From 4df9efc8f43f990a152641f7d513ca788538fc12 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 12 May 2026 18:59:41 +0900 Subject: [PATCH 01/16] fix(apple): dedupe purchase update emissions Track emitted iOS purchase transaction IDs for the current connection session so StoreKit replays cannot fire the same purchase update twice through requestPurchase and Transaction.updates paths. Refs #152 --- packages/apple/Sources/Helpers/IapState.swift | 24 +++++++++++++++ packages/apple/Sources/OpenIapModule.swift | 29 +++++++++++++++---- packages/apple/Tests/OpenIapTests.swift | 28 ++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift index 310cac01..de4a92bc 100644 --- a/packages/apple/Sources/Helpers/IapState.swift +++ b/packages/apple/Sources/Helpers/IapState.swift @@ -8,8 +8,11 @@ import StoreKit actor IapState { private(set) var isInitialized: Bool = false private var pendingTransactions: [String: Transaction] = [:] + private var emittedPurchaseUpdateIds: Set = [] + private var emittedPurchaseUpdateOrder: [String] = [] private var promotedProductId: String? private var pendingPromotedProductReplayId: String? + private let purchaseUpdateEmissionLimit = 512 // Event listeners private var purchaseUpdatedListeners: [(id: UUID, listener: PurchaseUpdatedListener)] = [] @@ -21,6 +24,8 @@ actor IapState { func setInitialized(_ value: Bool) { isInitialized = value } func reset() { pendingTransactions.removeAll() + emittedPurchaseUpdateIds.removeAll() + emittedPurchaseUpdateOrder.removeAll() isInitialized = false promotedProductId = nil pendingPromotedProductReplayId = nil @@ -31,6 +36,25 @@ actor IapState { func getPending(id: String) -> Transaction? { pendingTransactions[id] } func removePending(id: String) { pendingTransactions.removeValue(forKey: id) } func pendingSnapshot() -> [Transaction] { Array(pendingTransactions.values) } + func storePendingAndRecordPurchaseUpdateEmission(id: String, transaction: Transaction) -> Bool { + pendingTransactions[id] = transaction + return recordPurchaseUpdateEmission(id: id) + } + + // MARK: - Purchase Update Emissions + func recordPurchaseUpdateEmission(id: String) -> Bool { + guard !emittedPurchaseUpdateIds.contains(id) else { + return false + } + + emittedPurchaseUpdateIds.insert(id) + emittedPurchaseUpdateOrder.append(id) + if emittedPurchaseUpdateOrder.count > purchaseUpdateEmissionLimit { + let removed = emittedPurchaseUpdateOrder.removeFirst() + emittedPurchaseUpdateIds.remove(removed) + } + return true + } // MARK: - Promoted Products func setPromotedProductId(_ id: String?) { diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index db22f686..2f054f2c 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -376,16 +376,25 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { - Note: \(isSubscription ? "Subscription transactions will be emitted via Transaction.updates" : "Emitting directly") """) + let shouldEmit: Bool if shouldAutoFinish { await transaction.finish() + shouldEmit = await state.recordPurchaseUpdateEmission(id: transactionId) } else { - await state.storePending(id: transactionId, transaction: transaction) + shouldEmit = await state.storePendingAndRecordPurchaseUpdateEmission( + id: transactionId, + transaction: transaction + ) } // Emit purchase update - // Note: Transaction.updates will NOT fire for purchases initiated via product.purchase() - // It only fires for background events (renewals, restores, external purchases) - emitPurchaseUpdate(purchase) + // StoreKit can replay unfinished transactions through multiple paths during a + // connection session; only emit each transaction id once. + if shouldEmit { + emitPurchaseUpdate(purchase) + } else { + OpenIapLog.debug("⏭️ Skipping duplicate purchase update: \(transactionId)") + } return .purchase(purchase) @@ -1633,8 +1642,16 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { continue } - // Store pending and emit - await self.state.storePending(id: transactionId, transaction: transaction) + // Store pending and emit once per transaction id for this connection session. + let shouldEmit = await self.state.storePendingAndRecordPurchaseUpdateEmission( + id: transactionId, + transaction: transaction + ) + guard shouldEmit else { + OpenIapLog.debug("⏭️ [TransactionListener] Skipping duplicate transaction: \(transactionId)") + continue + } + let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) OpenIapLog.debug("✅ [TransactionListener] Emitting transaction: \(transactionId) for product: \(transaction.productID)") diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift index f4a6c768..61f67b2b 100644 --- a/packages/apple/Tests/OpenIapTests.swift +++ b/packages/apple/Tests/OpenIapTests.swift @@ -85,6 +85,34 @@ final class OpenIapTests: XCTestCase { XCTAssertNil(pendingSku) } + @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) + func testPurchaseUpdateEmissionDeduplicatesTransactionIds() async { + let state = IapState() + + let firstEmission = await state.recordPurchaseUpdateEmission(id: "txn-1") + let duplicateEmission = await state.recordPurchaseUpdateEmission(id: "txn-1") + let nextEmission = await state.recordPurchaseUpdateEmission(id: "txn-2") + + XCTAssertTrue(firstEmission) + XCTAssertFalse(duplicateEmission) + XCTAssertTrue(nextEmission) + } + + @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) + func testPurchaseUpdateEmissionResetAllowsReconnectReplay() async { + let state = IapState() + let firstEmission = await state.recordPurchaseUpdateEmission(id: "txn-1") + let duplicateEmission = await state.recordPurchaseUpdateEmission(id: "txn-1") + + XCTAssertTrue(firstEmission) + XCTAssertFalse(duplicateEmission) + + await state.reset() + + let replayEmission = await state.recordPurchaseUpdateEmission(id: "txn-1") + XCTAssertTrue(replayEmission) + } + func testPurchaseIOSWithRenewalInfo() { let renewalInfo = RenewalInfoIOS( autoRenewPreference: "dev.hyo.premium_year", From b9d704ad4cb67dc22873db15a16b3cb6d7de6c17 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 12:17:10 +0900 Subject: [PATCH 02/16] fix(apple): avoid stale duplicate pending updates --- packages/apple/Sources/Helpers/IapState.swift | 51 +++++++++++-------- packages/apple/Sources/OpenIapModule.swift | 20 +++----- 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift index de4a92bc..3141ee33 100644 --- a/packages/apple/Sources/Helpers/IapState.swift +++ b/packages/apple/Sources/Helpers/IapState.swift @@ -4,15 +4,41 @@ import StoreKit /// Thread-safe state manager for IAP transactions /// - SeeAlso: https://developer.apple.com/documentation/storekit/transaction +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +private struct PurchaseUpdateEmissionHistory { + private let limit: Int + private var ids: Set = [] + private var order: [String] = [] + + init(limit: Int) { + self.limit = limit + } + + mutating func record(_ id: String) -> Bool { + guard ids.insert(id).inserted else { + return false + } + + order.append(id) + if order.count > limit { + ids.remove(order.removeFirst()) + } + return true + } + + mutating func removeAll() { + ids.removeAll() + order.removeAll() + } +} + @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) actor IapState { private(set) var isInitialized: Bool = false private var pendingTransactions: [String: Transaction] = [:] - private var emittedPurchaseUpdateIds: Set = [] - private var emittedPurchaseUpdateOrder: [String] = [] + private var purchaseUpdateEmissionHistory = PurchaseUpdateEmissionHistory(limit: 512) private var promotedProductId: String? private var pendingPromotedProductReplayId: String? - private let purchaseUpdateEmissionLimit = 512 // Event listeners private var purchaseUpdatedListeners: [(id: UUID, listener: PurchaseUpdatedListener)] = [] @@ -24,8 +50,7 @@ actor IapState { func setInitialized(_ value: Bool) { isInitialized = value } func reset() { pendingTransactions.removeAll() - emittedPurchaseUpdateIds.removeAll() - emittedPurchaseUpdateOrder.removeAll() + purchaseUpdateEmissionHistory.removeAll() isInitialized = false promotedProductId = nil pendingPromotedProductReplayId = nil @@ -36,24 +61,10 @@ actor IapState { func getPending(id: String) -> Transaction? { pendingTransactions[id] } func removePending(id: String) { pendingTransactions.removeValue(forKey: id) } func pendingSnapshot() -> [Transaction] { Array(pendingTransactions.values) } - func storePendingAndRecordPurchaseUpdateEmission(id: String, transaction: Transaction) -> Bool { - pendingTransactions[id] = transaction - return recordPurchaseUpdateEmission(id: id) - } // MARK: - Purchase Update Emissions func recordPurchaseUpdateEmission(id: String) -> Bool { - guard !emittedPurchaseUpdateIds.contains(id) else { - return false - } - - emittedPurchaseUpdateIds.insert(id) - emittedPurchaseUpdateOrder.append(id) - if emittedPurchaseUpdateOrder.count > purchaseUpdateEmissionLimit { - let removed = emittedPurchaseUpdateOrder.removeFirst() - emittedPurchaseUpdateIds.remove(removed) - } - return true + purchaseUpdateEmissionHistory.record(id) } // MARK: - Promoted Products diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 2f054f2c..b8089472 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -376,15 +376,12 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { - Note: \(isSubscription ? "Subscription transactions will be emitted via Transaction.updates" : "Emitting directly") """) - let shouldEmit: Bool + let shouldEmit = await state.recordPurchaseUpdateEmission(id: transactionId) if shouldAutoFinish { await transaction.finish() - shouldEmit = await state.recordPurchaseUpdateEmission(id: transactionId) - } else { - shouldEmit = await state.storePendingAndRecordPurchaseUpdateEmission( - id: transactionId, - transaction: transaction - ) + await state.removePending(id: transactionId) + } else if shouldEmit { + await state.storePending(id: transactionId, transaction: transaction) } // Emit purchase update @@ -1642,15 +1639,12 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { continue } - // Store pending and emit once per transaction id for this connection session. - let shouldEmit = await self.state.storePendingAndRecordPurchaseUpdateEmission( - id: transactionId, - transaction: transaction - ) - guard shouldEmit else { + // Emit once per transaction id for this connection session. + guard await self.state.recordPurchaseUpdateEmission(id: transactionId) else { OpenIapLog.debug("⏭️ [TransactionListener] Skipping duplicate transaction: \(transactionId)") continue } + await self.state.storePending(id: transactionId, transaction: transaction) let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) From f51ad6df19f942a559aaf093c91a2ca9ffd9cdca Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 12:27:57 +0900 Subject: [PATCH 03/16] fix(apple): improve duplicate update logging --- packages/apple/Sources/OpenIapModule.swift | 27 ++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index b8089472..2bef865d 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -390,7 +390,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { if shouldEmit { emitPurchaseUpdate(purchase) } else { - OpenIapLog.debug("⏭️ Skipping duplicate purchase update: \(transactionId)") + logDuplicatePurchaseUpdateSuppressed( + source: "requestPurchase", + transactionId: transactionId, + productId: transaction.productID + ) } return .purchase(purchase) @@ -1641,7 +1645,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // Emit once per transaction id for this connection session. guard await self.state.recordPurchaseUpdateEmission(id: transactionId) else { - OpenIapLog.debug("⏭️ [TransactionListener] Skipping duplicate transaction: \(transactionId)") + self.logDuplicatePurchaseUpdateSuppressed( + source: "Transaction.updates", + transactionId: transactionId, + productId: transaction.productID + ) continue } await self.state.storePending(id: transactionId, transaction: transaction) @@ -1781,6 +1789,21 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } } + private func logDuplicatePurchaseUpdateSuppressed( + source: String, + transactionId: String, + productId: String + ) { + OpenIapLog.warn(""" + [PurchaseUpdateDedup] Suppressed duplicate purchase-updated listener emission. + - Source: \(source) + - Product: \(productId) + - Transaction ID: \(transactionId) + - Reason: this transaction id was already emitted during the current connection session. + - Scope: only identical transaction ids are suppressed; distinct StoreKit transactions still emit. + """) + } + private func emitPurchaseUpdate(_ purchase: Purchase) { Task { [state] in let listeners = await state.snapshotPurchaseUpdated() From 4baa12489d6642334e71fd6a73ea42e729d43106 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 13:22:05 +0900 Subject: [PATCH 04/16] feat(spec): add purchase update listener options --- libraries/expo-iap/ios/ExpoIapHelper.swift | 23 +++- libraries/expo-iap/ios/ExpoIapModule.swift | 1 + .../expo-iap/src/__tests__/index.test.ts | 12 ++ libraries/expo-iap/src/index.ts | 18 +-- libraries/expo-iap/src/types.ts | 22 +++- libraries/expo-iap/src/useIAP.ts | 9 ++ .../Classes/FlutterInappPurchasePlugin.swift | 29 ++-- .../lib/flutter_inapp_purchase.dart | 83 ++++++++---- .../flutter_inapp_purchase/lib/types.dart | 35 ++++- .../godot-iap/addons/godot-iap/godot_iap.gd | 26 ++++ libraries/godot-iap/addons/godot-iap/types.gd | 16 +++ .../godot-iap/ios-gdextension/Package.swift | 20 ++- .../Sources/GodotIap/GodotIap.swift | 36 ++++- libraries/kmp-iap/library/build.gradle.kts | 9 +- .../hyochan/kmpiap/InAppPurchaseAndroid.kt | 6 +- .../kotlin/io/github/hyochan/kmpiap/KmpIap.kt | 10 ++ .../io/github/hyochan/kmpiap/openiap/Types.kt | 29 +++- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 39 +++++- .../ApiDefinition.cs | 5 + .../maui-iap/src/OpenIap.Maui/OpenIap.cs | 8 ++ .../Platforms/Android/OpenIapAndroid.cs | 2 + .../OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs | 29 ++++ libraries/maui-iap/src/OpenIap.Maui/Types.cs | 15 ++- .../src/OpenIap.Maui/UnsupportedOpenIap.cs | 1 + .../java/com/margelo/nitro/iap/HybridRnIap.kt | 5 +- .../react-native-iap/ios/HybridRnIap.swift | 108 +++++++++++---- .../src/__tests__/index.test.ts | 31 +++++ .../react-native-iap/src/hooks/useIAP.ts | 8 ++ libraries/react-native-iap/src/index.ts | 77 +++++++++-- .../react-native-iap/src/specs/RnIap.nitro.ts | 8 +- libraries/react-native-iap/src/types.ts | 22 +++- openiap-versions.json | 2 +- packages/apple/Sources/Helpers/IapState.swift | 47 +++++-- packages/apple/Sources/Models/Types.swift | 21 ++- .../apple/Sources/OpenIapModule+ObjC.swift | 15 ++- packages/apple/Sources/OpenIapModule.swift | 91 ++++++++----- packages/apple/Sources/OpenIapProtocol.swift | 5 +- packages/apple/Sources/OpenIapStore.swift | 4 +- packages/apple/Tests/OpenIapTests.swift | 24 ++++ .../OpenIapTests/VerifyPurchaseTests.swift | 5 +- .../VerifyPurchaseWithProviderTests.swift | 5 +- packages/docs/openiap-versions.json | 2 +- packages/docs/src/lib/searchData.ts | 8 ++ .../docs/events/purchase-updated-listener.tsx | 85 ++++++++++-- packages/docs/src/pages/docs/index.tsx | 9 ++ packages/docs/src/pages/docs/types/index.tsx | 7 + .../purchase-updated-listener-options.tsx | 124 ++++++++++++++++++ .../docs/src/pages/docs/updates/releases.tsx | 112 ++++++++++++++++ .../src/main/java/dev/hyo/openiap/Types.kt | 29 +++- packages/gql/src/event.graphql | 5 +- packages/gql/src/generated/Types.cs | 15 ++- packages/gql/src/generated/Types.kt | 29 +++- packages/gql/src/generated/Types.swift | 21 ++- packages/gql/src/generated/types.dart | 35 ++++- packages/gql/src/generated/types.gd | 16 +++ packages/gql/src/generated/types.ts | 22 +++- packages/gql/src/type.graphql | 11 ++ 57 files changed, 1322 insertions(+), 169 deletions(-) create mode 100644 packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift index 4d077821..7baf92b2 100644 --- a/libraries/expo-iap/ios/ExpoIapHelper.swift +++ b/libraries/expo-iap/ios/ExpoIapHelper.swift @@ -128,6 +128,7 @@ enum ExpoIapHelper { static func setupListeners( module: ExpoIapModule, purchaseUpdated: @escaping (Purchase) -> Void, + purchaseUpdatedDuplicatesIOS: @escaping (Purchase) -> Void, purchaseError: @escaping (PurchaseError) -> Void, promotedProduct: @escaping (String) async -> Void, subscriptionBillingIssue: @escaping (Purchase) -> Void @@ -141,6 +142,15 @@ enum ExpoIapHelper { } } + let duplicateOptions = PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: true + ) + let purchaseUpdatedDuplicatesSub = OpenIapModule.shared.purchaseUpdatedListener({ purchase in + Task { @MainActor in + purchaseUpdatedDuplicatesIOS(purchase) + } + }, options: duplicateOptions) + let purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { error in Task { @MainActor in purchaseError(error) @@ -159,7 +169,13 @@ enum ExpoIapHelper { } } - listeners = [purchaseUpdatedSub, purchaseErrorSub, promotedProductSub, billingIssueSub] + listeners = [ + purchaseUpdatedSub, + purchaseUpdatedDuplicatesSub, + purchaseErrorSub, + promotedProductSub, + billingIssueSub, + ] } static func cleanupListeners() { @@ -176,6 +192,11 @@ enum ExpoIapHelper { let payload = sanitizeDictionary(OpenIapSerialization.purchase(purchase)) module.sendEvent(OpenIapEvent.purchaseUpdated.rawValue, payload) }, + purchaseUpdatedDuplicatesIOS: { [weak module] purchase in + guard let module else { return } + let payload = sanitizeDictionary(OpenIapSerialization.purchase(purchase)) + module.sendEvent("purchase-updated-duplicates-ios", payload) + }, purchaseError: { [weak module] error in guard let module else { return } let payload = sanitizeDictionary(OpenIapSerialization.encode(error)) diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index c220f614..8fb88476 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -20,6 +20,7 @@ public final class ExpoIapModule: Module { Events( OpenIapEvent.purchaseUpdated.rawValue, + "purchase-updated-duplicates-ios", OpenIapEvent.purchaseError.rawValue, OpenIapEvent.promotedProductIos.rawValue, OpenIapEvent.subscriptionBillingIssue.rawValue diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index 3b822f45..1fba955d 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -71,6 +71,18 @@ describe('Public API (index.ts)', () => { expect(fn).toHaveBeenCalledWith({...event, platform: 'ios'}); }); + it('registers duplicate-enabled purchase updated listener on iOS', () => { + const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const fn = jest.fn(); + purchaseUpdatedListener(fn, { + includeDuplicateTransactionUpdatesIOS: true, + }); + expect(addListener).toHaveBeenCalledWith( + OpenIapEvent.PurchaseUpdatedDuplicateIOS, + expect.any(Function), + ); + }); + it('registers purchase error listener', () => { const addListener = (ExpoIapModule as any).addListener as jest.Mock; const fn = jest.fn(); diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index bf37bf84..e015633c 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -30,6 +30,7 @@ import type { ProductSubscription, Purchase, PurchaseOptions, + PurchaseUpdatedListenerOptions, QueryField, RequestPurchasePropsByPlatforms, RequestPurchaseAndroidProps, @@ -51,6 +52,7 @@ export * from './onside'; // Get the native constant value export enum OpenIapEvent { PurchaseUpdated = 'purchase-updated', + PurchaseUpdatedDuplicateIOS = 'purchase-updated-duplicates-ios', PurchaseError = 'purchase-error', PromotedProductIOS = 'promoted-product-ios', UserChoiceBillingAndroid = 'user-choice-billing-android', @@ -69,6 +71,7 @@ export enum OpenIapEvent { type ExpoIapEventPayloads = { [OpenIapEvent.PurchaseUpdated]: Purchase; + [OpenIapEvent.PurchaseUpdatedDuplicateIOS]: Purchase; [OpenIapEvent.PurchaseError]: PurchaseError; [OpenIapEvent.PromotedProductIOS]: | Product @@ -159,15 +162,17 @@ const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] => export const purchaseUpdatedListener = ( listener: (event: Purchase) => void, + options?: PurchaseUpdatedListenerOptions | null, ) => { const wrappedListener = (event: Purchase) => { const normalized = normalizePurchasePlatform(event); listener(normalized); }; - const emitterSubscription = emitter.addListener( - OpenIapEvent.PurchaseUpdated, - wrappedListener, - ); + const eventName = + Platform.OS === 'ios' && options?.includeDuplicateTransactionUpdatesIOS + ? OpenIapEvent.PurchaseUpdatedDuplicateIOS + : OpenIapEvent.PurchaseUpdated; + const emitterSubscription = emitter.addListener(eventName, wrappedListener); return emitterSubscription; }; @@ -1099,10 +1104,7 @@ export type { UseWebhookEventsOptions, UseWebhookEventsResult, } from './useWebhookEvents'; -export { - connectWebhookStream, - parseWebhookEventData, -} from './webhook-client'; +export {connectWebhookStream, parseWebhookEventData} from './webhook-client'; export type { WebhookEventPayload, WebhookEventStream, diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 7eb00860..4432d3fe 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1292,6 +1292,16 @@ export interface PurchaseOptions { export type PurchaseState = 'pending' | 'purchased' | 'unknown'; +export interface PurchaseUpdatedListenerOptions { + /** + * iOS only. When true, listener callbacks also receive StoreKit replay events + * for a transaction ID that was already emitted during the current connection + * session. Defaults to false so purchase success handlers run once per + * transaction ID. + */ + includeDuplicateTransactionUpdatesIOS?: (boolean | null); +} + export type PurchaseVerificationProvider = 'iapkit'; export interface Query { @@ -1734,7 +1744,12 @@ export interface Subscription { promotedProductIOS: string; /** Fires when a purchase fails or is cancelled */ purchaseError: PurchaseError; - /** Fires when a purchase completes successfully or a pending purchase resolves */ + /** + * Fires when a purchase completes successfully or a pending purchase resolves + * Options can opt iOS listeners into duplicate StoreKit transaction replays + * for diagnostics; default listeners receive one event per transaction ID + * during a single connection session. + */ purchaseUpdated: Purchase; /** * Fires when an active subscription enters a billing-issue state that needs user action @@ -1758,6 +1773,9 @@ export interface Subscription { } + +export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined; + export interface SubscriptionInfoIOS { introductoryOffer?: (SubscriptionOfferIOS | null); promotionalOffers?: (SubscriptionOfferIOS[] | null); @@ -2218,7 +2236,7 @@ export type SubscriptionArgsMap = { developerProvidedBillingAndroid: never; promotedProductIOS: never; purchaseError: never; - purchaseUpdated: never; + purchaseUpdated: SubscriptionPurchaseUpdatedArgs; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; }; diff --git a/libraries/expo-iap/src/useIAP.ts b/libraries/expo-iap/src/useIAP.ts index 90853a68..4110eefb 100644 --- a/libraries/expo-iap/src/useIAP.ts +++ b/libraries/expo-iap/src/useIAP.ts @@ -43,6 +43,7 @@ import type { Purchase, MutationRequestPurchaseArgs, PurchaseInput, + PurchaseUpdatedListenerOptions, VerifyPurchaseProps, VerifyPurchaseResult, VerifyPurchaseWithProviderProps, @@ -115,6 +116,12 @@ type UseIap = { export interface UseIAPOptions { onPurchaseSuccess?: (purchase: Purchase) => void; onPurchaseError?: (error: PurchaseError) => void; + /** + * iOS only. When enabled, the purchase success listener also receives + * StoreKit replay events for a transaction ID already delivered during the + * current connection session. Defaults to false. + */ + purchaseUpdatedListenerOptions?: PurchaseUpdatedListenerOptions | null; /** * Callback for general errors from hook methods like fetchProducts, * getAvailablePurchases, getActiveSubscriptions, restorePurchases, etc. @@ -628,6 +635,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { optionsRef.current.onPurchaseSuccess(purchase); } }, + optionsRef.current?.purchaseUpdatedListenerOptions, ); // Register purchase error listener EARLY. Ignore init-related errors until connected. @@ -709,6 +717,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { optionsRef.current.onPurchaseSuccess(purchase); } }, + optionsRef.current?.purchaseUpdatedListenerOptions, ); } diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift index 166d072d..b7613144 100644 --- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift @@ -14,6 +14,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { private var updateListenerTask: Task? // OpenIAP listener tokens private var purchaseUpdatedToken: OpenIAP.Subscription? + private var purchaseUpdatedDuplicateToken: OpenIAP.Subscription? private var purchaseErrorToken: OpenIAP.Subscription? private var promotedProductToken: OpenIAP.Subscription? private var subscriptionBillingIssueToken: OpenIAP.Subscription? @@ -403,16 +404,16 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { FlutterIapLog.debug("Setting up OpenIAP listeners") purchaseUpdatedToken = OpenIapModule.shared.purchaseUpdatedListener { [weak self] purchase in - Task { @MainActor in - guard let self else { return } - FlutterIapLog.debug("purchaseUpdatedListener fired for \(purchase.productId)") - let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase)) - if let jsonString = FlutterIapHelper.jsonString(from: payload) { - self.channel?.invokeMethod("purchase-updated", arguments: jsonString) - } - } + self?.emitPurchaseUpdated(purchase, method: "purchase-updated") } + let duplicateOptions = PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: true + ) + purchaseUpdatedDuplicateToken = OpenIapModule.shared.purchaseUpdatedListener({ [weak self] purchase in + self?.emitPurchaseUpdated(purchase, method: "purchase-updated-duplicates-ios") + }, options: duplicateOptions) + purchaseErrorToken = OpenIapModule.shared.purchaseErrorListener { [weak self] error in Task { @MainActor in guard let self else { return } @@ -456,14 +457,26 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { private func removeOpenIapListeners() { if let token = purchaseUpdatedToken { OpenIapModule.shared.removeListener(token) } + if let token = purchaseUpdatedDuplicateToken { OpenIapModule.shared.removeListener(token) } if let token = purchaseErrorToken { OpenIapModule.shared.removeListener(token) } if let token = promotedProductToken { OpenIapModule.shared.removeListener(token) } if let token = subscriptionBillingIssueToken { OpenIapModule.shared.removeListener(token) } purchaseUpdatedToken = nil + purchaseUpdatedDuplicateToken = nil purchaseErrorToken = nil promotedProductToken = nil subscriptionBillingIssueToken = nil } + + private func emitPurchaseUpdated(_ purchase: Purchase, method: String) { + Task { @MainActor in + FlutterIapLog.debug("\(method) fired for \(purchase.productId)") + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase)) + if let jsonString = FlutterIapHelper.jsonString(from: payload) { + self.channel?.invokeMethod(method, arguments: jsonString) + } + } + } // All transaction event handling is routed via OpenIapModule listeners diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 27d418a0..f0ca37e4 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -89,6 +89,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { // Purchase event streams final StreamController _purchaseUpdatedListener = StreamController.broadcast(); + final StreamController _purchaseUpdatedDuplicateListener = + StreamController.broadcast(); final StreamController _purchaseErrorListener = StreamController.broadcast(); final StreamController @@ -104,6 +106,20 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { Stream get purchaseUpdatedListener => _purchaseUpdatedListener.stream; + /// Purchase updated event stream with listener options. + /// + /// On iOS, set [PurchaseUpdatedListenerOptions.includeDuplicateTransactionUpdatesIOS] + /// to true to also receive StoreKit replay events for transaction IDs already + /// delivered during the current connection session. Android ignores this flag. + Stream purchaseUpdatedListenerWithOptions( + gentype.PurchaseUpdatedListenerOptions? options, + ) { + if (isIOS && options?.includeDuplicateTransactionUpdatesIOS == true) { + return _purchaseUpdatedDuplicateListener.stream; + } + return purchaseUpdatedListener; + } + /// Purchase error event stream Stream get purchaseErrorListener => _purchaseErrorListener.stream; @@ -138,28 +154,14 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { _channel.setMethodCallHandler((MethodCall call) async { switch (call.method) { case 'purchase-updated': - try { - Map result = - jsonDecode(call.arguments as String) as Map; - - // Convert directly to Purchase without intermediate PurchasedItem - final purchase = convertToPurchase( - result, - originalJson: result, - platformIsAndroid: _platform.isAndroid, - platformIsIOS: _platform.isIOS || _platform.isMacOS, - acknowledgedAndroidPurchaseTokens: - _acknowledgedAndroidPurchaseTokens, - ); - - _purchaseController!.add(purchase); - _purchaseUpdatedListener.add(purchase); - } catch (e, stackTrace) { - debugPrint( - '[flutter_inapp_purchase] ERROR in purchase-updated: $e', - ); - debugPrint('[flutter_inapp_purchase] Stack trace: $stackTrace'); - } + _handlePurchaseUpdatedCall( + call, + _purchaseUpdatedListener, + publishToLegacyStream: true, + ); + break; + case 'purchase-updated-duplicates-ios': + _handlePurchaseUpdatedCall(call, _purchaseUpdatedDuplicateListener); break; case 'purchase-error': debugPrint( @@ -246,6 +248,35 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { }); } + void _handlePurchaseUpdatedCall( + MethodCall call, + StreamController controller, { + bool publishToLegacyStream = false, + }) { + try { + final result = + jsonDecode(call.arguments as String) as Map; + + final purchase = convertToPurchase( + result, + originalJson: result, + platformIsAndroid: _platform.isAndroid, + platformIsIOS: _platform.isIOS || _platform.isMacOS, + acknowledgedAndroidPurchaseTokens: _acknowledgedAndroidPurchaseTokens, + ); + + if (publishToLegacyStream) { + _purchaseController!.add(purchase); + } + controller.add(purchase); + } catch (e, stackTrace) { + debugPrint( + '[flutter_inapp_purchase] ERROR in ${call.method}: $e', + ); + debugPrint('[flutter_inapp_purchase] Stack trace: $stackTrace'); + } + } + /// Initialize the store connection. Must be called before any other IAP API. /// /// Parameters: @@ -2619,7 +2650,13 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { }, purchaseError: () async => await purchaseErrorListener.first as gentype.PurchaseError, - purchaseUpdated: () async => await purchaseUpdatedListener.first, + purchaseUpdated: ({bool? includeDuplicateTransactionUpdatesIOS}) => + purchaseUpdatedListenerWithOptions( + gentype.PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: + includeDuplicateTransactionUpdatesIOS, + ), + ).first, subscriptionBillingIssue: () async => await subscriptionBillingIssueListener.first, userChoiceBillingAndroid: () async => diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 93997a8c..23f81af9 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -4347,6 +4347,30 @@ class PurchaseOptions { } } +class PurchaseUpdatedListenerOptions { + const PurchaseUpdatedListenerOptions({ + this.includeDuplicateTransactionUpdatesIOS, + }); + + /// iOS only. When true, listener callbacks also receive StoreKit replay events + /// for a transaction ID that was already emitted during the current connection + /// session. Defaults to false so purchase success handlers run once per + /// transaction ID. + final bool? includeDuplicateTransactionUpdatesIOS; + + factory PurchaseUpdatedListenerOptions.fromJson(Map json) { + return PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: json['includeDuplicateTransactionUpdatesIOS'] as bool?, + ); + } + + Map toJson() { + return { + 'includeDuplicateTransactionUpdatesIOS': includeDuplicateTransactionUpdatesIOS, + }; + } +} + class RequestPurchaseAndroidProps { const RequestPurchaseAndroidProps({ this.developerBillingOption, @@ -5452,7 +5476,12 @@ abstract class SubscriptionResolver { /// Fires when a purchase fails or is cancelled Future purchaseError(); /// Fires when a purchase completes successfully or a pending purchase resolves - Future purchaseUpdated(); + /// Options can opt iOS listeners into duplicate StoreKit transaction replays + /// for diagnostics; default listeners receive one event per transaction ID + /// during a single connection session. + Future purchaseUpdated({ + bool? includeDuplicateTransactionUpdatesIOS, + }); /// Fires when an active subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: /// @@ -5672,7 +5701,9 @@ class QueryHandlers { typedef SubscriptionDeveloperProvidedBillingAndroidHandler = Future Function(); typedef SubscriptionPromotedProductIOSHandler = Future Function(); typedef SubscriptionPurchaseErrorHandler = Future Function(); -typedef SubscriptionPurchaseUpdatedHandler = Future Function(); +typedef SubscriptionPurchaseUpdatedHandler = Future Function({ + bool? includeDuplicateTransactionUpdatesIOS, +}); typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index db5145a9..c350724b 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -36,6 +36,7 @@ signal subscription_billing_issue(purchase: Dictionary) var _native_plugin: Object = null var _is_connected: bool = false static var _is_initialized: bool = false +var _purchase_updated_listener_options: Dictionary = {} # Platform detection var _platform: String = "" @@ -225,6 +226,7 @@ func init_connection() -> bool: print("[GodotIap] initConnection result: ", _is_connected) elif _platform == "iOS": print("[GodotIap] Calling iOS initConnection...") + _apply_purchase_updated_listener_options_ios() _is_connected = _native_plugin.call("initConnection") if not _is_connected: print("[GodotIap] ERROR: initConnection failed. Check StoreKit configuration.") @@ -257,6 +259,30 @@ func end_connection() -> bool: func is_store_connected() -> bool: return _is_connected +## Configure purchase update listener options. +## +## On iOS, set [code]include_duplicate_transaction_updates_ios[/code] to true +## to also receive StoreKit replay events for transaction IDs already delivered +## during the current connection session. Android ignores this flag. +func set_purchase_updated_listener_options(options = null) -> void: + if typeof(options) == TYPE_OBJECT and options.has_method("to_dict"): + _purchase_updated_listener_options = options.to_dict() + elif options is Dictionary: + _purchase_updated_listener_options = options + else: + _purchase_updated_listener_options = {} + _apply_purchase_updated_listener_options_ios() + +func _apply_purchase_updated_listener_options_ios() -> void: + if _platform != "iOS" or not _native_plugin: + return + if not _native_plugin.has_method("setPurchaseUpdatedListenerOptions"): + return + _native_plugin.call( + "setPurchaseUpdatedListenerOptions", + JSON.stringify(_purchase_updated_listener_options) + ) + # ========================================== # Products (OpenIAP Query) # ========================================== diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 29d37286..7809a85f 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -3850,6 +3850,22 @@ class PurchaseOptions: dict["includeSuspendedAndroid"] = include_suspended_android return dict +class PurchaseUpdatedListenerOptions: + ## iOS only. When true, listener callbacks also receive StoreKit replay events + var include_duplicate_transaction_updates_ios: Variant = null + + static func from_dict(data: Dictionary) -> PurchaseUpdatedListenerOptions: + var obj = PurchaseUpdatedListenerOptions.new() + if data.has("includeDuplicateTransactionUpdatesIOS") and data["includeDuplicateTransactionUpdatesIOS"] != null: + obj.include_duplicate_transaction_updates_ios = data["includeDuplicateTransactionUpdatesIOS"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if include_duplicate_transaction_updates_ios != null: + dict["includeDuplicateTransactionUpdatesIOS"] = include_duplicate_transaction_updates_ios + return dict + class RequestPurchaseAndroidProps: ## List of product SKUs var skus: Array[String] = [] diff --git a/libraries/godot-iap/ios-gdextension/Package.swift b/libraries/godot-iap/ios-gdextension/Package.swift index 9ef2d02b..f300cd21 100644 --- a/libraries/godot-iap/ios-gdextension/Package.swift +++ b/libraries/godot-iap/ios-gdextension/Package.swift @@ -27,6 +27,24 @@ let linkerSettings: [LinkerSetting] = [ .unsafeFlags(["-Xlinker", "-dead_strip"]) ] +let packageDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() +let repoRoot = packageDir + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() +let localOpenIapPackage = repoRoot.appendingPathComponent("packages/apple") +let openIapDependency: Package.Dependency +if FileManager.default.fileExists( + atPath: localOpenIapPackage.appendingPathComponent("Package.swift").path +) { + openIapDependency = .package(name: "openiap", path: localOpenIapPackage.path) +} else { + openIapDependency = .package( + url: "https://github.com/hyodotdev/openiap.git", + .upToNextMinor(from: Version(stringLiteral: openIapVersion)) + ) +} + let package = Package( name: "GodotIap", platforms: [ @@ -38,7 +56,7 @@ let package = Package( ], dependencies: [ .package(name: "SwiftGodot", path: "../SwiftGodot"), - .package(url: "https://github.com/hyodotdev/openiap.git", .upToNextMinor(from: Version(stringLiteral: openIapVersion))), + openIapDependency, ], targets: [ .target( diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index c9fb4ffa..49bdb4a1 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -65,6 +65,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { private var purchaseErrorSubscription: Subscription? private var promotedProductSubscription: Subscription? private var subscriptionBillingIssueSubscription: Subscription? + private var includeDuplicatePurchaseUpdatesIOS = false // MARK: - Initialization required init(_ context: InitContext) { @@ -113,6 +114,24 @@ public class GodotIap: RefCounted, @unchecked Sendable { return true // Return optimistically, actual result via signal } + @Callable + public func setPurchaseUpdatedListenerOptions(optionsJson: String) -> Bool { + let data = Data(optionsJson.utf8) + let decoded = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + includeDuplicatePurchaseUpdatesIOS = + decoded?["includeDuplicateTransactionUpdatesIOS"] as? Bool == true + + if isConnected { + if let sub = purchaseUpdateSubscription { + openIap.removeListener(sub) + purchaseUpdateSubscription = nil + } + setupPurchaseUpdatedListener() + } + + return true + } + @Callable public func endConnection() -> Bool { GodotIapLog.payload("endConnection", payload: nil) @@ -1301,11 +1320,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { // MARK: - Private Helpers private func setupListeners() { - purchaseUpdateSubscription = openIap.purchaseUpdatedListener { [weak self] purchase in - Task { @MainActor in - self?.emitPurchaseUpdated(purchase: purchase) - } - } + setupPurchaseUpdatedListener() purchaseErrorSubscription = openIap.purchaseErrorListener { [weak self] error in Task { @MainActor in @@ -1329,6 +1344,17 @@ public class GodotIap: RefCounted, @unchecked Sendable { } } + private func setupPurchaseUpdatedListener() { + let options = PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: includeDuplicatePurchaseUpdatesIOS + ) + purchaseUpdateSubscription = openIap.purchaseUpdatedListener({ [weak self] purchase in + Task { @MainActor in + self?.emitPurchaseUpdated(purchase: purchase) + } + }, options: options) + } + private func removeListeners() { if let sub = purchaseUpdateSubscription { openIap.removeListener(sub) diff --git a/libraries/kmp-iap/library/build.gradle.kts b/libraries/kmp-iap/library/build.gradle.kts index 21de5b0d..ce50cb22 100644 --- a/libraries/kmp-iap/library/build.gradle.kts +++ b/libraries/kmp-iap/library/build.gradle.kts @@ -23,6 +23,7 @@ val googleVersion = if (openIapVersionsFile.exists()) { } else { "1.2.10" } +val localApplePodspecDir = rootProject.file("../../packages/apple") println("DEBUG: OpenIAP versions loaded - Apple: $appleVersion, Google: $googleVersion") @@ -149,8 +150,12 @@ kotlin { pod("openiap") { version = appleVersion - source = git("https://github.com/hyodotdev/openiap.git") { - tag = appleVersion + source = if (localApplePodspecDir.resolve("openiap.podspec").exists()) { + path(localApplePodspecDir) + } else { + git("https://github.com/hyodotdev/openiap.git") { + tag = appleVersion + } } moduleName = "OpenIAP" } diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index 66437930..39b1ca6b 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -54,6 +54,7 @@ import io.github.hyochan.kmpiap.openiap.IapPlatform import io.github.hyochan.kmpiap.openiap.ProductIOS import io.github.hyochan.kmpiap.openiap.PurchaseError import io.github.hyochan.kmpiap.openiap.PurchaseOptions +import io.github.hyochan.kmpiap.openiap.PurchaseUpdatedListenerOptions import io.github.hyochan.kmpiap.openiap.QueryFetchProductsHandler import io.github.hyochan.kmpiap.openiap.QueryGetActiveSubscriptionsHandler import io.github.hyochan.kmpiap.openiap.QueryGetAvailablePurchasesHandler @@ -125,6 +126,8 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife // --------------------------------------------------------------------- private val _purchaseUpdatedListener = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) override val purchaseUpdatedListener: Flow = _purchaseUpdatedListener.asSharedFlow() + override fun purchaseUpdatedListener(options: PurchaseUpdatedListenerOptions?): Flow = + purchaseUpdatedListener private val _purchaseErrorListener = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) override val purchaseErrorListener: Flow = _purchaseErrorListener.asSharedFlow() @@ -708,7 +711,8 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife override suspend fun purchaseError(): PurchaseError = purchaseErrorListener.first() - override suspend fun purchaseUpdated(): Purchase = purchaseUpdatedListener.first() + override suspend fun purchaseUpdated(options: PurchaseUpdatedListenerOptions?): Purchase = + purchaseUpdatedListener(options).first() override suspend fun subscriptionBillingIssue(): Purchase = subscriptionBillingIssueListener.first() diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt index 760b3199..e30e7528 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt @@ -65,6 +65,7 @@ typealias RequestPurchaseIosProps = io.github.hyochan.kmpiap.openiap.RequestPurc typealias RequestPurchaseAndroidProps = io.github.hyochan.kmpiap.openiap.RequestPurchaseAndroidProps typealias RequestSubscriptionAndroidProps = io.github.hyochan.kmpiap.openiap.RequestSubscriptionAndroidProps typealias PurchaseOptions = io.github.hyochan.kmpiap.openiap.PurchaseOptions +typealias PurchaseUpdatedListenerOptions = io.github.hyochan.kmpiap.openiap.PurchaseUpdatedListenerOptions typealias DeepLinkOptions = io.github.hyochan.kmpiap.openiap.DeepLinkOptions typealias ValidationOptions = io.github.hyochan.kmpiap.openiap.VerifyPurchaseProps typealias ValidationResult = io.github.hyochan.kmpiap.openiap.VerifyPurchaseResult @@ -105,6 +106,15 @@ interface KmpInAppPurchase : MutationResolver, QueryResolver, SubscriptionResolv */ val purchaseUpdatedListener: Flow + /** + * Listener for observing purchase updates with subscription options. + * + * On iOS, set [PurchaseUpdatedListenerOptions.includeDuplicateTransactionUpdatesIOS] + * to true to also receive StoreKit replay events for transaction IDs already + * delivered during the current connection session. Android ignores this flag. + */ + fun purchaseUpdatedListener(options: PurchaseUpdatedListenerOptions? = null): Flow + /** * Listener for observing purchase errors * Collect this Flow to receive error events diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index 9f4afca3..e637dfdd 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -4415,6 +4415,28 @@ public data class PurchaseOptions( ) } +public data class PurchaseUpdatedListenerOptions( + /** + * iOS only. When true, listener callbacks also receive StoreKit replay events + * for a transaction ID that was already emitted during the current connection + * session. Defaults to false so purchase success handlers run once per + * transaction ID. + */ + val includeDuplicateTransactionUpdatesIOS: Boolean? = null +) { + companion object { + fun fromJson(json: Map): PurchaseUpdatedListenerOptions { + return PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS = json["includeDuplicateTransactionUpdatesIOS"] as? Boolean, + ) + } + } + + fun toJson(): Map = mapOf( + "includeDuplicateTransactionUpdatesIOS" to includeDuplicateTransactionUpdatesIOS, + ) +} + public data class RequestPurchaseAndroidProps( /** * Developer billing option parameters for external payments flow (8.3.0+). @@ -5562,8 +5584,11 @@ public interface SubscriptionResolver { suspend fun purchaseError(): PurchaseError /** * Fires when a purchase completes successfully or a pending purchase resolves + * Options can opt iOS listeners into duplicate StoreKit transaction replays + * for diagnostics; default listeners receive one event per transaction ID + * during a single connection session. */ - suspend fun purchaseUpdated(): Purchase + suspend fun purchaseUpdated(options: PurchaseUpdatedListenerOptions? = null): Purchase /** * Fires when an active subscription enters a billing-issue state that needs user action * (payment method failed, card expired, etc.). Cross-platform unification: @@ -5698,7 +5723,7 @@ public data class QueryHandlers( public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = suspend () -> DeveloperProvidedBillingDetailsAndroid public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError -public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase +public typealias SubscriptionPurchaseUpdatedHandler = suspend (options: PurchaseUpdatedListenerOptions?) -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 6ee42735..c8bdba52 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -29,6 +29,22 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { ) override val purchaseUpdatedListener: Flow = _purchaseUpdatedFlow.asSharedFlow() + private val _purchaseUpdatedDuplicateFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val purchaseUpdatedDuplicateListener: Flow = + _purchaseUpdatedDuplicateFlow.asSharedFlow() + + override fun purchaseUpdatedListener(options: PurchaseUpdatedListenerOptions?): Flow { + return if (options?.includeDuplicateTransactionUpdatesIOS == true) { + purchaseUpdatedDuplicateListener + } else { + purchaseUpdatedListener + } + } + private val _purchaseErrorFlow = MutableSharedFlow( replay = 0, extraBufferCapacity = 64, @@ -59,6 +75,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // Listener subscriptions private var purchaseSubscription: NSObject? = null + private var purchaseDuplicateSubscription: NSObject? = null private var errorSubscription: NSObject? = null private var promotedProductSubscription: NSObject? = null private var subscriptionBillingIssueSubscription: NSObject? = null @@ -84,6 +101,19 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } + purchaseDuplicateSubscription = openIapModule.addPurchaseUpdatedListener( + { dictionary -> + println("[KMP-IAP iOS] Duplicate-enabled purchase updated received: $dictionary") + val purchase = convertAnyToPurchase(dictionary) + if (purchase != null) { + coroutineScope.launch { + _purchaseUpdatedDuplicateFlow.emit(purchase) + } + } + }, + true + ) + // Purchase error listener errorSubscription = openIapModule.addPurchaseErrorListener { dictionary -> println("[KMP-IAP iOS] Purchase error received: $dictionary") @@ -172,6 +202,8 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // can freshly re-register without orphaning the previous subscriptions. purchaseSubscription?.let { openIapModule.removeListener(it) } purchaseSubscription = null + purchaseDuplicateSubscription?.let { openIapModule.removeListener(it) } + purchaseDuplicateSubscription = null errorSubscription?.let { openIapModule.removeListener(it) } errorSubscription = null promotedProductSubscription?.let { openIapModule.removeListener(it) } @@ -1011,9 +1043,8 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // SubscriptionResolver Implementation // ------------------------------------------------------------------------- - override suspend fun purchaseUpdated(): Purchase { - throw UnsupportedOperationException("Use purchaseUpdatedListener Flow instead") - } + override suspend fun purchaseUpdated(options: PurchaseUpdatedListenerOptions?): Purchase = + purchaseUpdatedListener(options).first() override suspend fun purchaseError(): PurchaseError { throw UnsupportedOperationException("Use purchaseErrorListener Flow instead") @@ -1526,4 +1557,4 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { emptyList() } } -} \ No newline at end of file +} diff --git a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs index 98685c5c..681960cb 100644 --- a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs +++ b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs @@ -217,6 +217,11 @@ void VerifyPurchaseWithProvider( [Export("addPurchaseUpdatedListener:")] NSObject AddPurchaseUpdatedListener(Action callback); + [Export("addPurchaseUpdatedListener:includeDuplicateTransactionUpdatesIOS:")] + NSObject AddPurchaseUpdatedListener( + Action callback, + bool includeDuplicateTransactionUpdatesIOS); + [Export("addPurchaseErrorListener:")] NSObject AddPurchaseErrorListener(Action callback); diff --git a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs index 51237849..f36b2471 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs @@ -47,6 +47,14 @@ public interface IOpenIap /// IObservable PurchaseUpdated { get; } + /// + /// Stream of successful purchase updates with listener options. On iOS, + /// + /// also emits StoreKit replay events for transaction IDs already delivered + /// during the current connection session. Android ignores this flag. + /// + IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null); + /// /// Stream of purchase failures. Mirrors /// SubscriptionResolver.purchaseError. diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs index ab8e5505..5060715f 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs @@ -62,6 +62,8 @@ private void RefreshCurrentActivity() } public IObservable PurchaseUpdated => _purchaseUpdated; + public IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null) => + PurchaseUpdated; public IObservable PurchaseError => _purchaseError; public IObservable PromotedProductIOS => _promotedProductIOS; public IObservable SubscriptionBillingIssue => _subscriptionBillingIssue; diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs index 87764316..50f835e9 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs @@ -39,15 +39,18 @@ internal class OpenIapIOS : IOpenIap, QueryResolver, MutationResolver, IDisposab private readonly OpenIapModule _module = OpenIapModule.SharedInstance(); private readonly Subject _purchaseUpdated = new(); + private readonly Subject _purchaseUpdatedDuplicate = new(); private readonly Subject _purchaseError = new(); private readonly Subject _promotedProductIOS = new(); private readonly Subject _subscriptionBillingIssue = new(); private NSObject? _purchaseUpdatedToken; + private NSObject? _purchaseUpdatedDuplicateToken; private NSObject? _purchaseErrorToken; private NSObject? _promotedProductToken; private NSObject? _billingIssueToken; private readonly Action _purchaseUpdatedCallback; + private readonly Action _purchaseUpdatedDuplicateCallback; private readonly Action _purchaseErrorCallback; private readonly Action _promotedProductCallback; private readonly Action _billingIssueCallback; @@ -71,6 +74,21 @@ public OpenIapIOS() } }; + _purchaseUpdatedDuplicateCallback = dict => + { + try + { + var node = NSObjectJsonBridge.DictToObject(dict); + if (node is null) return; + var p = node.Deserialize(JsonOptions.Default); + if (p is not null) _purchaseUpdatedDuplicate.OnNext(p); + } + catch (Exception ex) + { + Console.WriteLine($"[OpenIapIOS] duplicate purchaseUpdated listener failed: {ex.Message}"); + } + }; + _purchaseErrorCallback = dict => { try @@ -116,6 +134,10 @@ public OpenIapIOS() } public IObservable PurchaseUpdated => _purchaseUpdated; + public IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null) => + options?.IncludeDuplicateTransactionUpdatesIOS == true + ? _purchaseUpdatedDuplicate + : _purchaseUpdated; public IObservable PurchaseError => _purchaseError; public IObservable PromotedProductIOS => _promotedProductIOS; public IObservable SubscriptionBillingIssue => _subscriptionBillingIssue; @@ -134,6 +156,9 @@ private void WireListeners() // can never escape into mono's native unwind path — that path has no // managed handler and aborts the process with SIGABRT. _purchaseUpdatedToken = _module.AddPurchaseUpdatedListener(_purchaseUpdatedCallback); + _purchaseUpdatedDuplicateToken = _module.AddPurchaseUpdatedListener( + _purchaseUpdatedDuplicateCallback, + includeDuplicateTransactionUpdatesIOS: true); _purchaseErrorToken = _module.AddPurchaseErrorListener(_purchaseErrorCallback); _promotedProductToken = _module.AddPromotedProductListener(_promotedProductCallback); _billingIssueToken = _module.AddSubscriptionBillingIssueListener(_billingIssueCallback); @@ -143,6 +168,7 @@ private void WireListeners() public void Dispose() { NSObject? purchaseUpdatedToken; + NSObject? purchaseUpdatedDuplicateToken; NSObject? purchaseErrorToken; NSObject? promotedProductToken; NSObject? billingIssueToken; @@ -153,17 +179,20 @@ public void Dispose() _disposed = true; purchaseUpdatedToken = _purchaseUpdatedToken; + purchaseUpdatedDuplicateToken = _purchaseUpdatedDuplicateToken; purchaseErrorToken = _purchaseErrorToken; promotedProductToken = _promotedProductToken; billingIssueToken = _billingIssueToken; _purchaseUpdatedToken = null; + _purchaseUpdatedDuplicateToken = null; _purchaseErrorToken = null; _promotedProductToken = null; _billingIssueToken = null; } RemoveListener(purchaseUpdatedToken, nameof(_purchaseUpdatedToken)); + RemoveListener(purchaseUpdatedDuplicateToken, nameof(_purchaseUpdatedDuplicateToken)); RemoveListener(purchaseErrorToken, nameof(_purchaseErrorToken)); RemoveListener(promotedProductToken, nameof(_promotedProductToken)); RemoveListener(billingIssueToken, nameof(_billingIssueToken)); diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index 91e82bf4..1562711d 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -3762,6 +3762,16 @@ public sealed record PurchaseOptions public bool? IncludeSuspendedAndroid { get; init; } } +public sealed record PurchaseUpdatedListenerOptions +{ + /// iOS only. When true, listener callbacks also receive StoreKit replay events + /// for a transaction ID that was already emitted during the current connection + /// session. Defaults to false so purchase success handlers run once per + /// transaction ID. + [JsonPropertyName("includeDuplicateTransactionUpdatesIOS")] + public bool? IncludeDuplicateTransactionUpdatesIOS { get; init; } +} + public sealed record RequestPurchaseAndroidProps { /// List of product SKUs @@ -4347,7 +4357,10 @@ public interface SubscriptionResolver Task PurchaseErrorAsync(); /// Fires when a purchase completes successfully or a pending purchase resolves - Task PurchaseUpdatedAsync(); + /// Options can opt iOS listeners into duplicate StoreKit transaction replays + /// for diagnostics; default listeners receive one event per transaction ID + /// during a single connection session. + Task PurchaseUpdatedAsync(PurchaseUpdatedListenerOptions? options = null); /// Fires when an active subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: diff --git a/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs b/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs index 5a3cddac..9afae7f3 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs @@ -13,6 +13,7 @@ namespace OpenIap.Maui; internal sealed class UnsupportedOpenIap : IOpenIap { public IObservable PurchaseUpdated => EmptyObservable.Instance; + public IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null) => PurchaseUpdated; public IObservable PurchaseError => EmptyObservable.Instance; public IObservable PromotedProductIOS => EmptyObservable.Instance; public IObservable SubscriptionBillingIssue => EmptyObservable.Instance; diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 4b960d8b..bef88d5a 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -783,7 +783,10 @@ class HybridRnIap : HybridRnIapSpec() { get() = 0L // Event listener methods - override fun addPurchaseUpdatedListener(listener: (purchase: NitroPurchase) -> Unit) { + override fun addPurchaseUpdatedListener( + listener: (purchase: NitroPurchase) -> Unit, + options: NitroPurchaseUpdatedListenerOptions? + ) { synchronized(purchaseUpdatedListeners) { purchaseUpdatedListeners.add(listener) } diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index 1aac6e47..70867290 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -11,10 +11,12 @@ class HybridRnIap: HybridRnIapSpec { private var productTypeBySku: [String: String] = [:] // OpenIAP event subscriptions private var purchaseUpdatedSub: Subscription? + private var purchaseUpdatedDuplicateSub: Subscription? private var purchaseErrorSub: Subscription? private var promotedProductSub: Subscription? // Event listeners private var purchaseUpdatedListeners: [(NitroPurchase) -> Void] = [] + private var purchaseUpdatedDuplicateListeners: [(NitroPurchase) -> Void] = [] private var purchaseErrorListeners: [(NitroPurchaseResult) -> Void] = [] private var promotedProductListeners: [(NitroProduct) -> Void] = [] private var subscriptionBillingIssueListeners: [(NitroPurchase) -> Void] = [] @@ -942,8 +944,28 @@ class HybridRnIap: HybridRnIapSpec { // MARK: - Event Listener Methods - func addPurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws { - listenerLock.withLock { purchaseUpdatedListeners.append(listener) } + func addPurchaseUpdatedListener( + listener: @escaping (NitroPurchase) -> Void, + options: NitroPurchaseUpdatedListenerOptions? + ) throws { + let includeDuplicateTransactionUpdatesIOS: Bool = { + if case .second(let enabled) = options?.includeDuplicateTransactionUpdatesIOS { + return enabled + } + return false + }() + listenerLock.withLock { + if includeDuplicateTransactionUpdatesIOS { + purchaseUpdatedDuplicateListeners.append(listener) + } else { + purchaseUpdatedListeners.append(listener) + } + } + if includeDuplicateTransactionUpdatesIOS { + attachDuplicatePurchaseUpdatedSubIfNeeded() + } else { + attachPurchaseUpdatedSubIfNeeded() + } } func addPurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws { @@ -951,7 +973,10 @@ class HybridRnIap: HybridRnIapSpec { } func removePurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws { - listenerLock.withLock { purchaseUpdatedListeners.removeAll() } + listenerLock.withLock { + purchaseUpdatedListeners.removeAll() + purchaseUpdatedDuplicateListeners.removeAll() + } } func removePurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws { @@ -970,26 +995,7 @@ class HybridRnIap: HybridRnIapSpec { // MARK: - Private Helper Methods private func attachListenersIfNeeded() { - if purchaseUpdatedSub == nil { - RnIapLog.payload("purchaseUpdatedListener.register", nil) - purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in - guard let self else { - RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, purchase event dropped") - return - } - Task { @MainActor in - let rawPayload = OpenIapSerialization.purchase(openIapPurchase) - let payload = RnIapHelper.sanitizeDictionary(rawPayload) - RnIapLog.result("purchaseUpdatedListener", payload) - if let identifier = rawPayload["id"] as? String { - self.purchasePayloadById[identifier] = rawPayload - } - let nitro = RnIapHelper.convertPurchaseDictionary(payload) - self.sendPurchaseUpdate(nitro) - } - } - RnIapLog.result("purchaseUpdatedListener.register", "attached") - } + attachPurchaseUpdatedSubIfNeeded() if purchaseErrorSub == nil { RnIapLog.payload("purchaseErrorListener.register", nil) @@ -1048,6 +1054,55 @@ class HybridRnIap: HybridRnIapSpec { } } + private func attachPurchaseUpdatedSubIfNeeded() { + if purchaseUpdatedSub == nil { + RnIapLog.payload("purchaseUpdatedListener.register", nil) + purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in + guard let self else { + RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, purchase event dropped") + return + } + Task { @MainActor in + let rawPayload = OpenIapSerialization.purchase(openIapPurchase) + let payload = RnIapHelper.sanitizeDictionary(rawPayload) + RnIapLog.result("purchaseUpdatedListener", payload) + if let identifier = rawPayload["id"] as? String { + self.purchasePayloadById[identifier] = rawPayload + } + let nitro = RnIapHelper.convertPurchaseDictionary(payload) + self.sendPurchaseUpdate(nitro, includeDuplicateListeners: false) + } + } + RnIapLog.result("purchaseUpdatedListener.register", "attached") + } + } + + private func attachDuplicatePurchaseUpdatedSubIfNeeded() { + if purchaseUpdatedDuplicateSub == nil { + RnIapLog.payload("purchaseUpdatedListener.register.duplicates", nil) + let options = PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: true + ) + purchaseUpdatedDuplicateSub = OpenIapModule.shared.purchaseUpdatedListener({ [weak self] openIapPurchase in + guard let self else { + RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, duplicate-enabled purchase event dropped") + return + } + Task { @MainActor in + let rawPayload = OpenIapSerialization.purchase(openIapPurchase) + let payload = RnIapHelper.sanitizeDictionary(rawPayload) + RnIapLog.result("purchaseUpdatedListener.duplicates", payload) + if let identifier = rawPayload["id"] as? String { + self.purchasePayloadById[identifier] = rawPayload + } + let nitro = RnIapHelper.convertPurchaseDictionary(payload) + self.sendPurchaseUpdate(nitro, includeDuplicateListeners: true) + } + }, options: options) + RnIapLog.result("purchaseUpdatedListener.register.duplicates", "attached") + } + } + private func attachSubscriptionBillingIssueSubIfNeeded() { guard subscriptionBillingIssueSub == nil else { return } RnIapLog.payload("subscriptionBillingIssueListener.register", nil) @@ -1075,7 +1130,7 @@ class HybridRnIap: HybridRnIapSpec { } } - private func sendPurchaseUpdate(_ purchase: NitroPurchase) { + private func sendPurchaseUpdate(_ purchase: NitroPurchase, includeDuplicateListeners: Bool) { let originalTxId: String if case .second(let val) = purchase.originalTransactionIdentifierIOS { originalTxId = val } else { originalTxId = "" } let purchaseTokenStr: String @@ -1091,6 +1146,10 @@ class HybridRnIap: HybridRnIapSpec { var isDuplicate = false let snapshot: [(NitroPurchase) -> Void] = listenerLock.withLock { + if includeDuplicateListeners { + return Array(purchaseUpdatedDuplicateListeners) + } + if deliveredPurchaseEventKeys.contains(eventKey) { isDuplicate = true return [] @@ -1203,6 +1262,7 @@ class HybridRnIap: HybridRnIapSpec { // Clear event listeners, error dedup state, and delivery state (thread-safe) listenerLock.withLock { purchaseUpdatedListeners.removeAll() + purchaseUpdatedDuplicateListeners.removeAll() purchaseErrorListeners.removeAll() promotedProductListeners.removeAll() subscriptionBillingIssueListeners.removeAll() diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 8771602c..153435c4 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -162,6 +162,37 @@ describe('Public API (src/index.ts)', () => { expect(listener).not.toHaveBeenCalled(); }); + it('routes duplicate-enabled purchaseUpdatedListener through opt-in native listener', () => { + const defaultListener = jest.fn(); + const duplicateListener = jest.fn(); + IAP.purchaseUpdatedListener(defaultListener); + IAP.purchaseUpdatedListener(duplicateListener, { + includeDuplicateTransactionUpdatesIOS: true, + }); + + expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2); + expect(mockIap.addPurchaseUpdatedListener.mock.calls[1][1]).toEqual({ + includeDuplicateTransactionUpdatesIOS: true, + }); + + const nitroPurchase = { + id: 't1', + productId: 'p1', + transactionDate: Date.now(), + platform: 'ios', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }; + mockIap.addPurchaseUpdatedListener.mock.calls[0][0](nitroPurchase); + expect(defaultListener).toHaveBeenCalledTimes(1); + expect(duplicateListener).not.toHaveBeenCalled(); + + mockIap.addPurchaseUpdatedListener.mock.calls[1][0](nitroPurchase); + expect(defaultListener).toHaveBeenCalledTimes(1); + expect(duplicateListener).toHaveBeenCalledTimes(1); + }); + it('purchaseErrorListener forwards error objects and supports removal', () => { const listener = jest.fn(); const sub = IAP.purchaseErrorListener(listener); diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index cf47efd5..1624f0fa 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -48,6 +48,7 @@ import type { Product, Purchase, PurchaseError, + PurchaseUpdatedListenerOptions, ProductSubscription, } from '../types'; import type {MutationFinishTransactionArgs} from '../types'; @@ -268,6 +269,12 @@ type UseIap = { export interface UseIapOptions { onPurchaseSuccess?: (purchase: Purchase) => void; + /** + * Options for the purchase success listener. iOS defaults to suppressing + * StoreKit replay events for the same transaction ID; set + * `includeDuplicateTransactionUpdatesIOS` to true only for diagnostics. + */ + purchaseUpdatedListenerOptions?: PurchaseUpdatedListenerOptions | null; onPurchaseError?: (error: PurchaseError) => void; /** Callback for non-purchase errors (fetchProducts, getAvailablePurchases, etc.) */ onError?: (error: Error) => void; @@ -593,6 +600,7 @@ export function useIAP(options?: UseIapOptions): UseIap { optionsRef.current.onPurchaseSuccess(purchase); } }, + optionsRef.current?.purchaseUpdatedListenerOptions, ); } diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index b0be229c..2da5ae2d 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -10,6 +10,7 @@ import type { NitroReceiptValidationParams, NitroReceiptValidationResultIOS, NitroReceiptValidationResultAndroid, + NitroPurchaseUpdatedListenerOptions, NitroSubscriptionStatus, RnIap, } from './specs/RnIap.nitro'; @@ -30,6 +31,7 @@ import type { ProductSubscription, Purchase, PurchaseError, + PurchaseUpdatedListenerOptions, PurchaseIOS, QueryField, AppTransaction, @@ -97,6 +99,9 @@ type NitroFinishTransactionParamsInternal = Parameters< RnIap['finishTransaction'] >[0]; type NitroPurchaseListener = Parameters[0]; +type NitroPurchaseUpdatedListenerOptionsParam = NonNullable< + Parameters[1] +>; type NitroPurchaseErrorListener = Parameters< RnIap['addPurchaseErrorListener'] >[0]; @@ -129,10 +134,7 @@ export type { UseWebhookEventsOptions, UseWebhookEventsResult, } from './hooks/useWebhookEvents'; -export { - connectWebhookStream, - parseWebhookEventData, -} from './webhook-client'; +export {connectWebhookStream, parseWebhookEventData} from './webhook-client'; export type { WebhookEventPayload, WebhookEventStream, @@ -232,11 +234,18 @@ const IAP = { // ============================================================================ const purchaseUpdateJsListeners = new Set<(purchase: Purchase) => void>(); +const purchaseUpdateDuplicateJsListeners = new Set< + (purchase: Purchase) => void +>(); let purchaseUpdateNativeAttached = false; -const purchaseUpdateNativeHandler: NitroPurchaseListener = (nitroPurchase) => { +let purchaseUpdateDuplicateNativeAttached = false; +const emitPurchaseUpdateToListeners = ( + nitroPurchase: Parameters[0], + listeners: Set<(purchase: Purchase) => void>, +) => { if (validateNitroPurchase(nitroPurchase)) { const convertedPurchase = convertNitroPurchaseToPurchase(nitroPurchase); - for (const listener of purchaseUpdateJsListeners) { + for (const listener of listeners) { try { listener(convertedPurchase); } catch (e) { @@ -250,6 +259,17 @@ const purchaseUpdateNativeHandler: NitroPurchaseListener = (nitroPurchase) => { ); } }; +const purchaseUpdateNativeHandler: NitroPurchaseListener = (nitroPurchase) => { + emitPurchaseUpdateToListeners(nitroPurchase, purchaseUpdateJsListeners); +}; +const purchaseUpdateDuplicateNativeHandler: NitroPurchaseListener = ( + nitroPurchase, +) => { + emitPurchaseUpdateToListeners( + nitroPurchase, + purchaseUpdateDuplicateJsListeners, + ); +}; const purchaseErrorJsListeners = new Set<(error: PurchaseError) => void>(); let purchaseErrorNativeAttached = false; @@ -299,6 +319,7 @@ const promotedProductNativeHandler: NitroPromotedProductListener = ( */ export const resetListenerState = (): void => { purchaseUpdateNativeAttached = false; + purchaseUpdateDuplicateNativeAttached = false; purchaseErrorNativeAttached = false; promotedProductNativeAttached = false; userChoiceBillingNativeAttached = false; @@ -306,6 +327,7 @@ export const resetListenerState = (): void => { subscriptionBillingIssueNativeAttached = false; // Clear all JS listeners since native side clears them in endConnection purchaseUpdateJsListeners.clear(); + purchaseUpdateDuplicateJsListeners.clear(); purchaseErrorJsListeners.clear(); promotedProductJsListeners.clear(); userChoiceBillingJsListeners.clear(); @@ -315,10 +337,17 @@ export const resetListenerState = (): void => { export const purchaseUpdatedListener = ( listener: (purchase: Purchase) => void, + options?: PurchaseUpdatedListenerOptions | null, ): EventSubscription => { - purchaseUpdateJsListeners.add(listener); + const includeDuplicateTransactionUpdatesIOS = + options?.includeDuplicateTransactionUpdatesIOS === true; + const listeners = includeDuplicateTransactionUpdatesIOS + ? purchaseUpdateDuplicateJsListeners + : purchaseUpdateJsListeners; + + listeners.add(listener); - if (!purchaseUpdateNativeAttached) { + if (!purchaseUpdateNativeAttached && !includeDuplicateTransactionUpdatesIOS) { try { IAP.instance.addPurchaseUpdatedListener(purchaseUpdateNativeHandler); purchaseUpdateNativeAttached = true; @@ -334,9 +363,35 @@ export const purchaseUpdatedListener = ( } } + if ( + !purchaseUpdateDuplicateNativeAttached && + includeDuplicateTransactionUpdatesIOS + ) { + try { + const nativeOptions: NitroPurchaseUpdatedListenerOptions & + NitroPurchaseUpdatedListenerOptionsParam = { + includeDuplicateTransactionUpdatesIOS: true, + }; + IAP.instance.addPurchaseUpdatedListener( + purchaseUpdateDuplicateNativeHandler, + nativeOptions, + ); + purchaseUpdateDuplicateNativeAttached = true; + } catch (e) { + const msg = toErrorMessage(e); + if (msg.includes('Nitro runtime not installed')) { + RnIapConsole.warn( + '[purchaseUpdatedListener] Nitro not ready yet; listener inert until initConnection()', + ); + } else { + throw e; + } + } + } + return { remove: () => { - purchaseUpdateJsListeners.delete(listener); + listeners.delete(listener); }, }; }; @@ -619,7 +674,9 @@ type NitroSubscriptionBillingIssueListener = Parameters< RnIap['addSubscriptionBillingIssueListener'] >[0]; -const subscriptionBillingIssueJsListeners = new Set<(purchase: Purchase) => void>(); +const subscriptionBillingIssueJsListeners = new Set< + (purchase: Purchase) => void +>(); let subscriptionBillingIssueNativeAttached = false; const subscriptionBillingIssueNativeHandler: NitroSubscriptionBillingIssueListener = (nitroPurchase) => { diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index 94643735..fa927fd9 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -40,6 +40,7 @@ import type { PromotionalOfferJwsInputIOS, PurchaseCommon, PurchaseOptions, + PurchaseUpdatedListenerOptions, VerifyPurchaseAppleOptions, VerifyPurchaseGoogleOptions, VerifyPurchaseHorizonOptions, @@ -146,6 +147,8 @@ export interface NitroReceiptValidationHorizonOptions { userId: VerifyPurchaseHorizonOptions['userId']; } +export interface NitroPurchaseUpdatedListenerOptions extends PurchaseUpdatedListenerOptions {} + export interface NitroReceiptValidationParams { apple?: NitroReceiptValidationAppleOptions | null; google?: NitroReceiptValidationGoogleOptions | null; @@ -725,7 +728,10 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { * Add a listener for purchase updates * @param listener - Function to call when a purchase is updated */ - addPurchaseUpdatedListener(listener: (purchase: NitroPurchase) => void): void; + addPurchaseUpdatedListener( + listener: (purchase: NitroPurchase) => void, + options?: NitroPurchaseUpdatedListenerOptions, + ): void; /** * Add a listener for purchase errors diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 7eb00860..4432d3fe 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1292,6 +1292,16 @@ export interface PurchaseOptions { export type PurchaseState = 'pending' | 'purchased' | 'unknown'; +export interface PurchaseUpdatedListenerOptions { + /** + * iOS only. When true, listener callbacks also receive StoreKit replay events + * for a transaction ID that was already emitted during the current connection + * session. Defaults to false so purchase success handlers run once per + * transaction ID. + */ + includeDuplicateTransactionUpdatesIOS?: (boolean | null); +} + export type PurchaseVerificationProvider = 'iapkit'; export interface Query { @@ -1734,7 +1744,12 @@ export interface Subscription { promotedProductIOS: string; /** Fires when a purchase fails or is cancelled */ purchaseError: PurchaseError; - /** Fires when a purchase completes successfully or a pending purchase resolves */ + /** + * Fires when a purchase completes successfully or a pending purchase resolves + * Options can opt iOS listeners into duplicate StoreKit transaction replays + * for diagnostics; default listeners receive one event per transaction ID + * during a single connection session. + */ purchaseUpdated: Purchase; /** * Fires when an active subscription enters a billing-issue state that needs user action @@ -1758,6 +1773,9 @@ export interface Subscription { } + +export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined; + export interface SubscriptionInfoIOS { introductoryOffer?: (SubscriptionOfferIOS | null); promotionalOffers?: (SubscriptionOfferIOS[] | null); @@ -2218,7 +2236,7 @@ export type SubscriptionArgsMap = { developerProvidedBillingAndroid: never; promotedProductIOS: never; purchaseError: never; - purchaseUpdated: never; + purchaseUpdated: SubscriptionPurchaseUpdatedArgs; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; }; diff --git a/openiap-versions.json b/openiap-versions.json index 6721d2c1..998c5884 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,5 +1,5 @@ { - "spec": "2.0.1", + "spec": "2.0.2", "google": "2.1.4", "apple": "2.1.8" } diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift index 3141ee33..c55fd292 100644 --- a/packages/apple/Sources/Helpers/IapState.swift +++ b/packages/apple/Sources/Helpers/IapState.swift @@ -32,16 +32,27 @@ private struct PurchaseUpdateEmissionHistory { } } +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +private struct PurchaseUpdatedListenerRegistration { + let id: UUID + let listener: PurchaseUpdatedListener + let includeDuplicateTransactionUpdatesIOS: Bool +} + @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) actor IapState { + private static let purchaseUpdateEmissionHistoryLimit = 512 + private(set) var isInitialized: Bool = false private var pendingTransactions: [String: Transaction] = [:] - private var purchaseUpdateEmissionHistory = PurchaseUpdateEmissionHistory(limit: 512) + private var purchaseUpdateEmissionHistory = PurchaseUpdateEmissionHistory( + limit: purchaseUpdateEmissionHistoryLimit + ) private var promotedProductId: String? private var pendingPromotedProductReplayId: String? // Event listeners - private var purchaseUpdatedListeners: [(id: UUID, listener: PurchaseUpdatedListener)] = [] + private var purchaseUpdatedListeners: [PurchaseUpdatedListenerRegistration] = [] private var purchaseErrorListeners: [(id: UUID, listener: PurchaseErrorListener)] = [] private var promotedProductListeners: [(id: UUID, listener: PromotedProductListener)] = [] private var subscriptionBillingIssueListeners: [(id: UUID, listener: SubscriptionBillingIssueListener)] = [] @@ -63,8 +74,15 @@ actor IapState { func pendingSnapshot() -> [Transaction] { Array(pendingTransactions.values) } // MARK: - Purchase Update Emissions - func recordPurchaseUpdateEmission(id: String) -> Bool { - purchaseUpdateEmissionHistory.record(id) + func recordPurchaseUpdateEmission( + id: String, + pendingTransaction: Transaction? = nil + ) -> Bool { + let shouldEmit = purchaseUpdateEmissionHistory.record(id) + if shouldEmit, let pendingTransaction { + pendingTransactions[id] = pendingTransaction + } + return shouldEmit } // MARK: - Promoted Products @@ -83,8 +101,16 @@ actor IapState { } // MARK: - Listeners - func addPurchaseUpdatedListener(_ pair: (UUID, PurchaseUpdatedListener)) { - purchaseUpdatedListeners.append((id: pair.0, listener: pair.1)) + func addPurchaseUpdatedListener( + id: UUID, + listener: @escaping PurchaseUpdatedListener, + options: PurchaseUpdatedListenerOptions? + ) { + purchaseUpdatedListeners.append(PurchaseUpdatedListenerRegistration( + id: id, + listener: listener, + includeDuplicateTransactionUpdatesIOS: options?.includeDuplicateTransactionUpdatesIOS == true + )) } func addPurchaseErrorListener(_ pair: (UUID, PurchaseErrorListener)) { purchaseErrorListeners.append((id: pair.0, listener: pair.1)) @@ -127,8 +153,13 @@ actor IapState { subscriptionBillingIssueListeners.removeAll() } - func snapshotPurchaseUpdated() -> [PurchaseUpdatedListener] { - purchaseUpdatedListeners.map { $0.listener } + func snapshotPurchaseUpdated(isDuplicate: Bool = false) -> [PurchaseUpdatedListener] { + purchaseUpdatedListeners.compactMap { registration in + guard !isDuplicate || registration.includeDuplicateTransactionUpdatesIOS else { + return nil + } + return registration.listener + } } func snapshotPurchaseError() -> [PurchaseErrorListener] { purchaseErrorListeners.map { $0.listener } diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 4b6a09da..b42ddce2 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -1687,6 +1687,20 @@ public struct PurchaseOptions: Codable { } } +public struct PurchaseUpdatedListenerOptions: Codable { + /// iOS only. When true, listener callbacks also receive StoreKit replay events + /// for a transaction ID that was already emitted during the current connection + /// session. Defaults to false so purchase success handlers run once per + /// transaction ID. + public var includeDuplicateTransactionUpdatesIOS: Bool? + + public init( + includeDuplicateTransactionUpdatesIOS: Bool? = nil + ) { + self.includeDuplicateTransactionUpdatesIOS = includeDuplicateTransactionUpdatesIOS + } +} + public struct RequestPurchaseAndroidProps: Codable { /// Developer billing option parameters for external payments flow (8.3.0+). /// When provided, the purchase flow will show a side-by-side choice between @@ -2695,7 +2709,10 @@ public protocol SubscriptionResolver { /// Fires when a purchase fails or is cancelled func purchaseError() async throws -> PurchaseError /// Fires when a purchase completes successfully or a pending purchase resolves - func purchaseUpdated() async throws -> Purchase + /// Options can opt iOS listeners into duplicate StoreKit transaction replays + /// for diagnostics; default listeners receive one event per transaction ID + /// during a single connection session. + func purchaseUpdated(_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase /// Fires when an active subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: /// @@ -2928,7 +2945,7 @@ public struct QueryHandlers { public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = () async throws -> DeveloperProvidedBillingDetailsAndroid public typealias SubscriptionPromotedProductIOSHandler = () async throws -> String public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseError -public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase +public typealias SubscriptionPurchaseUpdatedHandler = (_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index b88b65da..44881391 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -801,10 +801,21 @@ import StoreKit // MARK: - Event Listeners @objc func addPurchaseUpdatedListener(_ callback: @escaping (NSDictionary) -> Void) -> NSObject { - let subscription = purchaseUpdatedListener { purchase in + addPurchaseUpdatedListener(callback, includeDuplicateTransactionUpdatesIOS: false) + } + + @objc(addPurchaseUpdatedListener:includeDuplicateTransactionUpdatesIOS:) + func addPurchaseUpdatedListener( + _ callback: @escaping (NSDictionary) -> Void, + includeDuplicateTransactionUpdatesIOS: Bool + ) -> NSObject { + let options = PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: includeDuplicateTransactionUpdatesIOS + ) + let subscription = purchaseUpdatedListener({ purchase in let dictionary = OpenIapSerialization.purchase(purchase) callback(dictionary as NSDictionary) - } + }, options: options) return subscription as NSObject } diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 2bef865d..f813d6e0 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -376,26 +376,24 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { - Note: \(isSubscription ? "Subscription transactions will be emitted via Transaction.updates" : "Emitting directly") """) - let shouldEmit = await state.recordPurchaseUpdateEmission(id: transactionId) + let shouldEmit = await state.recordPurchaseUpdateEmission( + id: transactionId, + pendingTransaction: shouldAutoFinish ? nil : transaction + ) if shouldAutoFinish { await transaction.finish() await state.removePending(id: transactionId) - } else if shouldEmit { - await state.storePending(id: transactionId, transaction: transaction) } - // Emit purchase update // StoreKit can replay unfinished transactions through multiple paths during a - // connection session; only emit each transaction id once. - if shouldEmit { - emitPurchaseUpdate(purchase) - } else { - logDuplicatePurchaseUpdateSuppressed( - source: "requestPurchase", - transactionId: transactionId, - productId: transaction.productID - ) - } + // connection session. Default listeners receive each transaction id once; + // duplicate-enabled listeners can opt into the replay for diagnostics. + emitPurchaseUpdate( + purchase, + isDuplicate: !shouldEmit, + duplicateSource: "requestPurchase", + duplicateTransactionId: transactionId + ) return .purchase(purchase) @@ -1379,9 +1377,18 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Event Listener Registration - public func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription { + public func purchaseUpdatedListener( + _ listener: @escaping PurchaseUpdatedListener, + options: PurchaseUpdatedListenerOptions? = nil + ) -> Subscription { let subscription = Subscription(eventType: .purchaseUpdated) - Task { await state.addPurchaseUpdatedListener((subscription.id, listener)) } + Task { + await state.addPurchaseUpdatedListener( + id: subscription.id, + listener: listener, + options: options + ) + } return subscription } @@ -1643,19 +1650,22 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { continue } - // Emit once per transaction id for this connection session. - guard await self.state.recordPurchaseUpdateEmission(id: transactionId) else { - self.logDuplicatePurchaseUpdateSuppressed( - source: "Transaction.updates", - transactionId: transactionId, - productId: transaction.productID + let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) + + // Default listeners receive each transaction id once per connection + // session. Duplicate-enabled listeners can opt into StoreKit replays. + guard await self.state.recordPurchaseUpdateEmission( + id: transactionId, + pendingTransaction: transaction + ) else { + self.emitPurchaseUpdate( + purchase, + isDuplicate: true, + duplicateSource: "Transaction.updates", + duplicateTransactionId: transactionId ) continue } - await self.state.storePending(id: transactionId, transaction: transaction) - - let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) - OpenIapLog.debug("✅ [TransactionListener] Emitting transaction: \(transactionId) for product: \(transaction.productID)") self.emitPurchaseUpdate(purchase) } catch { @@ -1789,24 +1799,41 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } } - private func logDuplicatePurchaseUpdateSuppressed( + private func logDuplicatePurchaseUpdate( source: String, transactionId: String, - productId: String + productId: String, + listenerCount: Int ) { + let action = listenerCount > 0 + ? "Delivered duplicate purchase-updated event to \(listenerCount) duplicate-enabled listener(s)." + : "Suppressed duplicate purchase-updated listener emission." OpenIapLog.warn(""" - [PurchaseUpdateDedup] Suppressed duplicate purchase-updated listener emission. + [PurchaseUpdateDedup] \(action) - Source: \(source) - Product: \(productId) - Transaction ID: \(transactionId) - Reason: this transaction id was already emitted during the current connection session. - - Scope: only identical transaction ids are suppressed; distinct StoreKit transactions still emit. + - Scope: default listeners receive one event per transaction id; listeners registered with includeDuplicateTransactionUpdatesIOS receive StoreKit replays. """) } - private func emitPurchaseUpdate(_ purchase: Purchase) { + private func emitPurchaseUpdate( + _ purchase: Purchase, + isDuplicate: Bool = false, + duplicateSource: String? = nil, + duplicateTransactionId: String? = nil + ) { Task { [state] in - let listeners = await state.snapshotPurchaseUpdated() + let listeners = await state.snapshotPurchaseUpdated(isDuplicate: isDuplicate) + if isDuplicate { + self.logDuplicatePurchaseUpdate( + source: duplicateSource ?? "unknown", + transactionId: duplicateTransactionId ?? purchase.id, + productId: purchase.productId, + listenerCount: listeners.count + ) + } OpenIapLog.debug("✅ Emitting purchase update: Product=\(purchase.productId), Listeners=\(listeners.count)") await MainActor.run { listeners.forEach { $0(purchase) } diff --git a/packages/apple/Sources/OpenIapProtocol.swift b/packages/apple/Sources/OpenIapProtocol.swift index cf4c0dca..1a67292c 100644 --- a/packages/apple/Sources/OpenIapProtocol.swift +++ b/packages/apple/Sources/OpenIapProtocol.swift @@ -93,7 +93,10 @@ public protocol OpenIapModuleProtocol { func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void // Event Listeners - func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription + func purchaseUpdatedListener( + _ listener: @escaping PurchaseUpdatedListener, + options: PurchaseUpdatedListenerOptions? + ) -> Subscription func purchaseErrorListener(_ listener: @escaping PurchaseErrorListener) -> Subscription func promotedProductListenerIOS(_ listener: @escaping PromotedProductListener) -> Subscription /// Listener for subscription billing-issue events (iOS 18+). diff --git a/packages/apple/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift index 82f97172..c7229a69 100644 --- a/packages/apple/Sources/OpenIapStore.swift +++ b/packages/apple/Sources/OpenIapStore.swift @@ -60,9 +60,9 @@ public final class OpenIapStore: ObservableObject { // MARK: - Listener Management private func setupListeners() { - let purchaseUpdate = module.purchaseUpdatedListener { [weak self] purchase in + let purchaseUpdate = module.purchaseUpdatedListener({ [weak self] purchase in Task { @MainActor in self?.handlePurchaseUpdate(purchase) } - } + }, options: nil) listenerTokens.append(purchaseUpdate) let purchaseError = module.purchaseErrorListener { [weak self] error in diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift index 61f67b2b..9fa959e9 100644 --- a/packages/apple/Tests/OpenIapTests.swift +++ b/packages/apple/Tests/OpenIapTests.swift @@ -113,6 +113,30 @@ final class OpenIapTests: XCTestCase { XCTAssertTrue(replayEmission) } + @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) + func testPurchaseUpdateDuplicateSnapshotOnlyIncludesOptInListeners() async { + let state = IapState() + + await state.addPurchaseUpdatedListener( + id: UUID(), + listener: { _ in }, + options: nil + ) + await state.addPurchaseUpdatedListener( + id: UUID(), + listener: { _ in }, + options: PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: true + ) + ) + + let normalListeners = await state.snapshotPurchaseUpdated() + let duplicateListeners = await state.snapshotPurchaseUpdated(isDuplicate: true) + + XCTAssertEqual(normalListeners.count, 2) + XCTAssertEqual(duplicateListeners.count, 1) + } + func testPurchaseIOSWithRenewalInfo() { let renewalInfo = RenewalInfoIOS( autoRenewPreference: "dev.hyo.premium_year", diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift index 0726ecff..3563d95f 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift @@ -170,7 +170,10 @@ private final class FakeOpenIapModule: OpenIapModuleProtocol { func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void { () } // MARK: - Event Listeners - func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription { + func purchaseUpdatedListener( + _ listener: @escaping PurchaseUpdatedListener, + options: PurchaseUpdatedListenerOptions? + ) -> Subscription { Subscription(eventType: .purchaseUpdated) } diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift index 2daf5efa..fd2d523c 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -155,7 +155,10 @@ private final class FakeVerifyPurchaseModule: OpenIapModuleProtocol { func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void { () } // MARK: - Event Listeners - func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription { + func purchaseUpdatedListener( + _ listener: @escaping PurchaseUpdatedListener, + options: PurchaseUpdatedListenerOptions? + ) -> Subscription { Subscription(eventType: .purchaseUpdated) } diff --git a/packages/docs/openiap-versions.json b/packages/docs/openiap-versions.json index 6721d2c1..998c5884 100644 --- a/packages/docs/openiap-versions.json +++ b/packages/docs/openiap-versions.json @@ -1,5 +1,5 @@ { - "spec": "2.0.1", + "spec": "2.0.2", "google": "2.1.4", "apple": "2.1.8" } diff --git a/packages/docs/src/lib/searchData.ts b/packages/docs/src/lib/searchData.ts index 1ec9bdc0..29616240 100644 --- a/packages/docs/src/lib/searchData.ts +++ b/packages/docs/src/lib/searchData.ts @@ -656,6 +656,14 @@ export const apiData: ApiItem[] = [ 'ProductRequest, RequestPurchaseProps, platform-specific request types', path: '/docs/types/request-purchase-props', }, + { + id: 'purchase-updated-listener-options', + title: 'PurchaseUpdatedListenerOptions', + category: 'Types', + description: + 'Options for purchaseUpdatedListener, including iOS duplicate StoreKit replay delivery', + path: '/docs/types/purchase-updated-listener-options', + }, { id: 'types-verification', title: 'Verification Types', diff --git a/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx b/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx index 2e020d5d..cd94c670 100644 --- a/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx +++ b/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx @@ -21,20 +21,38 @@ function PurchaseUpdatedListener() { completed.

+

Duplicate Transaction Replays on iOS

+

+ StoreKit can replay the same unfinished transaction through more than + one native path during a single connection session. By default, OpenIAP + delivers one purchaseUpdated event per iOS transaction ID + to purchase-success listeners, while still keeping distinct transactions + separate. This prevents entitlement delivery from running twice for the + same purchase. +

+

+ For diagnostics, register the purchase update listener with{' '} + includeDuplicateTransactionUpdatesIOS: true. The flag + belongs to the purchase update listener only; purchase error listeners + do not receive successful StoreKit transactions. +

+

Listener Setup

{{ typescript: ( {`purchaseUpdatedListener( - listener: (purchase: Purchase) => void + listener: (purchase: Purchase) => void, + options?: PurchaseUpdatedListenerOptions | null ): Subscription`} ), swift: ( - {`// AsyncSequence approach -var purchaseUpdates: AsyncStream - -// Combine approach -var purchaseUpdatedPublisher: AnyPublisher`} + {`let subscription = OpenIapModule.shared.purchaseUpdatedListener( + { purchase in + print("Purchase updated: \\(purchase.productId)") + }, + options: nil +)`} ), kotlin: ( {`// Flow approach @@ -42,10 +60,10 @@ val purchaseUpdates: Flow`} ), kmp: ( {`// Flow approach -val purchaseUpdates: Flow`} +val purchaseUpdates: Flow = kmpIAP.purchaseUpdatedListener`} ), dart: ( - {`Stream get purchaseUpdatedStream;`} + {`Stream get purchaseUpdatedListener;`} ), csharp: ( {`using OpenIap; @@ -61,6 +79,57 @@ IObservable purchaseUpdates = Iap.Instance.PurchaseUpdated;`}

Registers a listener for successful purchase events.

+

Opt In to iOS StoreKit Replays

+ + {{ + typescript: ( + {`const subscription = purchaseUpdatedListener( + (purchase) => { + console.log('StoreKit replay or first delivery:', purchase.id); + }, + { includeDuplicateTransactionUpdatesIOS: true } +);`} + ), + swift: ( + {`let subscription = OpenIapModule.shared.purchaseUpdatedListener( + { purchase in + print("StoreKit replay or first delivery: \\(purchase.id)") + }, + options: PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: true + ) +)`} + ), + kmp: ( + {`val updates = kmpIAP.purchaseUpdatedListener( + PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS = true + ) +)`} + ), + dart: ( + {`final updates = FlutterInappPurchase.instance + .purchaseUpdatedListenerWithOptions( + const PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: true, + ), +);`} + ), + csharp: ( + {`var updates = Iap.Instance.PurchaseUpdatedWithOptions( + new PurchaseUpdatedListenerOptions + { + IncludeDuplicateTransactionUpdatesIOS = true, + });`} + ), + gdscript: ( + {`var options = Types.PurchaseUpdatedListenerOptions.new() +options.include_duplicate_transaction_updates_ios = true +iap.set_purchase_updated_listener_options(options)`} + ), + }} + + {{ typescript: ( diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index cc88b34e..24888e39 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -20,6 +20,7 @@ import TypesStorefront from './types/storefront'; import TypesPurchase from './types/purchase'; import TypesActiveSubscription from './types/active-subscription'; import TypesProductRequest from './types/product-request'; +import TypesPurchaseUpdatedListenerOptions from './types/purchase-updated-listener-options'; import TypesRequestPurchaseProps from './types/request-purchase-props'; import TypesAlternativeBillingTypes from './types/alternative-billing-types'; import TypesBillingPrograms from './types/billing-programs'; @@ -256,6 +257,10 @@ function Docs() { to: '/docs/types/request-purchase-props', label: 'RequestPurchaseProps', }, + { + to: '/docs/types/purchase-updated-listener-options', + label: 'PurchaseUpdatedListenerOptions', + }, { to: '/docs/types/discount-offer', label: 'DiscountOffer', @@ -819,6 +824,10 @@ function Docs() { path="types/request-purchase-props" element={} /> + } + /> } diff --git a/packages/docs/src/pages/docs/types/index.tsx b/packages/docs/src/pages/docs/types/index.tsx index c18bff1d..276c8535 100644 --- a/packages/docs/src/pages/docs/types/index.tsx +++ b/packages/docs/src/pages/docs/types/index.tsx @@ -18,6 +18,8 @@ const LEGACY_ANCHOR_REDIRECTS: Record = { 'active-subscription': '/docs/types/active-subscription', 'product-request': '/docs/types/product-request', 'request-purchase-props': '/docs/types/request-purchase-props', + 'purchase-updated-listener-options': + '/docs/types/purchase-updated-listener-options', 'discount-offer': '/docs/types/discount-offer', 'subscription-offer': '/docs/types/subscription-offer', 'verify-purchase': '/docs/types/verify-purchase', @@ -121,6 +123,11 @@ const COMMON_TYPES: TypeRow[] = [ name: 'RequestPurchaseProps', description: 'Discriminated union for one-time purchases or subscriptions.', }, + { + to: '/docs/types/purchase-updated-listener-options', + name: 'PurchaseUpdatedListenerOptions', + description: 'Options for purchase update listener replay behavior.', + }, { to: '/docs/types/discount-offer', name: 'DiscountOffer', diff --git a/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx b/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx new file mode 100644 index 00000000..da7bafb0 --- /dev/null +++ b/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx @@ -0,0 +1,124 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../../components/AnchorLink'; +import CodeBlock from '../../../components/CodeBlock'; +import LanguageTabs from '../../../components/LanguageTabs'; +import SEO from '../../../components/SEO'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; + +function PurchaseUpdatedListenerOptions() { + useScrollToHash(); + + return ( +
+ +

PurchaseUpdatedListenerOptions

+ +
+ + PurchaseUpdatedListenerOptions + +

+ Options passed when registering{' '} + + purchaseUpdatedListener + + . The current option is iOS-only and controls whether a listener + receives StoreKit replay events for transaction IDs already delivered + during the current connection session. +

+ + + Fields + + + + + + + + + + + + + + + + +
NameTypeSummary
+ includeDuplicateTransactionUpdatesIOS + + boolean? + + iOS only. Defaults to false. When true, the + listener also receives StoreKit replay events for a transaction + ID already emitted during the current connection session. +
+ + + Default Behavior + +

+ The default is designed for entitlement safety: purchase success + handlers run once per iOS transaction ID during one connection + session. Enable the flag only when you need to inspect native StoreKit + replay behavior or build your own duplicate handling. +

+ + + Examples + + + {{ + typescript: ( + {`purchaseUpdatedListener(onPurchase, { + includeDuplicateTransactionUpdatesIOS: true, +});`} + ), + swift: ( + {`OpenIapModule.shared.purchaseUpdatedListener( + onPurchase, + options: PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: true + ) +)`} + ), + kmp: ( + {`kmpIAP.purchaseUpdatedListener( + PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS = true + ) +)`} + ), + dart: ( + {`FlutterInappPurchase.instance.purchaseUpdatedListenerWithOptions( + const PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: true, + ), +);`} + ), + csharp: ( + {`Iap.Instance.PurchaseUpdatedWithOptions( + new PurchaseUpdatedListenerOptions + { + IncludeDuplicateTransactionUpdatesIOS = true, + });`} + ), + gdscript: ( + {`var options = Types.PurchaseUpdatedListenerOptions.new() +options.include_duplicate_transaction_updates_ios = true +iap.set_purchase_updated_listener_options(options)`} + ), + }} + +
+
+ ); +} + +export default PurchaseUpdatedListenerOptions; diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 71f5f42f..82cb08cd 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -26,6 +26,118 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // May 13, 2026 — OpenIAP Spec 2.0.2 purchase update replay controls + { + id: 'openiap-spec-2-0-2-purchase-update-replay-controls', + date: new Date('2026-05-13'), + element: ( +
+ + May 13, 2026 — OpenIAP Spec 2.0.2 purchase update replay controls + + +

+ Publishes OpenIAP Spec 2.0.2 with{' '} + PurchaseUpdatedListenerOptions and an iOS-only{' '} + includeDuplicateTransactionUpdatesIOS flag. StoreKit + can replay the same unfinished transaction through request and + transaction-update paths during a single connection session. The + default listener behavior remains entitlement-safe: one purchase + success event per iOS transaction ID. Diagnostics can opt into the + StoreKit replay stream explicitly. Track the fix in{' '} + + issue #152 + {' '} + and{' '} + + PR #153 + + . +

+ +
    +
  • + Listener-level opt-in — React Native and Expo + accept the flag on purchaseUpdatedListener; Flutter, + KMP, MAUI, and Godot expose equivalent stream or signal-level + options without changing default purchase success handling. +
  • +
  • + Native debugging preserved — openiap-apple no + longer drops duplicate StoreKit updates before framework bridges + can observe them. Default listeners suppress duplicates, while + duplicate-enabled listeners receive the replay. +
  • +
  • + Docs and type sync — the generated GQL types now + include PurchaseUpdatedListenerOptions across Swift, + Kotlin, TypeScript, Dart, GDScript, and C#. +
  • +
  • + Usage guide — see{' '} + + purchaseUpdatedListener + {' '} + for the default behavior and opt-in examples. +
  • +
+ +
+
Package Releases
+ +
+
+ ), + }, + // May 10, 2026 — godot-iap 2.2.8 iOS export embedding patch { id: 'godot-iap-2-2-8-ios-export-framework-embedding', diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index edc23e07..fa7863a8 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -4294,6 +4294,28 @@ public data class PurchaseOptions( ) } +public data class PurchaseUpdatedListenerOptions( + /** + * iOS only. When true, listener callbacks also receive StoreKit replay events + * for a transaction ID that was already emitted during the current connection + * session. Defaults to false so purchase success handlers run once per + * transaction ID. + */ + val includeDuplicateTransactionUpdatesIOS: Boolean? = null +) { + companion object { + fun fromJson(json: Map): PurchaseUpdatedListenerOptions { + return PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS = json["includeDuplicateTransactionUpdatesIOS"] as? Boolean, + ) + } + } + + fun toJson(): Map = mapOf( + "includeDuplicateTransactionUpdatesIOS" to includeDuplicateTransactionUpdatesIOS, + ) +} + public data class RequestPurchaseAndroidProps( /** * Developer billing option parameters for external payments flow (8.3.0+). @@ -5441,8 +5463,11 @@ public interface SubscriptionResolver { suspend fun purchaseError(): PurchaseError /** * Fires when a purchase completes successfully or a pending purchase resolves + * Options can opt iOS listeners into duplicate StoreKit transaction replays + * for diagnostics; default listeners receive one event per transaction ID + * during a single connection session. */ - suspend fun purchaseUpdated(): Purchase + suspend fun purchaseUpdated(options: PurchaseUpdatedListenerOptions? = null): Purchase /** * Fires when an active subscription enters a billing-issue state that needs user action * (payment method failed, card expired, etc.). Cross-platform unification: @@ -5577,7 +5602,7 @@ public data class QueryHandlers( public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = suspend () -> DeveloperProvidedBillingDetailsAndroid public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError -public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase +public typealias SubscriptionPurchaseUpdatedHandler = suspend (options: PurchaseUpdatedListenerOptions?) -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails diff --git a/packages/gql/src/event.graphql b/packages/gql/src/event.graphql index 3687e7c1..e0d12f89 100644 --- a/packages/gql/src/event.graphql +++ b/packages/gql/src/event.graphql @@ -3,8 +3,11 @@ extend type Subscription { """ Fires when a purchase completes successfully or a pending purchase resolves + Options can opt iOS listeners into duplicate StoreKit transaction replays + for diagnostics; default listeners receive one event per transaction ID + during a single connection session. """ - purchaseUpdated: Purchase! + purchaseUpdated(options: PurchaseUpdatedListenerOptions): Purchase! """ Fires when a purchase fails or is cancelled """ diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index 91e82bf4..1562711d 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -3762,6 +3762,16 @@ public sealed record PurchaseOptions public bool? IncludeSuspendedAndroid { get; init; } } +public sealed record PurchaseUpdatedListenerOptions +{ + /// iOS only. When true, listener callbacks also receive StoreKit replay events + /// for a transaction ID that was already emitted during the current connection + /// session. Defaults to false so purchase success handlers run once per + /// transaction ID. + [JsonPropertyName("includeDuplicateTransactionUpdatesIOS")] + public bool? IncludeDuplicateTransactionUpdatesIOS { get; init; } +} + public sealed record RequestPurchaseAndroidProps { /// List of product SKUs @@ -4347,7 +4357,10 @@ public interface SubscriptionResolver Task PurchaseErrorAsync(); /// Fires when a purchase completes successfully or a pending purchase resolves - Task PurchaseUpdatedAsync(); + /// Options can opt iOS listeners into duplicate StoreKit transaction replays + /// for diagnostics; default listeners receive one event per transaction ID + /// during a single connection session. + Task PurchaseUpdatedAsync(PurchaseUpdatedListenerOptions? options = null); /// Fires when an active subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index b393cf86..0ae60666 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -4413,6 +4413,28 @@ public data class PurchaseOptions( ) } +public data class PurchaseUpdatedListenerOptions( + /** + * iOS only. When true, listener callbacks also receive StoreKit replay events + * for a transaction ID that was already emitted during the current connection + * session. Defaults to false so purchase success handlers run once per + * transaction ID. + */ + val includeDuplicateTransactionUpdatesIOS: Boolean? = null +) { + companion object { + fun fromJson(json: Map): PurchaseUpdatedListenerOptions { + return PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS = json["includeDuplicateTransactionUpdatesIOS"] as? Boolean, + ) + } + } + + fun toJson(): Map = mapOf( + "includeDuplicateTransactionUpdatesIOS" to includeDuplicateTransactionUpdatesIOS, + ) +} + public data class RequestPurchaseAndroidProps( /** * Developer billing option parameters for external payments flow (8.3.0+). @@ -5560,8 +5582,11 @@ public interface SubscriptionResolver { suspend fun purchaseError(): PurchaseError /** * Fires when a purchase completes successfully or a pending purchase resolves + * Options can opt iOS listeners into duplicate StoreKit transaction replays + * for diagnostics; default listeners receive one event per transaction ID + * during a single connection session. */ - suspend fun purchaseUpdated(): Purchase + suspend fun purchaseUpdated(options: PurchaseUpdatedListenerOptions? = null): Purchase /** * Fires when an active subscription enters a billing-issue state that needs user action * (payment method failed, card expired, etc.). Cross-platform unification: @@ -5696,7 +5721,7 @@ public data class QueryHandlers( public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = suspend () -> DeveloperProvidedBillingDetailsAndroid public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError -public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase +public typealias SubscriptionPurchaseUpdatedHandler = suspend (options: PurchaseUpdatedListenerOptions?) -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 4b6a09da..b42ddce2 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -1687,6 +1687,20 @@ public struct PurchaseOptions: Codable { } } +public struct PurchaseUpdatedListenerOptions: Codable { + /// iOS only. When true, listener callbacks also receive StoreKit replay events + /// for a transaction ID that was already emitted during the current connection + /// session. Defaults to false so purchase success handlers run once per + /// transaction ID. + public var includeDuplicateTransactionUpdatesIOS: Bool? + + public init( + includeDuplicateTransactionUpdatesIOS: Bool? = nil + ) { + self.includeDuplicateTransactionUpdatesIOS = includeDuplicateTransactionUpdatesIOS + } +} + public struct RequestPurchaseAndroidProps: Codable { /// Developer billing option parameters for external payments flow (8.3.0+). /// When provided, the purchase flow will show a side-by-side choice between @@ -2695,7 +2709,10 @@ public protocol SubscriptionResolver { /// Fires when a purchase fails or is cancelled func purchaseError() async throws -> PurchaseError /// Fires when a purchase completes successfully or a pending purchase resolves - func purchaseUpdated() async throws -> Purchase + /// Options can opt iOS listeners into duplicate StoreKit transaction replays + /// for diagnostics; default listeners receive one event per transaction ID + /// during a single connection session. + func purchaseUpdated(_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase /// Fires when an active subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: /// @@ -2928,7 +2945,7 @@ public struct QueryHandlers { public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = () async throws -> DeveloperProvidedBillingDetailsAndroid public typealias SubscriptionPromotedProductIOSHandler = () async throws -> String public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseError -public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase +public typealias SubscriptionPurchaseUpdatedHandler = (_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 93997a8c..23f81af9 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -4347,6 +4347,30 @@ class PurchaseOptions { } } +class PurchaseUpdatedListenerOptions { + const PurchaseUpdatedListenerOptions({ + this.includeDuplicateTransactionUpdatesIOS, + }); + + /// iOS only. When true, listener callbacks also receive StoreKit replay events + /// for a transaction ID that was already emitted during the current connection + /// session. Defaults to false so purchase success handlers run once per + /// transaction ID. + final bool? includeDuplicateTransactionUpdatesIOS; + + factory PurchaseUpdatedListenerOptions.fromJson(Map json) { + return PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: json['includeDuplicateTransactionUpdatesIOS'] as bool?, + ); + } + + Map toJson() { + return { + 'includeDuplicateTransactionUpdatesIOS': includeDuplicateTransactionUpdatesIOS, + }; + } +} + class RequestPurchaseAndroidProps { const RequestPurchaseAndroidProps({ this.developerBillingOption, @@ -5452,7 +5476,12 @@ abstract class SubscriptionResolver { /// Fires when a purchase fails or is cancelled Future purchaseError(); /// Fires when a purchase completes successfully or a pending purchase resolves - Future purchaseUpdated(); + /// Options can opt iOS listeners into duplicate StoreKit transaction replays + /// for diagnostics; default listeners receive one event per transaction ID + /// during a single connection session. + Future purchaseUpdated({ + bool? includeDuplicateTransactionUpdatesIOS, + }); /// Fires when an active subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: /// @@ -5672,7 +5701,9 @@ class QueryHandlers { typedef SubscriptionDeveloperProvidedBillingAndroidHandler = Future Function(); typedef SubscriptionPromotedProductIOSHandler = Future Function(); typedef SubscriptionPurchaseErrorHandler = Future Function(); -typedef SubscriptionPurchaseUpdatedHandler = Future Function(); +typedef SubscriptionPurchaseUpdatedHandler = Future Function({ + bool? includeDuplicateTransactionUpdatesIOS, +}); typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 29d37286..7809a85f 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -3850,6 +3850,22 @@ class PurchaseOptions: dict["includeSuspendedAndroid"] = include_suspended_android return dict +class PurchaseUpdatedListenerOptions: + ## iOS only. When true, listener callbacks also receive StoreKit replay events + var include_duplicate_transaction_updates_ios: Variant = null + + static func from_dict(data: Dictionary) -> PurchaseUpdatedListenerOptions: + var obj = PurchaseUpdatedListenerOptions.new() + if data.has("includeDuplicateTransactionUpdatesIOS") and data["includeDuplicateTransactionUpdatesIOS"] != null: + obj.include_duplicate_transaction_updates_ios = data["includeDuplicateTransactionUpdatesIOS"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if include_duplicate_transaction_updates_ios != null: + dict["includeDuplicateTransactionUpdatesIOS"] = include_duplicate_transaction_updates_ios + return dict + class RequestPurchaseAndroidProps: ## List of product SKUs var skus: Array[String] = [] diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 7eb00860..4432d3fe 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1292,6 +1292,16 @@ export interface PurchaseOptions { export type PurchaseState = 'pending' | 'purchased' | 'unknown'; +export interface PurchaseUpdatedListenerOptions { + /** + * iOS only. When true, listener callbacks also receive StoreKit replay events + * for a transaction ID that was already emitted during the current connection + * session. Defaults to false so purchase success handlers run once per + * transaction ID. + */ + includeDuplicateTransactionUpdatesIOS?: (boolean | null); +} + export type PurchaseVerificationProvider = 'iapkit'; export interface Query { @@ -1734,7 +1744,12 @@ export interface Subscription { promotedProductIOS: string; /** Fires when a purchase fails or is cancelled */ purchaseError: PurchaseError; - /** Fires when a purchase completes successfully or a pending purchase resolves */ + /** + * Fires when a purchase completes successfully or a pending purchase resolves + * Options can opt iOS listeners into duplicate StoreKit transaction replays + * for diagnostics; default listeners receive one event per transaction ID + * during a single connection session. + */ purchaseUpdated: Purchase; /** * Fires when an active subscription enters a billing-issue state that needs user action @@ -1758,6 +1773,9 @@ export interface Subscription { } + +export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined; + export interface SubscriptionInfoIOS { introductoryOffer?: (SubscriptionOfferIOS | null); promotionalOffers?: (SubscriptionOfferIOS[] | null); @@ -2218,7 +2236,7 @@ export type SubscriptionArgsMap = { developerProvidedBillingAndroid: never; promotedProductIOS: never; purchaseError: never; - purchaseUpdated: never; + purchaseUpdated: SubscriptionPurchaseUpdatedArgs; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; }; diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index 60b2175d..1cfeb77f 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -151,6 +151,17 @@ input PurchaseOptions { includeSuspendedAndroid: Boolean } +# Listener registration options for purchase update events +input PurchaseUpdatedListenerOptions { + """ + iOS only. When true, listener callbacks also receive StoreKit replay events + for a transaction ID that was already emitted during the current connection + session. Defaults to false so purchase success handlers run once per + transaction ID. + """ + includeDuplicateTransactionUpdatesIOS: Boolean +} + # Parameters for requestPurchase input RequestPurchaseProps { """ From 7bb73f1fec9217610cf9ba390aa9a72f18ee0f14 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 15:25:48 +0900 Subject: [PATCH 05/16] fix(listeners): route purchase replay options --- libraries/expo-iap/ios/ExpoIapHelper.swift | 58 ++++++---- libraries/expo-iap/ios/ExpoIapModule.swift | 9 +- .../src/__mocks__/expo-modules-core.js | 1 + .../expo-iap/src/__tests__/index.test.ts | 7 +- libraries/expo-iap/src/index.ts | 63 +++++++++-- .../Classes/FlutterInappPurchasePlugin.swift | 106 ++++++++++-------- .../lib/flutter_inapp_purchase.dart | 38 +++++-- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 43 +++---- .../OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs | 64 ++++++----- .../maui-iap/src/OpenIap.Maui/Subject.cs | 27 +++++ .../react-native-iap/ios/HybridRnIap.swift | 50 +-------- .../docs/src/pages/docs/updates/releases.tsx | 4 +- 12 files changed, 279 insertions(+), 191 deletions(-) diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift index 7baf92b2..b88435d0 100644 --- a/libraries/expo-iap/ios/ExpoIapHelper.swift +++ b/libraries/expo-iap/ios/ExpoIapHelper.swift @@ -22,6 +22,9 @@ final class IapException: GenericException<(code: String, message: String, produ enum ExpoIapHelper { // Disambiguate Subscription type to the one provided by OpenIAP private static var listeners: [OpenIAP.Subscription] = [] + private static var purchaseUpdatedSub: OpenIAP.Subscription? + private static var purchaseUpdatedHandler: ((Purchase) -> Void)? + private static var purchaseUpdatedOptions = PurchaseUpdatedListenerOptions() static func sanitizeDictionary(_ dictionary: [String: Any?]) -> [String: Any] { var result: [String: Any] = [:] @@ -128,7 +131,6 @@ enum ExpoIapHelper { static func setupListeners( module: ExpoIapModule, purchaseUpdated: @escaping (Purchase) -> Void, - purchaseUpdatedDuplicatesIOS: @escaping (Purchase) -> Void, purchaseError: @escaping (PurchaseError) -> Void, promotedProduct: @escaping (String) async -> Void, subscriptionBillingIssue: @escaping (Purchase) -> Void @@ -136,20 +138,8 @@ enum ExpoIapHelper { // Clean up any existing listeners first cleanupListeners() - let purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { purchase in - Task { @MainActor in - purchaseUpdated(purchase) - } - } - - let duplicateOptions = PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: true - ) - let purchaseUpdatedDuplicatesSub = OpenIapModule.shared.purchaseUpdatedListener({ purchase in - Task { @MainActor in - purchaseUpdatedDuplicatesIOS(purchase) - } - }, options: duplicateOptions) + purchaseUpdatedHandler = purchaseUpdated + attachPurchaseUpdatedListener() let purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { error in Task { @MainActor in @@ -170,18 +160,43 @@ enum ExpoIapHelper { } listeners = [ - purchaseUpdatedSub, - purchaseUpdatedDuplicatesSub, purchaseErrorSub, promotedProductSub, billingIssueSub, ] } + static func setPurchaseUpdatedListenerOptions(_ options: PurchaseUpdatedListenerOptions?) { + purchaseUpdatedOptions = options ?? PurchaseUpdatedListenerOptions() + guard purchaseUpdatedHandler != nil else { return } + if let purchaseUpdatedSub { + OpenIapModule.shared.removeListener(purchaseUpdatedSub) + self.purchaseUpdatedSub = nil + } + attachPurchaseUpdatedListener() + } + + private static func attachPurchaseUpdatedListener() { + guard let purchaseUpdatedHandler, purchaseUpdatedSub == nil else { return } + + purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener({ purchase in + Task { @MainActor in + purchaseUpdatedHandler(purchase) + } + }, options: purchaseUpdatedOptions) + } + static func cleanupListeners() { - // Clear subscriptions to prevent memory leaks - // Subscription deinit will automatically call onRemove closure + if let purchaseUpdatedSub { + OpenIapModule.shared.removeListener(purchaseUpdatedSub) + self.purchaseUpdatedSub = nil + } + for subscription in listeners { + OpenIapModule.shared.removeListener(subscription) + } listeners.removeAll() + purchaseUpdatedHandler = nil + purchaseUpdatedOptions = PurchaseUpdatedListenerOptions() } static func setupStore(module: ExpoIapModule) { @@ -192,11 +207,6 @@ enum ExpoIapHelper { let payload = sanitizeDictionary(OpenIapSerialization.purchase(purchase)) module.sendEvent(OpenIapEvent.purchaseUpdated.rawValue, payload) }, - purchaseUpdatedDuplicatesIOS: { [weak module] purchase in - guard let module else { return } - let payload = sanitizeDictionary(OpenIapSerialization.purchase(purchase)) - module.sendEvent("purchase-updated-duplicates-ios", payload) - }, purchaseError: { [weak module] error in guard let module else { return } let payload = sanitizeDictionary(OpenIapSerialization.encode(error)) diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index 8fb88476..e1e9c03d 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -20,7 +20,6 @@ public final class ExpoIapModule: Module { Events( OpenIapEvent.purchaseUpdated.rawValue, - "purchase-updated-duplicates-ios", OpenIapEvent.purchaseError.rawValue, OpenIapEvent.promotedProductIos.rawValue, OpenIapEvent.subscriptionBillingIssue.rawValue @@ -52,6 +51,14 @@ public final class ExpoIapModule: Module { return succeeded } + AsyncFunction("setPurchaseUpdatedListenerOptions") { (options: [String: Any]?) async throws -> Void in + let listenerOptions = PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: + options?["includeDuplicateTransactionUpdatesIOS"] as? Bool + ) + ExpoIapHelper.setPurchaseUpdatedListenerOptions(listenerOptions) + } + AsyncFunction("fetchProducts") { (params: [String: Any]) async throws -> [[String: Any]] in ExpoIapLog.payload("fetchProducts", payload: params) let request = try ExpoIapHelper.decodeProductRequest(from: params) diff --git a/libraries/expo-iap/src/__mocks__/expo-modules-core.js b/libraries/expo-iap/src/__mocks__/expo-modules-core.js index 3df7e636..7ed25367 100644 --- a/libraries/expo-iap/src/__mocks__/expo-modules-core.js +++ b/libraries/expo-iap/src/__mocks__/expo-modules-core.js @@ -32,6 +32,7 @@ const mockNativeModule = { verifyPurchaseWithProvider: jest.fn(), initConnection: jest.fn(), endConnection: jest.fn(), + setPurchaseUpdatedListenerOptions: jest.fn().mockResolvedValue(undefined), // Android-specific methods acknowledgePurchaseAndroid: jest.fn(), consumeProductAndroid: jest.fn(), diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index 1fba955d..29bf491c 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -73,12 +73,17 @@ describe('Public API (index.ts)', () => { it('registers duplicate-enabled purchase updated listener on iOS', () => { const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const setOptions = (ExpoIapModule as any) + .setPurchaseUpdatedListenerOptions as jest.Mock; const fn = jest.fn(); purchaseUpdatedListener(fn, { includeDuplicateTransactionUpdatesIOS: true, }); + expect(setOptions).toHaveBeenCalledWith({ + includeDuplicateTransactionUpdatesIOS: true, + }); expect(addListener).toHaveBeenCalledWith( - OpenIapEvent.PurchaseUpdatedDuplicateIOS, + OpenIapEvent.PurchaseUpdated, expect.any(Function), ); }); diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index e015633c..39e54bbd 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -52,7 +52,6 @@ export * from './onside'; // Get the native constant value export enum OpenIapEvent { PurchaseUpdated = 'purchase-updated', - PurchaseUpdatedDuplicateIOS = 'purchase-updated-duplicates-ios', PurchaseError = 'purchase-error', PromotedProductIOS = 'promoted-product-ios', UserChoiceBillingAndroid = 'user-choice-billing-android', @@ -71,7 +70,6 @@ export enum OpenIapEvent { type ExpoIapEventPayloads = { [OpenIapEvent.PurchaseUpdated]: Purchase; - [OpenIapEvent.PurchaseUpdatedDuplicateIOS]: Purchase; [OpenIapEvent.PurchaseError]: PurchaseError; [OpenIapEvent.PromotedProductIOS]: | Product @@ -97,6 +95,12 @@ type ExpoIapEmitter = { ): void; }; +type NativePurchaseUpdatedOptionsModule = { + setPurchaseUpdatedListenerOptions?: ( + options?: PurchaseUpdatedListenerOptions | null, + ) => Promise; +}; + // Use the raw native module for listener calls — JSI HostObjects require the // real native module as `this` when calling addListener. Using a Proxy as // `this` triggers "native state unsupported on Proxy" on New Architecture / Hermes. @@ -110,6 +114,25 @@ export const emitter: ExpoIapEmitter = { }, }; +let duplicatePurchaseUpdatedListenerCountIOS = 0; + +const configurePurchaseUpdatedListenerOptionsIOS = ( + includeDuplicateTransactionUpdatesIOS: boolean, +) => { + if (Platform.OS !== 'ios') return; + + const nativeModule = getNativeModule() as NativePurchaseUpdatedOptionsModule; + const promise = nativeModule.setPurchaseUpdatedListenerOptions?.({ + includeDuplicateTransactionUpdatesIOS, + }); + void promise?.catch((error: unknown) => { + ExpoIapConsole.warn( + 'Failed to configure purchase updated listener options:', + error, + ); + }); +}; + /** * TODO(v3.1.0): Remove legacy 'inapp' alias once downstream apps migrate to 'in-app'. */ @@ -164,16 +187,40 @@ export const purchaseUpdatedListener = ( listener: (event: Purchase) => void, options?: PurchaseUpdatedListenerOptions | null, ) => { + const includeDuplicateTransactionUpdatesIOS = + Platform.OS === 'ios' && + options?.includeDuplicateTransactionUpdatesIOS === true; + + if (includeDuplicateTransactionUpdatesIOS) { + duplicatePurchaseUpdatedListenerCountIOS += 1; + configurePurchaseUpdatedListenerOptionsIOS(true); + } + const wrappedListener = (event: Purchase) => { const normalized = normalizePurchasePlatform(event); listener(normalized); }; - const eventName = - Platform.OS === 'ios' && options?.includeDuplicateTransactionUpdatesIOS - ? OpenIapEvent.PurchaseUpdatedDuplicateIOS - : OpenIapEvent.PurchaseUpdated; - const emitterSubscription = emitter.addListener(eventName, wrappedListener); - return emitterSubscription; + const emitterSubscription = emitter.addListener( + OpenIapEvent.PurchaseUpdated, + wrappedListener, + ); + + if (!includeDuplicateTransactionUpdatesIOS) { + return emitterSubscription; + } + + return { + remove: () => { + emitterSubscription?.remove?.(); + duplicatePurchaseUpdatedListenerCountIOS = Math.max( + 0, + duplicatePurchaseUpdatedListenerCountIOS - 1, + ); + configurePurchaseUpdatedListenerOptionsIOS( + duplicatePurchaseUpdatedListenerCountIOS > 0, + ); + }, + }; }; export const purchaseErrorListener = ( diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift index b7613144..ee615c90 100644 --- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift @@ -14,10 +14,10 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { private var updateListenerTask: Task? // OpenIAP listener tokens private var purchaseUpdatedToken: OpenIAP.Subscription? - private var purchaseUpdatedDuplicateToken: OpenIAP.Subscription? private var purchaseErrorToken: OpenIAP.Subscription? private var promotedProductToken: OpenIAP.Subscription? private var subscriptionBillingIssueToken: OpenIAP.Subscription? + private var purchaseUpdatedListenerOptions = PurchaseUpdatedListenerOptions() // No local StoreKit caches; OpenIAP handles state internally private var processedTransactionIds: Set = [] @@ -66,6 +66,9 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { case "endConnection": endConnection(result: result) + + case "setPurchaseUpdatedListenerOptions": + setPurchaseUpdatedListenerOptions(call: call, result: result) case "fetchProducts": // OpenIAP-compliant: accepts { skus: [String], type: 'inapp'|'subs'|'all' } @@ -400,69 +403,84 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { // MARK: - OpenIAP Listeners private func setupOpenIapListeners() { - if purchaseUpdatedToken != nil || purchaseErrorToken != nil { return } FlutterIapLog.debug("Setting up OpenIAP listeners") - purchaseUpdatedToken = OpenIapModule.shared.purchaseUpdatedListener { [weak self] purchase in - self?.emitPurchaseUpdated(purchase, method: "purchase-updated") - } - - let duplicateOptions = PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: true - ) - purchaseUpdatedDuplicateToken = OpenIapModule.shared.purchaseUpdatedListener({ [weak self] purchase in - self?.emitPurchaseUpdated(purchase, method: "purchase-updated-duplicates-ios") - }, options: duplicateOptions) - - purchaseErrorToken = OpenIapModule.shared.purchaseErrorListener { [weak self] error in - Task { @MainActor in - guard let self else { return } - FlutterIapLog.debug("purchaseErrorListener fired") - let _ : [String: Any?] = [ - "code": error.code.rawValue, - "message": error.message, - "productId": error.productId - ] - let compacted = FlutterIapHelper.sanitizeDictionary([ - "code": error.code.rawValue, - "message": error.message, - "productId": error.productId - ]) - if let jsonString = FlutterIapHelper.jsonString(from: compacted) { - self.channel?.invokeMethod("purchase-error", arguments: jsonString) + attachPurchaseUpdatedListener() + + if purchaseErrorToken == nil { + purchaseErrorToken = OpenIapModule.shared.purchaseErrorListener { [weak self] error in + Task { @MainActor in + guard let self else { return } + FlutterIapLog.debug("purchaseErrorListener fired") + let _ : [String: Any?] = [ + "code": error.code.rawValue, + "message": error.message, + "productId": error.productId + ] + let compacted = FlutterIapHelper.sanitizeDictionary([ + "code": error.code.rawValue, + "message": error.message, + "productId": error.productId + ]) + if let jsonString = FlutterIapHelper.jsonString(from: compacted) { + self.channel?.invokeMethod("purchase-error", arguments: jsonString) + } } } } - promotedProductToken = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in - Task { @MainActor in - guard let self = self else { return } - FlutterIapLog.debug("promotedProductListenerIOS fired for: \(productId)") - // Emit event that Dart expects: name 'iap-promoted-product' with String payload - self.channel?.invokeMethod("iap-promoted-product", arguments: productId) + if promotedProductToken == nil { + promotedProductToken = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in + Task { @MainActor in + guard let self = self else { return } + FlutterIapLog.debug("promotedProductListenerIOS fired for: \(productId)") + // Emit event that Dart expects: name 'iap-promoted-product' with String payload + self.channel?.invokeMethod("iap-promoted-product", arguments: productId) + } } } - subscriptionBillingIssueToken = OpenIapModule.shared.subscriptionBillingIssueListener { [weak self] purchase in - Task { @MainActor in - guard let self else { return } - FlutterIapLog.debug("subscriptionBillingIssueListener fired for \(purchase.productId)") - let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase)) - if let jsonString = FlutterIapHelper.jsonString(from: payload) { - self.channel?.invokeMethod("subscription-billing-issue", arguments: jsonString) + if subscriptionBillingIssueToken == nil { + subscriptionBillingIssueToken = OpenIapModule.shared.subscriptionBillingIssueListener { [weak self] purchase in + Task { @MainActor in + guard let self else { return } + FlutterIapLog.debug("subscriptionBillingIssueListener fired for \(purchase.productId)") + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase)) + if let jsonString = FlutterIapHelper.jsonString(from: payload) { + self.channel?.invokeMethod("subscription-billing-issue", arguments: jsonString) + } } } } } + private func setPurchaseUpdatedListenerOptions(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] + purchaseUpdatedListenerOptions = PurchaseUpdatedListenerOptions( + includeDuplicateTransactionUpdatesIOS: + args?["includeDuplicateTransactionUpdatesIOS"] as? Bool + ) + if let token = purchaseUpdatedToken { + OpenIapModule.shared.removeListener(token) + purchaseUpdatedToken = nil + } + attachPurchaseUpdatedListener() + result(nil) + } + + private func attachPurchaseUpdatedListener() { + guard purchaseUpdatedToken == nil else { return } + purchaseUpdatedToken = OpenIapModule.shared.purchaseUpdatedListener({ [weak self] purchase in + self?.emitPurchaseUpdated(purchase, method: "purchase-updated") + }, options: purchaseUpdatedListenerOptions) + } + private func removeOpenIapListeners() { if let token = purchaseUpdatedToken { OpenIapModule.shared.removeListener(token) } - if let token = purchaseUpdatedDuplicateToken { OpenIapModule.shared.removeListener(token) } if let token = purchaseErrorToken { OpenIapModule.shared.removeListener(token) } if let token = promotedProductToken { OpenIapModule.shared.removeListener(token) } if let token = subscriptionBillingIssueToken { OpenIapModule.shared.removeListener(token) } purchaseUpdatedToken = nil - purchaseUpdatedDuplicateToken = nil purchaseErrorToken = nil promotedProductToken = nil subscriptionBillingIssueToken = nil diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index f0ca37e4..e962b779 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -89,8 +89,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { // Purchase event streams final StreamController _purchaseUpdatedListener = StreamController.broadcast(); - final StreamController _purchaseUpdatedDuplicateListener = - StreamController.broadcast(); final StreamController _purchaseErrorListener = StreamController.broadcast(); final StreamController @@ -114,12 +112,28 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { Stream purchaseUpdatedListenerWithOptions( gentype.PurchaseUpdatedListenerOptions? options, ) { - if (isIOS && options?.includeDuplicateTransactionUpdatesIOS == true) { - return _purchaseUpdatedDuplicateListener.stream; + if (isIOS) { + unawaited( + _setPurchaseUpdatedListenerOptions(options).catchError((Object error) { + debugPrint( + '[flutter_inapp_purchase] Failed to configure purchaseUpdatedListener options: $error', + ); + }), + ); } return purchaseUpdatedListener; } + Future _setPurchaseUpdatedListenerOptions( + gentype.PurchaseUpdatedListenerOptions? options, + ) async { + await _setPurchaseListener(); + await _channel.invokeMethod( + 'setPurchaseUpdatedListenerOptions', + options?.toJson(), + ); + } + /// Purchase error event stream Stream get purchaseErrorListener => _purchaseErrorListener.stream; @@ -160,9 +174,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { publishToLegacyStream: true, ); break; - case 'purchase-updated-duplicates-ios': - _handlePurchaseUpdatedCall(call, _purchaseUpdatedDuplicateListener); - break; case 'purchase-error': debugPrint( '[flutter_inapp_purchase] Processing purchase-error event', @@ -2650,13 +2661,16 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { }, purchaseError: () async => await purchaseErrorListener.first as gentype.PurchaseError, - purchaseUpdated: ({bool? includeDuplicateTransactionUpdatesIOS}) => - purchaseUpdatedListenerWithOptions( - gentype.PurchaseUpdatedListenerOptions( + purchaseUpdated: ({bool? includeDuplicateTransactionUpdatesIOS}) async { + final options = gentype.PurchaseUpdatedListenerOptions( includeDuplicateTransactionUpdatesIOS: includeDuplicateTransactionUpdatesIOS, - ), - ).first, + ); + if (isIOS) { + await _setPurchaseUpdatedListenerOptions(options); + } + return purchaseUpdatedListener.first; + }, subscriptionBillingIssue: () async => await subscriptionBillingIssueListener.first, userChoiceBillingAndroid: () async => diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index c8bdba52..dd030f8d 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -4,10 +4,12 @@ import io.github.hyochan.kmpiap.openiap.* import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.* +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.first import platform.Foundation.* import cocoapods.openiap.* @@ -29,19 +31,20 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { ) override val purchaseUpdatedListener: Flow = _purchaseUpdatedFlow.asSharedFlow() - private val _purchaseUpdatedDuplicateFlow = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 64, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - private val purchaseUpdatedDuplicateListener: Flow = - _purchaseUpdatedDuplicateFlow.asSharedFlow() - override fun purchaseUpdatedListener(options: PurchaseUpdatedListenerOptions?): Flow { - return if (options?.includeDuplicateTransactionUpdatesIOS == true) { - purchaseUpdatedDuplicateListener - } else { - purchaseUpdatedListener + if (options?.includeDuplicateTransactionUpdatesIOS != true) { + return purchaseUpdatedListener + } + + return callbackFlow { + val subscription = openIapModule.addPurchaseUpdatedListener( + { dictionary -> + println("[KMP-IAP iOS] Purchase updated received with options: $dictionary") + convertAnyToPurchase(dictionary)?.let { trySend(it) } + }, + true + ) + awaitClose { openIapModule.removeListener(subscription) } } } @@ -75,7 +78,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // Listener subscriptions private var purchaseSubscription: NSObject? = null - private var purchaseDuplicateSubscription: NSObject? = null private var errorSubscription: NSObject? = null private var promotedProductSubscription: NSObject? = null private var subscriptionBillingIssueSubscription: NSObject? = null @@ -101,19 +103,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } - purchaseDuplicateSubscription = openIapModule.addPurchaseUpdatedListener( - { dictionary -> - println("[KMP-IAP iOS] Duplicate-enabled purchase updated received: $dictionary") - val purchase = convertAnyToPurchase(dictionary) - if (purchase != null) { - coroutineScope.launch { - _purchaseUpdatedDuplicateFlow.emit(purchase) - } - } - }, - true - ) - // Purchase error listener errorSubscription = openIapModule.addPurchaseErrorListener { dictionary -> println("[KMP-IAP iOS] Purchase error received: $dictionary") @@ -202,8 +191,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // can freshly re-register without orphaning the previous subscriptions. purchaseSubscription?.let { openIapModule.removeListener(it) } purchaseSubscription = null - purchaseDuplicateSubscription?.let { openIapModule.removeListener(it) } - purchaseDuplicateSubscription = null errorSubscription?.let { openIapModule.removeListener(it) } errorSubscription = null promotedProductSubscription?.let { openIapModule.removeListener(it) } diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs index 50f835e9..729d35d6 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs @@ -39,18 +39,15 @@ internal class OpenIapIOS : IOpenIap, QueryResolver, MutationResolver, IDisposab private readonly OpenIapModule _module = OpenIapModule.SharedInstance(); private readonly Subject _purchaseUpdated = new(); - private readonly Subject _purchaseUpdatedDuplicate = new(); private readonly Subject _purchaseError = new(); private readonly Subject _promotedProductIOS = new(); private readonly Subject _subscriptionBillingIssue = new(); private NSObject? _purchaseUpdatedToken; - private NSObject? _purchaseUpdatedDuplicateToken; private NSObject? _purchaseErrorToken; private NSObject? _promotedProductToken; private NSObject? _billingIssueToken; private readonly Action _purchaseUpdatedCallback; - private readonly Action _purchaseUpdatedDuplicateCallback; private readonly Action _purchaseErrorCallback; private readonly Action _promotedProductCallback; private readonly Action _billingIssueCallback; @@ -74,21 +71,6 @@ public OpenIapIOS() } }; - _purchaseUpdatedDuplicateCallback = dict => - { - try - { - var node = NSObjectJsonBridge.DictToObject(dict); - if (node is null) return; - var p = node.Deserialize(JsonOptions.Default); - if (p is not null) _purchaseUpdatedDuplicate.OnNext(p); - } - catch (Exception ex) - { - Console.WriteLine($"[OpenIapIOS] duplicate purchaseUpdated listener failed: {ex.Message}"); - } - }; - _purchaseErrorCallback = dict => { try @@ -136,7 +118,7 @@ public OpenIapIOS() public IObservable PurchaseUpdated => _purchaseUpdated; public IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null) => options?.IncludeDuplicateTransactionUpdatesIOS == true - ? _purchaseUpdatedDuplicate + ? CreatePurchaseUpdatedObservable(includeDuplicateTransactionUpdatesIOS: true) : _purchaseUpdated; public IObservable PurchaseError => _purchaseError; public IObservable PromotedProductIOS => _promotedProductIOS; @@ -144,6 +126,43 @@ public IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerO public IObservable UserChoiceBillingAndroid => EmptyObservable.Instance; public IObservable DeveloperProvidedBillingAndroid => EmptyObservable.Instance; + private IObservable CreatePurchaseUpdatedObservable(bool includeDuplicateTransactionUpdatesIOS) + { + return new DelegateObservable(observer => + { + if (observer is null) throw new ArgumentNullException(nameof(observer)); + Action callback = dict => + { + try + { + var node = NSObjectJsonBridge.DictToObject(dict); + if (node is null) return; + var p = node.Deserialize(JsonOptions.Default); + if (p is not null) observer.OnNext(p); + } + catch (Exception ex) + { + observer.OnError(ex); + } + }; + + NSObject token; + lock (_listenerLock) + { + if (_disposed) throw new ObjectDisposedException(nameof(OpenIapIOS)); + token = _module.AddPurchaseUpdatedListener( + callback, + includeDuplicateTransactionUpdatesIOS); + } + + return new DisposableAction(() => + { + RemoveListener(token, nameof(PurchaseUpdatedWithOptions)); + GC.KeepAlive(callback); + }); + }); + } + private void WireListeners() { lock (_listenerLock) @@ -156,9 +175,6 @@ private void WireListeners() // can never escape into mono's native unwind path — that path has no // managed handler and aborts the process with SIGABRT. _purchaseUpdatedToken = _module.AddPurchaseUpdatedListener(_purchaseUpdatedCallback); - _purchaseUpdatedDuplicateToken = _module.AddPurchaseUpdatedListener( - _purchaseUpdatedDuplicateCallback, - includeDuplicateTransactionUpdatesIOS: true); _purchaseErrorToken = _module.AddPurchaseErrorListener(_purchaseErrorCallback); _promotedProductToken = _module.AddPromotedProductListener(_promotedProductCallback); _billingIssueToken = _module.AddSubscriptionBillingIssueListener(_billingIssueCallback); @@ -168,7 +184,6 @@ private void WireListeners() public void Dispose() { NSObject? purchaseUpdatedToken; - NSObject? purchaseUpdatedDuplicateToken; NSObject? purchaseErrorToken; NSObject? promotedProductToken; NSObject? billingIssueToken; @@ -179,20 +194,17 @@ public void Dispose() _disposed = true; purchaseUpdatedToken = _purchaseUpdatedToken; - purchaseUpdatedDuplicateToken = _purchaseUpdatedDuplicateToken; purchaseErrorToken = _purchaseErrorToken; promotedProductToken = _promotedProductToken; billingIssueToken = _billingIssueToken; _purchaseUpdatedToken = null; - _purchaseUpdatedDuplicateToken = null; _purchaseErrorToken = null; _promotedProductToken = null; _billingIssueToken = null; } RemoveListener(purchaseUpdatedToken, nameof(_purchaseUpdatedToken)); - RemoveListener(purchaseUpdatedDuplicateToken, nameof(_purchaseUpdatedDuplicateToken)); RemoveListener(purchaseErrorToken, nameof(_purchaseErrorToken)); RemoveListener(promotedProductToken, nameof(_promotedProductToken)); RemoveListener(billingIssueToken, nameof(_billingIssueToken)); diff --git a/libraries/maui-iap/src/OpenIap.Maui/Subject.cs b/libraries/maui-iap/src/OpenIap.Maui/Subject.cs index 43719056..e4e097f3 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Subject.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Subject.cs @@ -71,3 +71,30 @@ public void Dispose() } } } + +internal sealed class DelegateObservable : IObservable +{ + private readonly Func, IDisposable> _subscribe; + + public DelegateObservable(Func, IDisposable> subscribe) + { + _subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe)); + } + + public IDisposable Subscribe(IObserver observer) => _subscribe(observer); +} + +internal sealed class DisposableAction : IDisposable +{ + private Action? _dispose; + + public DisposableAction(Action dispose) + { + _dispose = dispose ?? throw new ArgumentNullException(nameof(dispose)); + } + + public void Dispose() + { + Interlocked.Exchange(ref _dispose, null)?.Invoke(); + } +} diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index 70867290..ab39191e 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -23,10 +23,6 @@ class HybridRnIap: HybridRnIapSpec { private var subscriptionBillingIssueSub: Subscription? private var lastPurchaseErrorKey: String? = nil private var lastPurchaseErrorTimestamp: TimeInterval = 0 - private var deliveredPurchaseEventKeys: Set = [] - private var deliveredPurchaseEventOrder: [String] = [] - private let purchaseEventDedupLimit = 128 - private static let duplicatePurchaseCode = "duplicate-purchase" private var purchasePayloadById: [String: [String: Any]] = [:] // Thread safety lock for listener arrays and error dedup state private let listenerLock = NSLock() @@ -1131,53 +1127,14 @@ class HybridRnIap: HybridRnIapSpec { } private func sendPurchaseUpdate(_ purchase: NitroPurchase, includeDuplicateListeners: Bool) { - let originalTxId: String - if case .second(let val) = purchase.originalTransactionIdentifierIOS { originalTxId = val } else { originalTxId = "" } - let purchaseTokenStr: String - if case .second(let val) = purchase.purchaseToken { purchaseTokenStr = val } else { purchaseTokenStr = "" } - let keyComponents: [String] = [ - purchase.id, - purchase.productId, - String(purchase.transactionDate), - originalTxId, - purchaseTokenStr - ] - let eventKey = keyComponents.joined(separator: "#") - - var isDuplicate = false let snapshot: [(NitroPurchase) -> Void] = listenerLock.withLock { if includeDuplicateListeners { return Array(purchaseUpdatedDuplicateListeners) } - if deliveredPurchaseEventKeys.contains(eventKey) { - isDuplicate = true - return [] - } - - deliveredPurchaseEventKeys.insert(eventKey) - deliveredPurchaseEventOrder.append(eventKey) - if deliveredPurchaseEventOrder.count > purchaseEventDedupLimit, let removed = deliveredPurchaseEventOrder.first { - deliveredPurchaseEventOrder.removeFirst() - deliveredPurchaseEventKeys.remove(removed) - } - return Array(purchaseUpdatedListeners) } - if isDuplicate { - RnIapLog.warn("Duplicate purchase update skipped for \(purchase.productId)") - let error = NitroPurchaseResult( - responseCode: -1, - debugMessage: nil, - code: HybridRnIap.duplicatePurchaseCode, - message: "Duplicate purchase update skipped for \(purchase.productId). Use restorePurchases or getAvailablePurchases to recover.", - purchaseToken: nil - ) - sendPurchaseError(error, productId: purchase.productId) - return - } - for listener in snapshot { listener(purchase) } @@ -1237,6 +1194,10 @@ class HybridRnIap: HybridRnIapSpec { RnIapLog.payload("removeListener", "purchaseUpdated") OpenIapModule.shared.removeListener(sub) } + if let sub = purchaseUpdatedDuplicateSub { + RnIapLog.payload("removeListener", "purchaseUpdatedDuplicate") + OpenIapModule.shared.removeListener(sub) + } if let sub = purchaseErrorSub { RnIapLog.payload("removeListener", "purchaseError") OpenIapModule.shared.removeListener(sub) @@ -1250,6 +1211,7 @@ class HybridRnIap: HybridRnIapSpec { OpenIapModule.shared.removeListener(sub) } purchaseUpdatedSub = nil + purchaseUpdatedDuplicateSub = nil purchaseErrorSub = nil promotedProductSub = nil subscriptionBillingIssueSub = nil @@ -1268,8 +1230,6 @@ class HybridRnIap: HybridRnIapSpec { subscriptionBillingIssueListeners.removeAll() lastPurchaseErrorKey = nil lastPurchaseErrorTimestamp = 0 - deliveredPurchaseEventKeys.removeAll() - deliveredPurchaseEventOrder.removeAll() } // Clear purchasePayloadById on MainActor to match its access pattern Task { @MainActor in diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index ea1d0718..b078abef 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -54,8 +54,8 @@ function Releases() { can replay the same unfinished transaction through request and transaction-update paths during a single connection session. The default listener behavior remains entitlement-safe: one purchase - success event per iOS transaction ID. Diagnostics can opt into the - StoreKit replay stream explicitly. Track the fix in{' '} + success event per iOS transaction ID. Diagnostics can opt into + StoreKit replay events explicitly. Track the fix in{' '} Date: Wed, 13 May 2026 16:03:58 +0900 Subject: [PATCH 06/16] fix(listeners): rename purchase dedupe option --- libraries/expo-iap/ios/ExpoIapModule.swift | 4 +- .../expo-iap/src/__tests__/index.test.ts | 6 +- libraries/expo-iap/src/index.ts | 39 +++++----- libraries/expo-iap/src/types.ts | 9 ++- .../Classes/FlutterInappPurchasePlugin.swift | 4 +- .../lib/flutter_inapp_purchase.dart | 72 +++++++++++++------ .../flutter_inapp_purchase/lib/types.dart | 19 +++-- .../godot-iap/addons/godot-iap/godot_iap.gd | 6 +- libraries/godot-iap/addons/godot-iap/types.gd | 12 ++-- .../Sources/GodotIap/GodotIap.swift | 15 ++-- .../kotlin/io/github/hyochan/kmpiap/KmpIap.kt | 7 +- .../io/github/hyochan/kmpiap/openiap/Types.kt | 13 ++-- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 6 +- .../ApiDefinition.cs | 4 +- .../maui-iap/src/OpenIap.Maui/OpenIap.cs | 6 +- .../OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs | 27 +++++-- libraries/maui-iap/src/OpenIap.Maui/Types.cs | 11 ++- .../react-native-iap/ios/HybridRnIap.swift | 39 +++++++--- .../src/__tests__/index.test.ts | 6 +- .../react-native-iap/src/hooks/useIAP.ts | 2 +- libraries/react-native-iap/src/index.ts | 12 ++-- libraries/react-native-iap/src/types.ts | 9 ++- packages/apple/Sources/Helpers/IapState.swift | 6 +- packages/apple/Sources/Models/Types.swift | 13 ++-- .../apple/Sources/OpenIapModule+ObjC.swift | 8 +-- packages/apple/Sources/OpenIapModule.swift | 20 ++++-- packages/apple/Tests/OpenIapTests.swift | 2 +- .../docs/events/purchase-updated-listener.tsx | 18 ++--- .../purchase-updated-listener-options.tsx | 28 +++++--- .../docs/src/pages/docs/updates/releases.tsx | 16 +++-- .../src/main/java/dev/hyo/openiap/Types.kt | 13 ++-- packages/gql/src/generated/Types.cs | 11 ++- packages/gql/src/generated/Types.kt | 13 ++-- packages/gql/src/generated/Types.swift | 13 ++-- packages/gql/src/generated/types.dart | 19 +++-- packages/gql/src/generated/types.gd | 12 ++-- packages/gql/src/generated/types.ts | 9 ++- packages/gql/src/type.graphql | 9 ++- 38 files changed, 306 insertions(+), 232 deletions(-) diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index e1e9c03d..1cbc807e 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -53,8 +53,8 @@ public final class ExpoIapModule: Module { AsyncFunction("setPurchaseUpdatedListenerOptions") { (options: [String: Any]?) async throws -> Void in let listenerOptions = PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: - options?["includeDuplicateTransactionUpdatesIOS"] as? Bool + dedupeTransactionIOS: + options?["dedupeTransactionIOS"] as? Bool ) ExpoIapHelper.setPurchaseUpdatedListenerOptions(listenerOptions) } diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index 29bf491c..f146cca4 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -71,16 +71,16 @@ describe('Public API (index.ts)', () => { expect(fn).toHaveBeenCalledWith({...event, platform: 'ios'}); }); - it('registers duplicate-enabled purchase updated listener on iOS', () => { + it('registers non-deduping purchase updated listener on iOS', () => { const addListener = (ExpoIapModule as any).addListener as jest.Mock; const setOptions = (ExpoIapModule as any) .setPurchaseUpdatedListenerOptions as jest.Mock; const fn = jest.fn(); purchaseUpdatedListener(fn, { - includeDuplicateTransactionUpdatesIOS: true, + dedupeTransactionIOS: false, }); expect(setOptions).toHaveBeenCalledWith({ - includeDuplicateTransactionUpdatesIOS: true, + dedupeTransactionIOS: false, }); expect(addListener).toHaveBeenCalledWith( OpenIapEvent.PurchaseUpdated, diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 39e54bbd..03894639 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -114,16 +114,16 @@ export const emitter: ExpoIapEmitter = { }, }; -let duplicatePurchaseUpdatedListenerCountIOS = 0; +let nonDedupingPurchaseUpdatedListenerCountIOS = 0; const configurePurchaseUpdatedListenerOptionsIOS = ( - includeDuplicateTransactionUpdatesIOS: boolean, + dedupeTransactionIOS: boolean, ) => { if (Platform.OS !== 'ios') return; const nativeModule = getNativeModule() as NativePurchaseUpdatedOptionsModule; const promise = nativeModule.setPurchaseUpdatedListenerOptions?.({ - includeDuplicateTransactionUpdatesIOS, + dedupeTransactionIOS, }); void promise?.catch((error: unknown) => { ExpoIapConsole.warn( @@ -187,14 +187,9 @@ export const purchaseUpdatedListener = ( listener: (event: Purchase) => void, options?: PurchaseUpdatedListenerOptions | null, ) => { - const includeDuplicateTransactionUpdatesIOS = + const receiveDuplicateTransactionUpdatesIOS = Platform.OS === 'ios' && - options?.includeDuplicateTransactionUpdatesIOS === true; - - if (includeDuplicateTransactionUpdatesIOS) { - duplicatePurchaseUpdatedListenerCountIOS += 1; - configurePurchaseUpdatedListenerOptionsIOS(true); - } + options?.dedupeTransactionIOS === false; const wrappedListener = (event: Purchase) => { const normalized = normalizePurchasePlatform(event); @@ -205,20 +200,26 @@ export const purchaseUpdatedListener = ( wrappedListener, ); - if (!includeDuplicateTransactionUpdatesIOS) { + if (!receiveDuplicateTransactionUpdatesIOS) { return emitterSubscription; } + nonDedupingPurchaseUpdatedListenerCountIOS += 1; + configurePurchaseUpdatedListenerOptionsIOS(false); + return { remove: () => { - emitterSubscription?.remove?.(); - duplicatePurchaseUpdatedListenerCountIOS = Math.max( - 0, - duplicatePurchaseUpdatedListenerCountIOS - 1, - ); - configurePurchaseUpdatedListenerOptionsIOS( - duplicatePurchaseUpdatedListenerCountIOS > 0, - ); + try { + emitterSubscription?.remove?.(); + } finally { + nonDedupingPurchaseUpdatedListenerCountIOS = Math.max( + 0, + nonDedupingPurchaseUpdatedListenerCountIOS - 1, + ); + configurePurchaseUpdatedListenerOptionsIOS( + nonDedupingPurchaseUpdatedListenerCountIOS === 0, + ); + } }, }; }; diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 4432d3fe..fc6fef35 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1294,12 +1294,11 @@ export type PurchaseState = 'pending' | 'purchased' | 'unknown'; export interface PurchaseUpdatedListenerOptions { /** - * iOS only. When true, listener callbacks also receive StoreKit replay events - * for a transaction ID that was already emitted during the current connection - * session. Defaults to false so purchase success handlers run once per - * transaction ID. + * iOS only. Defaults to true. When false, listener callbacks also receive + * StoreKit replay events for a transaction ID that was already emitted during + * the current connection session. Android ignores this option. */ - includeDuplicateTransactionUpdatesIOS?: (boolean | null); + dedupeTransactionIOS?: (boolean | null); } export type PurchaseVerificationProvider = 'iapkit'; diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift index ee615c90..e4416022 100644 --- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift @@ -457,8 +457,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { private func setPurchaseUpdatedListenerOptions(call: FlutterMethodCall, result: @escaping FlutterResult) { let args = call.arguments as? [String: Any] purchaseUpdatedListenerOptions = PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: - args?["includeDuplicateTransactionUpdatesIOS"] as? Bool + dedupeTransactionIOS: + args?["dedupeTransactionIOS"] as? Bool ) if let token = purchaseUpdatedToken { OpenIapModule.shared.removeListener(token) diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index e962b779..81d0f204 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -106,33 +106,50 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// Purchase updated event stream with listener options. /// - /// On iOS, set [PurchaseUpdatedListenerOptions.includeDuplicateTransactionUpdatesIOS] - /// to true to also receive StoreKit replay events for transaction IDs already - /// delivered during the current connection session. Android ignores this flag. + /// On iOS, set [PurchaseUpdatedListenerOptions.dedupeTransactionIOS] + /// to false to also receive StoreKit replay events for transaction IDs + /// already delivered during the current connection session. Android ignores + /// this flag. On iOS this configures shared native listener state for this + /// plugin instance; later option calls replace the previous native setting. Stream purchaseUpdatedListenerWithOptions( gentype.PurchaseUpdatedListenerOptions? options, ) { - if (isIOS) { - unawaited( - _setPurchaseUpdatedListenerOptions(options).catchError((Object error) { - debugPrint( - '[flutter_inapp_purchase] Failed to configure purchaseUpdatedListener options: $error', - ); - }), - ); + if (!isIOS) { + return purchaseUpdatedListener; } - return purchaseUpdatedListener; + + StreamSubscription? subscription; + late StreamController controller; + controller = StreamController.broadcast( + onListen: () { + _setPurchaseUpdatedListenerOptions(options).then((_) { + if (!controller.hasListener) return; + subscription = purchaseUpdatedListener.listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + }, onError: controller.addError); + }, + onCancel: () async { + final activeSubscription = subscription; + subscription = null; + await activeSubscription?.cancel(); + }, + ); + return controller.stream; } Future _setPurchaseUpdatedListenerOptions( gentype.PurchaseUpdatedListenerOptions? options, - ) async { - await _setPurchaseListener(); - await _channel.invokeMethod( - 'setPurchaseUpdatedListenerOptions', - options?.toJson(), - ); - } + ) => + _configurePurchaseListener(() async { + await _setPurchaseListener(); + await _channel.invokeMethod( + 'setPurchaseUpdatedListenerOptions', + options?.toJson(), + ); + }); /// Purchase error event stream Stream get purchaseErrorListener => @@ -158,6 +175,16 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { _subscriptionBillingIssueListener.stream; bool _isInitialized = false; + Future _purchaseListenerConfiguration = Future.value(); + + Future _configurePurchaseListener( + Future Function() configure, + ) { + final previous = _purchaseListenerConfiguration; + final current = previous.catchError((Object _) {}).then((_) => configure()); + _purchaseListenerConfiguration = current.catchError((Object _) {}); + return current; + } Future _setPurchaseListener() async { _purchaseController ??= StreamController.broadcast(); @@ -313,7 +340,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } try { - await _setPurchaseListener(); + await _configurePurchaseListener(_setPurchaseListener); // Build config map for alternative billing and billing program Map? config; @@ -2661,10 +2688,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { }, purchaseError: () async => await purchaseErrorListener.first as gentype.PurchaseError, - purchaseUpdated: ({bool? includeDuplicateTransactionUpdatesIOS}) async { + purchaseUpdated: ({bool? dedupeTransactionIOS}) async { final options = gentype.PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: - includeDuplicateTransactionUpdatesIOS, + dedupeTransactionIOS: dedupeTransactionIOS, ); if (isIOS) { await _setPurchaseUpdatedListenerOptions(options); diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 23f81af9..2558620f 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -4349,24 +4349,23 @@ class PurchaseOptions { class PurchaseUpdatedListenerOptions { const PurchaseUpdatedListenerOptions({ - this.includeDuplicateTransactionUpdatesIOS, + this.dedupeTransactionIOS, }); - /// iOS only. When true, listener callbacks also receive StoreKit replay events - /// for a transaction ID that was already emitted during the current connection - /// session. Defaults to false so purchase success handlers run once per - /// transaction ID. - final bool? includeDuplicateTransactionUpdatesIOS; + /// iOS only. Defaults to true. When false, listener callbacks also receive + /// StoreKit replay events for a transaction ID that was already emitted during + /// the current connection session. Android ignores this option. + final bool? dedupeTransactionIOS; factory PurchaseUpdatedListenerOptions.fromJson(Map json) { return PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: json['includeDuplicateTransactionUpdatesIOS'] as bool?, + dedupeTransactionIOS: json['dedupeTransactionIOS'] as bool?, ); } Map toJson() { return { - 'includeDuplicateTransactionUpdatesIOS': includeDuplicateTransactionUpdatesIOS, + 'dedupeTransactionIOS': dedupeTransactionIOS, }; } } @@ -5480,7 +5479,7 @@ abstract class SubscriptionResolver { /// for diagnostics; default listeners receive one event per transaction ID /// during a single connection session. Future purchaseUpdated({ - bool? includeDuplicateTransactionUpdatesIOS, + bool? dedupeTransactionIOS, }); /// Fires when an active subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: @@ -5702,7 +5701,7 @@ typedef SubscriptionDeveloperProvidedBillingAndroidHandler = Future Function(); typedef SubscriptionPurchaseErrorHandler = Future Function(); typedef SubscriptionPurchaseUpdatedHandler = Future Function({ - bool? includeDuplicateTransactionUpdatesIOS, + bool? dedupeTransactionIOS, }); typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index c350724b..75dc46ce 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -261,9 +261,9 @@ func is_store_connected() -> bool: ## Configure purchase update listener options. ## -## On iOS, set [code]include_duplicate_transaction_updates_ios[/code] to true -## to also receive StoreKit replay events for transaction IDs already delivered -## during the current connection session. Android ignores this flag. +## On iOS, set [code]dedupe_transaction_ios[/code] to false to also receive +## StoreKit replay events for transaction IDs already delivered during the +## current connection session. Android ignores this flag. func set_purchase_updated_listener_options(options = null) -> void: if typeof(options) == TYPE_OBJECT and options.has_method("to_dict"): _purchase_updated_listener_options = options.to_dict() diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 7809a85f..13bb03b9 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -3851,19 +3851,19 @@ class PurchaseOptions: return dict class PurchaseUpdatedListenerOptions: - ## iOS only. When true, listener callbacks also receive StoreKit replay events - var include_duplicate_transaction_updates_ios: Variant = null + ## iOS only. Defaults to true. When false, listener callbacks also receive + var dedupe_transaction_ios: Variant = null static func from_dict(data: Dictionary) -> PurchaseUpdatedListenerOptions: var obj = PurchaseUpdatedListenerOptions.new() - if data.has("includeDuplicateTransactionUpdatesIOS") and data["includeDuplicateTransactionUpdatesIOS"] != null: - obj.include_duplicate_transaction_updates_ios = data["includeDuplicateTransactionUpdatesIOS"] + if data.has("dedupeTransactionIOS") and data["dedupeTransactionIOS"] != null: + obj.dedupe_transaction_ios = data["dedupeTransactionIOS"] return obj func to_dict() -> Dictionary: var dict = {} - if include_duplicate_transaction_updates_ios != null: - dict["includeDuplicateTransactionUpdatesIOS"] = include_duplicate_transaction_updates_ios + if dedupe_transaction_ios != null: + dict["dedupeTransactionIOS"] = dedupe_transaction_ios return dict class RequestPurchaseAndroidProps: diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index 49bdb4a1..01c15f3a 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -65,7 +65,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { private var purchaseErrorSubscription: Subscription? private var promotedProductSubscription: Subscription? private var subscriptionBillingIssueSubscription: Subscription? - private var includeDuplicatePurchaseUpdatesIOS = false + private var dedupeTransactionIOS = true // MARK: - Initialization required init(_ context: InitContext) { @@ -117,9 +117,14 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func setPurchaseUpdatedListenerOptions(optionsJson: String) -> Bool { let data = Data(optionsJson.utf8) - let decoded = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] - includeDuplicatePurchaseUpdatesIOS = - decoded?["includeDuplicateTransactionUpdatesIOS"] as? Bool == true + guard + let decoded = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + else { + GodotIapLog.warn("setPurchaseUpdatedListenerOptions: invalid JSON") + return false + } + dedupeTransactionIOS = + decoded["dedupeTransactionIOS"] as? Bool ?? true if isConnected { if let sub = purchaseUpdateSubscription { @@ -1346,7 +1351,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { private func setupPurchaseUpdatedListener() { let options = PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: includeDuplicatePurchaseUpdatesIOS + dedupeTransactionIOS: dedupeTransactionIOS ) purchaseUpdateSubscription = openIap.purchaseUpdatedListener({ [weak self] purchase in Task { @MainActor in diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt index e30e7528..9cfb5374 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt @@ -109,9 +109,10 @@ interface KmpInAppPurchase : MutationResolver, QueryResolver, SubscriptionResolv /** * Listener for observing purchase updates with subscription options. * - * On iOS, set [PurchaseUpdatedListenerOptions.includeDuplicateTransactionUpdatesIOS] - * to true to also receive StoreKit replay events for transaction IDs already - * delivered during the current connection session. Android ignores this flag. + * On iOS, set [PurchaseUpdatedListenerOptions.dedupeTransactionIOS] + * to false to also receive StoreKit replay events for transaction IDs + * already delivered during the current connection session. Android ignores + * this flag. */ fun purchaseUpdatedListener(options: PurchaseUpdatedListenerOptions? = null): Flow diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index e637dfdd..49f8de1d 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -4417,23 +4417,22 @@ public data class PurchaseOptions( public data class PurchaseUpdatedListenerOptions( /** - * iOS only. When true, listener callbacks also receive StoreKit replay events - * for a transaction ID that was already emitted during the current connection - * session. Defaults to false so purchase success handlers run once per - * transaction ID. + * iOS only. Defaults to true. When false, listener callbacks also receive + * StoreKit replay events for a transaction ID that was already emitted during + * the current connection session. Android ignores this option. */ - val includeDuplicateTransactionUpdatesIOS: Boolean? = null + val dedupeTransactionIOS: Boolean? = null ) { companion object { fun fromJson(json: Map): PurchaseUpdatedListenerOptions { return PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS = json["includeDuplicateTransactionUpdatesIOS"] as? Boolean, + dedupeTransactionIOS = json["dedupeTransactionIOS"] as? Boolean, ) } } fun toJson(): Map = mapOf( - "includeDuplicateTransactionUpdatesIOS" to includeDuplicateTransactionUpdatesIOS, + "dedupeTransactionIOS" to dedupeTransactionIOS, ) } diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index dd030f8d..e1ffc2d9 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -32,17 +32,17 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { override val purchaseUpdatedListener: Flow = _purchaseUpdatedFlow.asSharedFlow() override fun purchaseUpdatedListener(options: PurchaseUpdatedListenerOptions?): Flow { - if (options?.includeDuplicateTransactionUpdatesIOS != true) { + if (options?.dedupeTransactionIOS != false) { return purchaseUpdatedListener } return callbackFlow { val subscription = openIapModule.addPurchaseUpdatedListener( { dictionary -> - println("[KMP-IAP iOS] Purchase updated received with options: $dictionary") + println("[KMP-IAP iOS] Purchase updated event received with dedupeTransactionIOS=false") convertAnyToPurchase(dictionary)?.let { trySend(it) } }, - true + false ) awaitClose { openIapModule.removeListener(subscription) } } diff --git a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs index 681960cb..534f6c47 100644 --- a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs +++ b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs @@ -217,10 +217,10 @@ void VerifyPurchaseWithProvider( [Export("addPurchaseUpdatedListener:")] NSObject AddPurchaseUpdatedListener(Action callback); - [Export("addPurchaseUpdatedListener:includeDuplicateTransactionUpdatesIOS:")] + [Export("addPurchaseUpdatedListener:dedupeTransactionIOS:")] NSObject AddPurchaseUpdatedListener( Action callback, - bool includeDuplicateTransactionUpdatesIOS); + bool dedupeTransactionIOS); [Export("addPurchaseErrorListener:")] NSObject AddPurchaseErrorListener(Action callback); diff --git a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs index f36b2471..b2cbde0b 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs @@ -49,9 +49,9 @@ public interface IOpenIap /// /// Stream of successful purchase updates with listener options. On iOS, - /// - /// also emits StoreKit replay events for transaction IDs already delivered - /// during the current connection session. Android ignores this flag. + /// set + /// to false to also emit StoreKit replay events for transaction IDs already + /// delivered during the current connection session. Android ignores this flag. /// IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null); diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs index 729d35d6..d0dba461 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs @@ -52,6 +52,7 @@ internal class OpenIapIOS : IOpenIap, QueryResolver, MutationResolver, IDisposab private readonly Action _promotedProductCallback; private readonly Action _billingIssueCallback; private readonly object _listenerLock = new(); + private readonly HashSet _dynamicPurchaseUpdatedTokens = new(); private bool _disposed; public OpenIapIOS() @@ -117,8 +118,8 @@ public OpenIapIOS() public IObservable PurchaseUpdated => _purchaseUpdated; public IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null) => - options?.IncludeDuplicateTransactionUpdatesIOS == true - ? CreatePurchaseUpdatedObservable(includeDuplicateTransactionUpdatesIOS: true) + options?.DedupeTransactionIOS == false + ? CreatePurchaseUpdatedObservable(dedupeTransactionIOS: false) : _purchaseUpdated; public IObservable PurchaseError => _purchaseError; public IObservable PromotedProductIOS => _promotedProductIOS; @@ -126,7 +127,7 @@ public IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerO public IObservable UserChoiceBillingAndroid => EmptyObservable.Instance; public IObservable DeveloperProvidedBillingAndroid => EmptyObservable.Instance; - private IObservable CreatePurchaseUpdatedObservable(bool includeDuplicateTransactionUpdatesIOS) + private IObservable CreatePurchaseUpdatedObservable(bool dedupeTransactionIOS) { return new DelegateObservable(observer => { @@ -152,12 +153,21 @@ private IObservable CreatePurchaseUpdatedObservable(bool includeDuplic if (_disposed) throw new ObjectDisposedException(nameof(OpenIapIOS)); token = _module.AddPurchaseUpdatedListener( callback, - includeDuplicateTransactionUpdatesIOS); + dedupeTransactionIOS); + _dynamicPurchaseUpdatedTokens.Add(token); } return new DisposableAction(() => { - RemoveListener(token, nameof(PurchaseUpdatedWithOptions)); + bool shouldRemove; + lock (_listenerLock) + { + shouldRemove = _dynamicPurchaseUpdatedTokens.Remove(token); + } + if (shouldRemove) + { + RemoveListener(token, nameof(PurchaseUpdatedWithOptions)); + } GC.KeepAlive(callback); }); }); @@ -187,6 +197,7 @@ public void Dispose() NSObject? purchaseErrorToken; NSObject? promotedProductToken; NSObject? billingIssueToken; + List dynamicPurchaseUpdatedTokens; lock (_listenerLock) { @@ -197,14 +208,20 @@ public void Dispose() purchaseErrorToken = _purchaseErrorToken; promotedProductToken = _promotedProductToken; billingIssueToken = _billingIssueToken; + dynamicPurchaseUpdatedTokens = _dynamicPurchaseUpdatedTokens.ToList(); _purchaseUpdatedToken = null; _purchaseErrorToken = null; _promotedProductToken = null; _billingIssueToken = null; + _dynamicPurchaseUpdatedTokens.Clear(); } RemoveListener(purchaseUpdatedToken, nameof(_purchaseUpdatedToken)); + foreach (var token in dynamicPurchaseUpdatedTokens) + { + RemoveListener(token, nameof(PurchaseUpdatedWithOptions)); + } RemoveListener(purchaseErrorToken, nameof(_purchaseErrorToken)); RemoveListener(promotedProductToken, nameof(_promotedProductToken)); RemoveListener(billingIssueToken, nameof(_billingIssueToken)); diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index 1562711d..e89111dd 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -3764,12 +3764,11 @@ public sealed record PurchaseOptions public sealed record PurchaseUpdatedListenerOptions { - /// iOS only. When true, listener callbacks also receive StoreKit replay events - /// for a transaction ID that was already emitted during the current connection - /// session. Defaults to false so purchase success handlers run once per - /// transaction ID. - [JsonPropertyName("includeDuplicateTransactionUpdatesIOS")] - public bool? IncludeDuplicateTransactionUpdatesIOS { get; init; } + /// iOS only. Defaults to true. When false, listener callbacks also receive + /// StoreKit replay events for a transaction ID that was already emitted during + /// the current connection session. Android ignores this option. + [JsonPropertyName("dedupeTransactionIOS")] + public bool? DedupeTransactionIOS { get; init; } } public sealed record RequestPurchaseAndroidProps diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index ab39191e..ca7a1389 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -4,6 +4,11 @@ import OpenIAP @available(iOS 15.0, macOS 14.0, tvOS 15.0, watchOS 8.0, *) class HybridRnIap: HybridRnIapSpec { + private enum PurchaseUpdatedListenerBucket { + case deduping + case nonDeduping + } + // MARK: - Properties private var updateListenerTask: Task? private var isInitialized: Bool = false @@ -17,6 +22,7 @@ class HybridRnIap: HybridRnIapSpec { // Event listeners private var purchaseUpdatedListeners: [(NitroPurchase) -> Void] = [] private var purchaseUpdatedDuplicateListeners: [(NitroPurchase) -> Void] = [] + private var purchaseUpdatedListenerBuckets: [PurchaseUpdatedListenerBucket] = [] private var purchaseErrorListeners: [(NitroPurchaseResult) -> Void] = [] private var promotedProductListeners: [(NitroProduct) -> Void] = [] private var subscriptionBillingIssueListeners: [(NitroPurchase) -> Void] = [] @@ -944,20 +950,23 @@ class HybridRnIap: HybridRnIapSpec { listener: @escaping (NitroPurchase) -> Void, options: NitroPurchaseUpdatedListenerOptions? ) throws { - let includeDuplicateTransactionUpdatesIOS: Bool = { - if case .second(let enabled) = options?.includeDuplicateTransactionUpdatesIOS { + let dedupeTransactionIOS: Bool = { + if case .second(let enabled) = options?.dedupeTransactionIOS { return enabled } - return false + return true }() + let receiveDuplicateTransactionUpdatesIOS = !dedupeTransactionIOS listenerLock.withLock { - if includeDuplicateTransactionUpdatesIOS { + if receiveDuplicateTransactionUpdatesIOS { purchaseUpdatedDuplicateListeners.append(listener) + purchaseUpdatedListenerBuckets.append(.nonDeduping) } else { purchaseUpdatedListeners.append(listener) + purchaseUpdatedListenerBuckets.append(.deduping) } } - if includeDuplicateTransactionUpdatesIOS { + if receiveDuplicateTransactionUpdatesIOS { attachDuplicatePurchaseUpdatedSubIfNeeded() } else { attachPurchaseUpdatedSubIfNeeded() @@ -970,8 +979,19 @@ class HybridRnIap: HybridRnIapSpec { func removePurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws { listenerLock.withLock { - purchaseUpdatedListeners.removeAll() - purchaseUpdatedDuplicateListeners.removeAll() + guard let bucket = purchaseUpdatedListenerBuckets.popLast() else { + return + } + switch bucket { + case .deduping: + if !purchaseUpdatedListeners.isEmpty { + purchaseUpdatedListeners.removeLast() + } + case .nonDeduping: + if !purchaseUpdatedDuplicateListeners.isEmpty { + purchaseUpdatedDuplicateListeners.removeLast() + } + } } } @@ -1077,11 +1097,11 @@ class HybridRnIap: HybridRnIapSpec { if purchaseUpdatedDuplicateSub == nil { RnIapLog.payload("purchaseUpdatedListener.register.duplicates", nil) let options = PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: true + dedupeTransactionIOS: false ) purchaseUpdatedDuplicateSub = OpenIapModule.shared.purchaseUpdatedListener({ [weak self] openIapPurchase in guard let self else { - RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, duplicate-enabled purchase event dropped") + RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, non-deduping purchase event dropped") return } Task { @MainActor in @@ -1225,6 +1245,7 @@ class HybridRnIap: HybridRnIapSpec { listenerLock.withLock { purchaseUpdatedListeners.removeAll() purchaseUpdatedDuplicateListeners.removeAll() + purchaseUpdatedListenerBuckets.removeAll() purchaseErrorListeners.removeAll() promotedProductListeners.removeAll() subscriptionBillingIssueListeners.removeAll() diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 153435c4..3e48d06e 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -162,17 +162,17 @@ describe('Public API (src/index.ts)', () => { expect(listener).not.toHaveBeenCalled(); }); - it('routes duplicate-enabled purchaseUpdatedListener through opt-in native listener', () => { + it('routes non-deduping purchaseUpdatedListener through opt-in native listener', () => { const defaultListener = jest.fn(); const duplicateListener = jest.fn(); IAP.purchaseUpdatedListener(defaultListener); IAP.purchaseUpdatedListener(duplicateListener, { - includeDuplicateTransactionUpdatesIOS: true, + dedupeTransactionIOS: false, }); expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2); expect(mockIap.addPurchaseUpdatedListener.mock.calls[1][1]).toEqual({ - includeDuplicateTransactionUpdatesIOS: true, + dedupeTransactionIOS: false, }); const nitroPurchase = { diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index 1624f0fa..227b7c18 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -272,7 +272,7 @@ export interface UseIapOptions { /** * Options for the purchase success listener. iOS defaults to suppressing * StoreKit replay events for the same transaction ID; set - * `includeDuplicateTransactionUpdatesIOS` to true only for diagnostics. + * `dedupeTransactionIOS` to false only for diagnostics. */ purchaseUpdatedListenerOptions?: PurchaseUpdatedListenerOptions | null; onPurchaseError?: (error: PurchaseError) => void; diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 2da5ae2d..61dd4149 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -339,15 +339,15 @@ export const purchaseUpdatedListener = ( listener: (purchase: Purchase) => void, options?: PurchaseUpdatedListenerOptions | null, ): EventSubscription => { - const includeDuplicateTransactionUpdatesIOS = - options?.includeDuplicateTransactionUpdatesIOS === true; - const listeners = includeDuplicateTransactionUpdatesIOS + const receiveDuplicateTransactionUpdatesIOS = + Platform.OS === 'ios' && options?.dedupeTransactionIOS === false; + const listeners = receiveDuplicateTransactionUpdatesIOS ? purchaseUpdateDuplicateJsListeners : purchaseUpdateJsListeners; listeners.add(listener); - if (!purchaseUpdateNativeAttached && !includeDuplicateTransactionUpdatesIOS) { + if (!purchaseUpdateNativeAttached && !receiveDuplicateTransactionUpdatesIOS) { try { IAP.instance.addPurchaseUpdatedListener(purchaseUpdateNativeHandler); purchaseUpdateNativeAttached = true; @@ -365,12 +365,12 @@ export const purchaseUpdatedListener = ( if ( !purchaseUpdateDuplicateNativeAttached && - includeDuplicateTransactionUpdatesIOS + receiveDuplicateTransactionUpdatesIOS ) { try { const nativeOptions: NitroPurchaseUpdatedListenerOptions & NitroPurchaseUpdatedListenerOptionsParam = { - includeDuplicateTransactionUpdatesIOS: true, + dedupeTransactionIOS: false, }; IAP.instance.addPurchaseUpdatedListener( purchaseUpdateDuplicateNativeHandler, diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 4432d3fe..fc6fef35 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1294,12 +1294,11 @@ export type PurchaseState = 'pending' | 'purchased' | 'unknown'; export interface PurchaseUpdatedListenerOptions { /** - * iOS only. When true, listener callbacks also receive StoreKit replay events - * for a transaction ID that was already emitted during the current connection - * session. Defaults to false so purchase success handlers run once per - * transaction ID. + * iOS only. Defaults to true. When false, listener callbacks also receive + * StoreKit replay events for a transaction ID that was already emitted during + * the current connection session. Android ignores this option. */ - includeDuplicateTransactionUpdatesIOS?: (boolean | null); + dedupeTransactionIOS?: (boolean | null); } export type PurchaseVerificationProvider = 'iapkit'; diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift index c55fd292..10e05af2 100644 --- a/packages/apple/Sources/Helpers/IapState.swift +++ b/packages/apple/Sources/Helpers/IapState.swift @@ -36,7 +36,7 @@ private struct PurchaseUpdateEmissionHistory { private struct PurchaseUpdatedListenerRegistration { let id: UUID let listener: PurchaseUpdatedListener - let includeDuplicateTransactionUpdatesIOS: Bool + let dedupeTransactionIOS: Bool } @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) @@ -109,7 +109,7 @@ actor IapState { purchaseUpdatedListeners.append(PurchaseUpdatedListenerRegistration( id: id, listener: listener, - includeDuplicateTransactionUpdatesIOS: options?.includeDuplicateTransactionUpdatesIOS == true + dedupeTransactionIOS: options?.dedupeTransactionIOS ?? true )) } func addPurchaseErrorListener(_ pair: (UUID, PurchaseErrorListener)) { @@ -155,7 +155,7 @@ actor IapState { func snapshotPurchaseUpdated(isDuplicate: Bool = false) -> [PurchaseUpdatedListener] { purchaseUpdatedListeners.compactMap { registration in - guard !isDuplicate || registration.includeDuplicateTransactionUpdatesIOS else { + guard !isDuplicate || !registration.dedupeTransactionIOS else { return nil } return registration.listener diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index b42ddce2..8cde48d8 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -1688,16 +1688,15 @@ public struct PurchaseOptions: Codable { } public struct PurchaseUpdatedListenerOptions: Codable { - /// iOS only. When true, listener callbacks also receive StoreKit replay events - /// for a transaction ID that was already emitted during the current connection - /// session. Defaults to false so purchase success handlers run once per - /// transaction ID. - public var includeDuplicateTransactionUpdatesIOS: Bool? + /// iOS only. Defaults to true. When false, listener callbacks also receive + /// StoreKit replay events for a transaction ID that was already emitted during + /// the current connection session. Android ignores this option. + public var dedupeTransactionIOS: Bool? public init( - includeDuplicateTransactionUpdatesIOS: Bool? = nil + dedupeTransactionIOS: Bool? = nil ) { - self.includeDuplicateTransactionUpdatesIOS = includeDuplicateTransactionUpdatesIOS + self.dedupeTransactionIOS = dedupeTransactionIOS } } diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index 44881391..c69d94aa 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -801,16 +801,16 @@ import StoreKit // MARK: - Event Listeners @objc func addPurchaseUpdatedListener(_ callback: @escaping (NSDictionary) -> Void) -> NSObject { - addPurchaseUpdatedListener(callback, includeDuplicateTransactionUpdatesIOS: false) + addPurchaseUpdatedListener(callback, dedupeTransactionIOS: true) } - @objc(addPurchaseUpdatedListener:includeDuplicateTransactionUpdatesIOS:) + @objc(addPurchaseUpdatedListener:dedupeTransactionIOS:) func addPurchaseUpdatedListener( _ callback: @escaping (NSDictionary) -> Void, - includeDuplicateTransactionUpdatesIOS: Bool + dedupeTransactionIOS: Bool ) -> NSObject { let options = PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: includeDuplicateTransactionUpdatesIOS + dedupeTransactionIOS: dedupeTransactionIOS ) let subscription = purchaseUpdatedListener({ purchase in let dictionary = OpenIapSerialization.purchase(purchase) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index f813d6e0..9217a8ac 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -387,7 +387,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // StoreKit can replay unfinished transactions through multiple paths during a // connection session. Default listeners receive each transaction id once; - // duplicate-enabled listeners can opt into the replay for diagnostics. + // non-deduping listeners can opt into the replay for diagnostics. emitPurchaseUpdate( purchase, isDuplicate: !shouldEmit, @@ -1653,7 +1653,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) // Default listeners receive each transaction id once per connection - // session. Duplicate-enabled listeners can opt into StoreKit replays. + // session. Non-deduping listeners can opt into StoreKit replays. guard await self.state.recordPurchaseUpdateEmission( id: transactionId, pendingTransaction: transaction @@ -1806,16 +1806,21 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { listenerCount: Int ) { let action = listenerCount > 0 - ? "Delivered duplicate purchase-updated event to \(listenerCount) duplicate-enabled listener(s)." + ? "Delivered duplicate purchase-updated event to \(listenerCount) non-deduping listener(s)." : "Suppressed duplicate purchase-updated listener emission." - OpenIapLog.warn(""" + let message = """ [PurchaseUpdateDedup] \(action) - Source: \(source) - Product: \(productId) - Transaction ID: \(transactionId) - Reason: this transaction id was already emitted during the current connection session. - - Scope: default listeners receive one event per transaction id; listeners registered with includeDuplicateTransactionUpdatesIOS receive StoreKit replays. - """) + - Scope: listeners dedupe by transaction id by default; listeners registered with dedupeTransactionIOS: false receive StoreKit replays. + """ + if listenerCount > 0 { + OpenIapLog.info(message) + } else { + OpenIapLog.debug(message) + } } private func emitPurchaseUpdate( @@ -1833,6 +1838,9 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { productId: purchase.productId, listenerCount: listeners.count ) + if listeners.isEmpty { + return + } } OpenIapLog.debug("✅ Emitting purchase update: Product=\(purchase.productId), Listeners=\(listeners.count)") await MainActor.run { diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift index 9fa959e9..d49d9468 100644 --- a/packages/apple/Tests/OpenIapTests.swift +++ b/packages/apple/Tests/OpenIapTests.swift @@ -126,7 +126,7 @@ final class OpenIapTests: XCTestCase { id: UUID(), listener: { _ in }, options: PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: true + dedupeTransactionIOS: false ) ) diff --git a/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx b/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx index cd94c670..e5649f19 100644 --- a/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx +++ b/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx @@ -32,9 +32,9 @@ function PurchaseUpdatedListener() {

For diagnostics, register the purchase update listener with{' '} - includeDuplicateTransactionUpdatesIOS: true. The flag - belongs to the purchase update listener only; purchase error listeners - do not receive successful StoreKit transactions. + dedupeTransactionIOS: false. The flag belongs to the + purchase update listener only; purchase error listeners do not receive + successful StoreKit transactions. Android ignores this iOS-only option.

Listener Setup

@@ -87,7 +87,7 @@ IObservable purchaseUpdates = Iap.Instance.PurchaseUpdated;`} { console.log('StoreKit replay or first delivery:', purchase.id); }, - { includeDuplicateTransactionUpdatesIOS: true } + { dedupeTransactionIOS: false } );`}
), swift: ( @@ -96,14 +96,14 @@ IObservable purchaseUpdates = Iap.Instance.PurchaseUpdated;`} ), kmp: ( {`val updates = kmpIAP.purchaseUpdatedListener( PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS = true + dedupeTransactionIOS = false ) )`} ), @@ -111,7 +111,7 @@ IObservable purchaseUpdates = Iap.Instance.PurchaseUpdated;`}{`final updates = FlutterInappPurchase.instance .purchaseUpdatedListenerWithOptions( const PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: true, + dedupeTransactionIOS: false, ), );`} ), @@ -119,12 +119,12 @@ IObservable purchaseUpdates = Iap.Instance.PurchaseUpdated;`}{`var updates = Iap.Instance.PurchaseUpdatedWithOptions( new PurchaseUpdatedListenerOptions { - IncludeDuplicateTransactionUpdatesIOS = true, + DedupeTransactionIOS = false, });`} ), gdscript: ( {`var options = Types.PurchaseUpdatedListenerOptions.new() -options.include_duplicate_transaction_updates_ios = true +options.dedupe_transaction_ios = false iap.set_purchase_updated_listener_options(options)`} ), }} diff --git a/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx b/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx index da7bafb0..f2f6662f 100644 --- a/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx +++ b/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx @@ -5,6 +5,10 @@ import LanguageTabs from '../../../components/LanguageTabs'; import SEO from '../../../components/SEO'; import { useScrollToHash } from '../../../hooks/useScrollToHash'; +/** + * Renders the docs page for purchase update listener options. + * @returns The PurchaseUpdatedListenerOptions documentation page. + */ function PurchaseUpdatedListenerOptions() { useScrollToHash(); @@ -14,7 +18,7 @@ function PurchaseUpdatedListenerOptions() { title="PurchaseUpdatedListenerOptions" description="Options for purchaseUpdatedListener, including iOS StoreKit duplicate replay delivery." path="/docs/types/purchase-updated-listener-options" - keywords="PurchaseUpdatedListenerOptions, purchaseUpdatedListener options, includeDuplicateTransactionUpdatesIOS" + keywords="PurchaseUpdatedListenerOptions, purchaseUpdatedListener options, dedupeTransactionIOS" />

PurchaseUpdatedListenerOptions

@@ -46,15 +50,16 @@ function PurchaseUpdatedListenerOptions() { - includeDuplicateTransactionUpdatesIOS + dedupeTransactionIOS boolean? - iOS only. Defaults to false. When true, the + iOS only. Defaults to true. When false, the listener also receives StoreKit replay events for a transaction ID already emitted during the current connection session. + Android ignores this option. @@ -66,8 +71,9 @@ function PurchaseUpdatedListenerOptions() {

The default is designed for entitlement safety: purchase success handlers run once per iOS transaction ID during one connection - session. Enable the flag only when you need to inspect native StoreKit - replay behavior or build your own duplicate handling. + session. Set the flag to false only when you need to + inspect native StoreKit replay behavior or build your own duplicate + handling.

@@ -77,28 +83,28 @@ function PurchaseUpdatedListenerOptions() { {{ typescript: ( {`purchaseUpdatedListener(onPurchase, { - includeDuplicateTransactionUpdatesIOS: true, + dedupeTransactionIOS: false, });`} ), swift: ( {`OpenIapModule.shared.purchaseUpdatedListener( onPurchase, options: PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: true + dedupeTransactionIOS: false ) )`} ), kmp: ( {`kmpIAP.purchaseUpdatedListener( PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS = true + dedupeTransactionIOS = false ) )`} ), dart: ( {`FlutterInappPurchase.instance.purchaseUpdatedListenerWithOptions( const PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: true, + dedupeTransactionIOS: false, ), );`} ), @@ -106,12 +112,12 @@ function PurchaseUpdatedListenerOptions() { {`Iap.Instance.PurchaseUpdatedWithOptions( new PurchaseUpdatedListenerOptions { - IncludeDuplicateTransactionUpdatesIOS = true, + DedupeTransactionIOS = false, });`} ), gdscript: ( {`var options = Types.PurchaseUpdatedListenerOptions.new() -options.include_duplicate_transaction_updates_ios = true +options.dedupe_transaction_ios = false iap.set_purchase_updated_listener_options(options)`} ), }} diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index b078abef..3f2aee13 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -50,12 +50,13 @@ function Releases() { > Publishes OpenIAP Spec 2.0.2 with{' '} PurchaseUpdatedListenerOptions and an iOS-only{' '} - includeDuplicateTransactionUpdatesIOS flag. StoreKit - can replay the same unfinished transaction through request and - transaction-update paths during a single connection session. The - default listener behavior remains entitlement-safe: one purchase - success event per iOS transaction ID. Diagnostics can opt into - StoreKit replay events explicitly. Track the fix in{' '} + dedupeTransactionIOS flag. StoreKit can replay the same + unfinished transaction through request and transaction-update paths + during a single connection session. The default listener behavior + remains entitlement-safe: one purchase success event per iOS + transaction ID because the flag defaults to true. Diagnostics can + opt into StoreKit replay events by setting it to false. Track the + fix in{' '}
Native debugging preserved — openiap-apple no longer drops duplicate StoreKit updates before framework bridges can observe them. Default listeners suppress duplicates, while - duplicate-enabled listeners receive the replay. + listeners with dedupeTransactionIOS: false receive + the replay.
  • Docs and type sync — the generated GQL types now diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index fa7863a8..58192905 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -4296,23 +4296,22 @@ public data class PurchaseOptions( public data class PurchaseUpdatedListenerOptions( /** - * iOS only. When true, listener callbacks also receive StoreKit replay events - * for a transaction ID that was already emitted during the current connection - * session. Defaults to false so purchase success handlers run once per - * transaction ID. + * iOS only. Defaults to true. When false, listener callbacks also receive + * StoreKit replay events for a transaction ID that was already emitted during + * the current connection session. Android ignores this option. */ - val includeDuplicateTransactionUpdatesIOS: Boolean? = null + val dedupeTransactionIOS: Boolean? = null ) { companion object { fun fromJson(json: Map): PurchaseUpdatedListenerOptions { return PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS = json["includeDuplicateTransactionUpdatesIOS"] as? Boolean, + dedupeTransactionIOS = json["dedupeTransactionIOS"] as? Boolean, ) } } fun toJson(): Map = mapOf( - "includeDuplicateTransactionUpdatesIOS" to includeDuplicateTransactionUpdatesIOS, + "dedupeTransactionIOS" to dedupeTransactionIOS, ) } diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index 1562711d..e89111dd 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -3764,12 +3764,11 @@ public sealed record PurchaseOptions public sealed record PurchaseUpdatedListenerOptions { - /// iOS only. When true, listener callbacks also receive StoreKit replay events - /// for a transaction ID that was already emitted during the current connection - /// session. Defaults to false so purchase success handlers run once per - /// transaction ID. - [JsonPropertyName("includeDuplicateTransactionUpdatesIOS")] - public bool? IncludeDuplicateTransactionUpdatesIOS { get; init; } + /// iOS only. Defaults to true. When false, listener callbacks also receive + /// StoreKit replay events for a transaction ID that was already emitted during + /// the current connection session. Android ignores this option. + [JsonPropertyName("dedupeTransactionIOS")] + public bool? DedupeTransactionIOS { get; init; } } public sealed record RequestPurchaseAndroidProps diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 0ae60666..7e1e6572 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -4415,23 +4415,22 @@ public data class PurchaseOptions( public data class PurchaseUpdatedListenerOptions( /** - * iOS only. When true, listener callbacks also receive StoreKit replay events - * for a transaction ID that was already emitted during the current connection - * session. Defaults to false so purchase success handlers run once per - * transaction ID. + * iOS only. Defaults to true. When false, listener callbacks also receive + * StoreKit replay events for a transaction ID that was already emitted during + * the current connection session. Android ignores this option. */ - val includeDuplicateTransactionUpdatesIOS: Boolean? = null + val dedupeTransactionIOS: Boolean? = null ) { companion object { fun fromJson(json: Map): PurchaseUpdatedListenerOptions { return PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS = json["includeDuplicateTransactionUpdatesIOS"] as? Boolean, + dedupeTransactionIOS = json["dedupeTransactionIOS"] as? Boolean, ) } } fun toJson(): Map = mapOf( - "includeDuplicateTransactionUpdatesIOS" to includeDuplicateTransactionUpdatesIOS, + "dedupeTransactionIOS" to dedupeTransactionIOS, ) } diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index b42ddce2..8cde48d8 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -1688,16 +1688,15 @@ public struct PurchaseOptions: Codable { } public struct PurchaseUpdatedListenerOptions: Codable { - /// iOS only. When true, listener callbacks also receive StoreKit replay events - /// for a transaction ID that was already emitted during the current connection - /// session. Defaults to false so purchase success handlers run once per - /// transaction ID. - public var includeDuplicateTransactionUpdatesIOS: Bool? + /// iOS only. Defaults to true. When false, listener callbacks also receive + /// StoreKit replay events for a transaction ID that was already emitted during + /// the current connection session. Android ignores this option. + public var dedupeTransactionIOS: Bool? public init( - includeDuplicateTransactionUpdatesIOS: Bool? = nil + dedupeTransactionIOS: Bool? = nil ) { - self.includeDuplicateTransactionUpdatesIOS = includeDuplicateTransactionUpdatesIOS + self.dedupeTransactionIOS = dedupeTransactionIOS } } diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 23f81af9..2558620f 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -4349,24 +4349,23 @@ class PurchaseOptions { class PurchaseUpdatedListenerOptions { const PurchaseUpdatedListenerOptions({ - this.includeDuplicateTransactionUpdatesIOS, + this.dedupeTransactionIOS, }); - /// iOS only. When true, listener callbacks also receive StoreKit replay events - /// for a transaction ID that was already emitted during the current connection - /// session. Defaults to false so purchase success handlers run once per - /// transaction ID. - final bool? includeDuplicateTransactionUpdatesIOS; + /// iOS only. Defaults to true. When false, listener callbacks also receive + /// StoreKit replay events for a transaction ID that was already emitted during + /// the current connection session. Android ignores this option. + final bool? dedupeTransactionIOS; factory PurchaseUpdatedListenerOptions.fromJson(Map json) { return PurchaseUpdatedListenerOptions( - includeDuplicateTransactionUpdatesIOS: json['includeDuplicateTransactionUpdatesIOS'] as bool?, + dedupeTransactionIOS: json['dedupeTransactionIOS'] as bool?, ); } Map toJson() { return { - 'includeDuplicateTransactionUpdatesIOS': includeDuplicateTransactionUpdatesIOS, + 'dedupeTransactionIOS': dedupeTransactionIOS, }; } } @@ -5480,7 +5479,7 @@ abstract class SubscriptionResolver { /// for diagnostics; default listeners receive one event per transaction ID /// during a single connection session. Future purchaseUpdated({ - bool? includeDuplicateTransactionUpdatesIOS, + bool? dedupeTransactionIOS, }); /// Fires when an active subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: @@ -5702,7 +5701,7 @@ typedef SubscriptionDeveloperProvidedBillingAndroidHandler = Future Function(); typedef SubscriptionPurchaseErrorHandler = Future Function(); typedef SubscriptionPurchaseUpdatedHandler = Future Function({ - bool? includeDuplicateTransactionUpdatesIOS, + bool? dedupeTransactionIOS, }); typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 7809a85f..13bb03b9 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -3851,19 +3851,19 @@ class PurchaseOptions: return dict class PurchaseUpdatedListenerOptions: - ## iOS only. When true, listener callbacks also receive StoreKit replay events - var include_duplicate_transaction_updates_ios: Variant = null + ## iOS only. Defaults to true. When false, listener callbacks also receive + var dedupe_transaction_ios: Variant = null static func from_dict(data: Dictionary) -> PurchaseUpdatedListenerOptions: var obj = PurchaseUpdatedListenerOptions.new() - if data.has("includeDuplicateTransactionUpdatesIOS") and data["includeDuplicateTransactionUpdatesIOS"] != null: - obj.include_duplicate_transaction_updates_ios = data["includeDuplicateTransactionUpdatesIOS"] + if data.has("dedupeTransactionIOS") and data["dedupeTransactionIOS"] != null: + obj.dedupe_transaction_ios = data["dedupeTransactionIOS"] return obj func to_dict() -> Dictionary: var dict = {} - if include_duplicate_transaction_updates_ios != null: - dict["includeDuplicateTransactionUpdatesIOS"] = include_duplicate_transaction_updates_ios + if dedupe_transaction_ios != null: + dict["dedupeTransactionIOS"] = dedupe_transaction_ios return dict class RequestPurchaseAndroidProps: diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 4432d3fe..fc6fef35 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1294,12 +1294,11 @@ export type PurchaseState = 'pending' | 'purchased' | 'unknown'; export interface PurchaseUpdatedListenerOptions { /** - * iOS only. When true, listener callbacks also receive StoreKit replay events - * for a transaction ID that was already emitted during the current connection - * session. Defaults to false so purchase success handlers run once per - * transaction ID. + * iOS only. Defaults to true. When false, listener callbacks also receive + * StoreKit replay events for a transaction ID that was already emitted during + * the current connection session. Android ignores this option. */ - includeDuplicateTransactionUpdatesIOS?: (boolean | null); + dedupeTransactionIOS?: (boolean | null); } export type PurchaseVerificationProvider = 'iapkit'; diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index 1cfeb77f..1bc2f085 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -154,12 +154,11 @@ input PurchaseOptions { # Listener registration options for purchase update events input PurchaseUpdatedListenerOptions { """ - iOS only. When true, listener callbacks also receive StoreKit replay events - for a transaction ID that was already emitted during the current connection - session. Defaults to false so purchase success handlers run once per - transaction ID. + iOS only. Defaults to true. When false, listener callbacks also receive + StoreKit replay events for a transaction ID that was already emitted during + the current connection session. Android ignores this option. """ - includeDuplicateTransactionUpdatesIOS: Boolean + dedupeTransactionIOS: Boolean } # Parameters for requestPurchase From 6b5534531da2254e4212373f118cdc447bd47a6a Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 16:15:32 +0900 Subject: [PATCH 07/16] docs(releases): clarify purchase dedupe option --- .../docs/src/pages/docs/updates/releases.tsx | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 3f2aee13..e6739424 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -52,11 +52,12 @@ function Releases() { PurchaseUpdatedListenerOptions and an iOS-only{' '} dedupeTransactionIOS flag. StoreKit can replay the same unfinished transaction through request and transaction-update paths - during a single connection session. The default listener behavior - remains entitlement-safe: one purchase success event per iOS - transaction ID because the flag defaults to true. Diagnostics can - opt into StoreKit replay events by setting it to false. Track the - fix in{' '} + during a single connection session. OpenIAP now gives developers an + explicit choice: keep dedupeTransactionIOS omitted or{' '} + true to receive one purchase success event per iOS + transaction ID, or set dedupeTransactionIOS: false when + you want StoreKit replay events for diagnostics or custom duplicate + handling. Android ignores this iOS-only option. Track the fix in{' '}
  • Listener-level opt-in — React Native and Expo - accept the flag on purchaseUpdatedListener; Flutter, - KMP, MAUI, and Godot expose equivalent stream or signal-level - options without changing default purchase success handling. + accept dedupeTransactionIOS on{' '} + purchaseUpdatedListener; Flutter, KMP, MAUI, and + Godot expose equivalent stream or signal-level options without + changing default purchase success handling.
  • Native debugging preserved — openiap-apple no longer drops duplicate StoreKit updates before framework bridges - can observe them. Default listeners suppress duplicates, while - listeners with dedupeTransactionIOS: false receive - the replay. + can observe them. Default listeners dedupe duplicate transaction + IDs, while listeners with dedupeTransactionIOS: false{' '} + receive the replay. +
  • +
  • + Platform scope — this option is only meaningful + on iOS because Android does not have the same StoreKit replay + path; Android SDKs accept the generated field for parity and + ignore it at runtime.
  • Docs and type sync — the generated GQL types now From 82148669a017df29aae421e980813ef7a5c9cf23 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 16:42:46 +0900 Subject: [PATCH 08/16] fix(listeners): address purchase replay review --- .../expo-iap/src/__tests__/index.test.ts | 50 ++++++++ libraries/expo-iap/src/index.ts | 87 +++++++++++++- .../lib/flutter_inapp_purchase.dart | 95 +++++++++++++++- .../flutter_inapp_purchase_channel_test.dart | 62 ++++++++++ .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 1 - .../react-native-iap/ios/HybridRnIap.swift | 107 +++++++++++++++--- packages/apple/Sources/Helpers/IapState.swift | 16 ++- 7 files changed, 387 insertions(+), 31 deletions(-) diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index f146cca4..bac734a8 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -88,6 +88,56 @@ describe('Public API (index.ts)', () => { ); }); + it('filters duplicate replay events for default listeners when a non-deduping listener is active', () => { + const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const defaultListener = jest.fn(); + const nonDedupingListener = jest.fn(); + + purchaseUpdatedListener(defaultListener); + purchaseUpdatedListener(nonDedupingListener, { + dedupeTransactionIOS: false, + }); + + const defaultHandler = addListener.mock.calls[0][1]; + const nonDedupingHandler = addListener.mock.calls[1][1]; + const event = { + id: 'expo-dedupe-replay', + productId: 'p', + platform: 'IOS', + } as any; + + defaultHandler(event); + nonDedupingHandler(event); + defaultHandler(event); + nonDedupingHandler(event); + + expect(defaultListener).toHaveBeenCalledTimes(1); + expect(nonDedupingListener).toHaveBeenCalledTimes(2); + }); + + it('resets default listener duplicate history after endConnection', async () => { + const addListener = (ExpoIapModule as any).addListener as jest.Mock; + (ExpoIapModule.endConnection as jest.Mock).mockResolvedValue(true); + const listener = jest.fn(); + + purchaseUpdatedListener(listener); + const handler = addListener.mock.calls[0][1]; + const event = { + id: 'expo-dedupe-after-reconnect', + productId: 'p', + platform: 'IOS', + } as any; + + handler(event); + handler(event); + expect(listener).toHaveBeenCalledTimes(1); + + await endConnection(); + handler(event); + + expect(listener).toHaveBeenCalledTimes(2); + }); + it('registers purchase error listener', () => { const addListener = (ExpoIapModule as any).addListener as jest.Mock; const fn = jest.fn(); diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 03894639..cc8abc1e 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -115,6 +115,54 @@ export const emitter: ExpoIapEmitter = { }; let nonDedupingPurchaseUpdatedListenerCountIOS = 0; +const purchaseUpdatedDedupeHistoryLimitIOS = 512; +const purchaseUpdatedDedupeHistoryIOS = { + ids: new Set(), + order: [] as string[], +}; +let purchaseUpdatedDedupeGenerationIOS = 0; + +type PurchaseUpdatedDedupeHistoryIOS = { + ids: Set; + order: string[]; +}; + +const purchaseUpdatedTransactionIdIOS = (purchase: Purchase) => { + if (typeof purchase.id === 'string' && purchase.id.length > 0) { + return purchase.id; + } + return null; +}; + +const rememberPurchaseUpdatedTransactionIOS = ( + transactionId: string, + history: PurchaseUpdatedDedupeHistoryIOS, +) => { + if (history.ids.has(transactionId)) { + return; + } + + history.ids.add(transactionId); + history.order.push(transactionId); + if (history.order.length > purchaseUpdatedDedupeHistoryLimitIOS) { + const evicted = history.order.shift(); + if (evicted != null) { + history.ids.delete(evicted); + } + } +}; + +const clearPurchaseUpdatedDedupeHistoryIOS = ( + history: PurchaseUpdatedDedupeHistoryIOS, +) => { + history.ids.clear(); + history.order.length = 0; +}; + +const resetPurchaseUpdatedDedupeHistoryIOS = () => { + clearPurchaseUpdatedDedupeHistoryIOS(purchaseUpdatedDedupeHistoryIOS); + purchaseUpdatedDedupeGenerationIOS += 1; +}; const configurePurchaseUpdatedListenerOptionsIOS = ( dedupeTransactionIOS: boolean, @@ -188,11 +236,37 @@ export const purchaseUpdatedListener = ( options?: PurchaseUpdatedListenerOptions | null, ) => { const receiveDuplicateTransactionUpdatesIOS = - Platform.OS === 'ios' && - options?.dedupeTransactionIOS === false; + Platform.OS === 'ios' && options?.dedupeTransactionIOS === false; + const listenerDedupeHistoryIOS: PurchaseUpdatedDedupeHistoryIOS = { + ids: new Set(purchaseUpdatedDedupeHistoryIOS.ids), + order: [...purchaseUpdatedDedupeHistoryIOS.order], + }; + let listenerDedupeGenerationIOS = purchaseUpdatedDedupeGenerationIOS; const wrappedListener = (event: Purchase) => { const normalized = normalizePurchasePlatform(event); + if (Platform.OS === 'ios') { + if (listenerDedupeGenerationIOS !== purchaseUpdatedDedupeGenerationIOS) { + clearPurchaseUpdatedDedupeHistoryIOS(listenerDedupeHistoryIOS); + listenerDedupeGenerationIOS = purchaseUpdatedDedupeGenerationIOS; + } + const transactionId = purchaseUpdatedTransactionIdIOS(normalized); + if (transactionId != null) { + const isDuplicateForListener = + listenerDedupeHistoryIOS.ids.has(transactionId); + rememberPurchaseUpdatedTransactionIOS( + transactionId, + listenerDedupeHistoryIOS, + ); + rememberPurchaseUpdatedTransactionIOS( + transactionId, + purchaseUpdatedDedupeHistoryIOS, + ); + if (!receiveDuplicateTransactionUpdatesIOS && isDuplicateForListener) { + return; + } + } + } listener(normalized); }; const emitterSubscription = emitter.addListener( @@ -463,8 +537,13 @@ export const initConnection: MutationField<'initConnection'> = async (config) => * * @see {@link https://www.openiap.dev/docs/apis/end-connection} */ -export const endConnection: MutationField<'endConnection'> = async () => - ExpoIapModule.endConnection(); +export const endConnection: MutationField<'endConnection'> = async () => { + const result = await ExpoIapModule.endConnection(); + if (result === true && Platform.OS === 'ios') { + resetPurchaseUpdatedDedupeHistoryIOS(); + } + return result; +}; /** * Retrieve products or subscriptions from the store by SKU. diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 81d0f204..ec3e17d4 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -30,6 +31,37 @@ export 'errors.dart' typedef PurchaseError = errors.PurchaseError; typedef SubscriptionOfferAndroid = gentype.AndroidSubscriptionOfferInput; +class _PurchaseUpdatedDedupeHistoryIOS { + static const int limit = 512; + + final Set ids; + final Queue order; + + _PurchaseUpdatedDedupeHistoryIOS() + : ids = {}, + order = Queue(); + + _PurchaseUpdatedDedupeHistoryIOS.copy( + _PurchaseUpdatedDedupeHistoryIOS source, + ) : ids = Set.of(source.ids), + order = Queue.of(source.order); + + bool contains(String id) => ids.contains(id); + + void record(String id) { + if (!ids.add(id)) return; + order.addLast(id); + if (order.length > limit) { + ids.remove(order.removeFirst()); + } + } + + void clear() { + ids.clear(); + order.clear(); + } +} + class FlutterInappPurchase with RequestPurchaseBuilderApi { // Singleton instance static FlutterInappPurchase? _instance; @@ -99,10 +131,13 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { gentype.DeveloperProvidedBillingDetailsAndroid>.broadcast(); final StreamController _subscriptionBillingIssueListener = StreamController.broadcast(); + final _purchaseUpdatedDedupeHistoryIOS = _PurchaseUpdatedDedupeHistoryIOS(); + int _purchaseUpdatedDedupeGenerationIOS = 0; /// Purchase updated event stream - Stream get purchaseUpdatedListener => - _purchaseUpdatedListener.stream; + Stream get purchaseUpdatedListener => isIOS + ? _purchaseUpdatedListenerStreamIOS(dedupeTransactionIOS: true) + : _purchaseUpdatedListener.stream; /// Purchase updated event stream with listener options. /// @@ -110,7 +145,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// to false to also receive StoreKit replay events for transaction IDs /// already delivered during the current connection session. Android ignores /// this flag. On iOS this configures shared native listener state for this - /// plugin instance; later option calls replace the previous native setting. + /// plugin instance; default streams still filter replayed IDs unless they + /// opt out with `dedupeTransactionIOS: false`. Stream purchaseUpdatedListenerWithOptions( gentype.PurchaseUpdatedListenerOptions? options, ) { @@ -124,7 +160,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { onListen: () { _setPurchaseUpdatedListenerOptions(options).then((_) { if (!controller.hasListener) return; - subscription = purchaseUpdatedListener.listen( + subscription = _purchaseUpdatedListenerStreamIOS( + dedupeTransactionIOS: options?.dedupeTransactionIOS != false, + ).listen( controller.add, onError: controller.addError, onDone: controller.close, @@ -140,6 +178,52 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { return controller.stream; } + Stream _purchaseUpdatedListenerStreamIOS({ + required bool dedupeTransactionIOS, + }) { + return Stream.multi((controller) { + final listenerHistory = _PurchaseUpdatedDedupeHistoryIOS.copy( + _purchaseUpdatedDedupeHistoryIOS, + ); + var listenerGeneration = _purchaseUpdatedDedupeGenerationIOS; + late StreamSubscription subscription; + subscription = _purchaseUpdatedListener.stream.listen( + (purchase) { + if (listenerGeneration != _purchaseUpdatedDedupeGenerationIOS) { + listenerHistory.clear(); + listenerGeneration = _purchaseUpdatedDedupeGenerationIOS; + } + + final transactionId = _purchaseUpdatedTransactionIdIOS(purchase); + if (transactionId != null) { + final isDuplicateForListener = + listenerHistory.contains(transactionId); + listenerHistory.record(transactionId); + _purchaseUpdatedDedupeHistoryIOS.record(transactionId); + if (dedupeTransactionIOS && isDuplicateForListener) { + return; + } + } + + controller.add(purchase); + }, + onError: controller.addError, + onDone: controller.close, + ); + controller.onCancel = () => subscription.cancel(); + }, isBroadcast: true); + } + + String? _purchaseUpdatedTransactionIdIOS(gentype.Purchase purchase) { + final id = purchase.id; + return id.isEmpty ? null : id; + } + + void _resetPurchaseUpdatedDedupeHistoryIOS() { + _purchaseUpdatedDedupeHistoryIOS.clear(); + _purchaseUpdatedDedupeGenerationIOS += 1; + } + Future _setPurchaseUpdatedListenerOptions( gentype.PurchaseUpdatedListenerOptions? options, ) => @@ -386,6 +470,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { await _channel.invokeMethod('endConnection'); _isInitialized = false; + if (isIOS) { + _resetPurchaseUpdatedDedupeHistoryIOS(); + } return true; } on PlatformException catch (error) { throw _purchaseErrorFromPlatformException( diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index 2195a774..66db139b 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -1224,6 +1224,68 @@ void main() { expect(listenerPurchase.productId, 'iap.premium'); }); + test( + 'default purchase listener filters replays while non-deduping listener receives them', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + if (call.method == 'initConnection') { + return true; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + final defaultPurchases = []; + final nonDedupingPurchases = []; + + await iap.initConnection(); + final defaultSub = iap.purchaseUpdatedListener.listen( + defaultPurchases.add, + ); + final nonDedupingSub = iap + .purchaseUpdatedListenerWithOptions( + const types.PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: false, + ), + ) + .listen(nonDedupingPurchases.add); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final purchasePayload = { + 'platform': 'ios', + 'store': 'apple', + 'productId': 'iap.premium', + 'transactionId': 'txn-dedupe-replay', + 'purchaseState': 'PURCHASED', + 'transactionReceipt': 'receipt-data', + 'transactionDate': 1700000000000, + }; + + for (var i = 0; i < 2; i += 1) { + await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + codec.encodeMethodCall( + MethodCall('purchase-updated', jsonEncode(purchasePayload)), + ), + (_) {}, + ); + } + await Future.delayed(Duration.zero); + + expect(defaultPurchases, hasLength(1)); + expect(nonDedupingPurchases, hasLength(2)); + + await defaultSub.cancel(); + await nonDedupingSub.cancel(); + }, + ); + test('purchase-error emits results to both error streams', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index e1ffc2d9..9df97a2d 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -39,7 +39,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return callbackFlow { val subscription = openIapModule.addPurchaseUpdatedListener( { dictionary -> - println("[KMP-IAP iOS] Purchase updated event received with dedupeTransactionIOS=false") convertAnyToPurchase(dictionary)?.let { trySend(it) } }, false diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index ca7a1389..a1baf5af 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -9,6 +9,11 @@ class HybridRnIap: HybridRnIapSpec { case nonDeduping } + private struct PurchaseUpdatedListenerRegistration { + let identity: ObjectIdentifier + let bucket: PurchaseUpdatedListenerBucket + } + // MARK: - Properties private var updateListenerTask: Task? private var isInitialized: Bool = false @@ -21,8 +26,10 @@ class HybridRnIap: HybridRnIapSpec { private var promotedProductSub: Subscription? // Event listeners private var purchaseUpdatedListeners: [(NitroPurchase) -> Void] = [] + private var purchaseUpdatedListenerIdentities: [ObjectIdentifier] = [] private var purchaseUpdatedDuplicateListeners: [(NitroPurchase) -> Void] = [] - private var purchaseUpdatedListenerBuckets: [PurchaseUpdatedListenerBucket] = [] + private var purchaseUpdatedDuplicateListenerIdentities: [ObjectIdentifier] = [] + private var purchaseUpdatedListenerRegistrations: [PurchaseUpdatedListenerRegistration] = [] private var purchaseErrorListeners: [(NitroPurchaseResult) -> Void] = [] private var promotedProductListeners: [(NitroProduct) -> Void] = [] private var subscriptionBillingIssueListeners: [(NitroPurchase) -> Void] = [] @@ -957,13 +964,22 @@ class HybridRnIap: HybridRnIapSpec { return true }() let receiveDuplicateTransactionUpdatesIOS = !dedupeTransactionIOS + let identity = purchaseUpdatedListenerIdentity(listener) listenerLock.withLock { if receiveDuplicateTransactionUpdatesIOS { purchaseUpdatedDuplicateListeners.append(listener) - purchaseUpdatedListenerBuckets.append(.nonDeduping) + purchaseUpdatedDuplicateListenerIdentities.append(identity) + purchaseUpdatedListenerRegistrations.append(PurchaseUpdatedListenerRegistration( + identity: identity, + bucket: .nonDeduping + )) } else { purchaseUpdatedListeners.append(listener) - purchaseUpdatedListenerBuckets.append(.deduping) + purchaseUpdatedListenerIdentities.append(identity) + purchaseUpdatedListenerRegistrations.append(PurchaseUpdatedListenerRegistration( + identity: identity, + bucket: .deduping + )) } } if receiveDuplicateTransactionUpdatesIOS { @@ -978,19 +994,70 @@ class HybridRnIap: HybridRnIapSpec { } func removePurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws { + let identity = purchaseUpdatedListenerIdentity(listener) listenerLock.withLock { - guard let bucket = purchaseUpdatedListenerBuckets.popLast() else { + if removePurchaseUpdatedListener(identity: identity) { return } - switch bucket { - case .deduping: - if !purchaseUpdatedListeners.isEmpty { - purchaseUpdatedListeners.removeLast() - } - case .nonDeduping: - if !purchaseUpdatedDuplicateListeners.isEmpty { - purchaseUpdatedDuplicateListeners.removeLast() - } + + // Nitro may not preserve a stable Swift closure identity across the bridge. + // Fall back to LIFO removal, matching the native subscription lifecycle used + // by the JS wrapper's EventSubscription.remove(). + guard let registration = purchaseUpdatedListenerRegistrations.popLast() else { + return + } + removeLastPurchaseUpdatedListener(from: registration.bucket) + } + } + + private func purchaseUpdatedListenerIdentity(_ listener: @escaping (NitroPurchase) -> Void) -> ObjectIdentifier { + ObjectIdentifier(listener as AnyObject) + } + + private func removePurchaseUpdatedListener(identity: ObjectIdentifier) -> Bool { + if let index = purchaseUpdatedListenerIdentities.lastIndex(of: identity) { + purchaseUpdatedListeners.remove(at: index) + purchaseUpdatedListenerIdentities.remove(at: index) + removePurchaseUpdatedRegistration(identity: identity, bucket: .deduping) + return true + } + + if let index = purchaseUpdatedDuplicateListenerIdentities.lastIndex(of: identity) { + purchaseUpdatedDuplicateListeners.remove(at: index) + purchaseUpdatedDuplicateListenerIdentities.remove(at: index) + removePurchaseUpdatedRegistration(identity: identity, bucket: .nonDeduping) + return true + } + + return false + } + + private func removePurchaseUpdatedRegistration( + identity: ObjectIdentifier, + bucket: PurchaseUpdatedListenerBucket + ) { + if let index = purchaseUpdatedListenerRegistrations.lastIndex(where: { + $0.identity == identity && $0.bucket == bucket + }) { + purchaseUpdatedListenerRegistrations.remove(at: index) + } + } + + private func removeLastPurchaseUpdatedListener(from bucket: PurchaseUpdatedListenerBucket) { + switch bucket { + case .deduping: + if !purchaseUpdatedListeners.isEmpty { + purchaseUpdatedListeners.removeLast() + } + if !purchaseUpdatedListenerIdentities.isEmpty { + purchaseUpdatedListenerIdentities.removeLast() + } + case .nonDeduping: + if !purchaseUpdatedDuplicateListeners.isEmpty { + purchaseUpdatedDuplicateListeners.removeLast() + } + if !purchaseUpdatedDuplicateListenerIdentities.isEmpty { + purchaseUpdatedDuplicateListenerIdentities.removeLast() } } } @@ -1071,7 +1138,10 @@ class HybridRnIap: HybridRnIapSpec { } private func attachPurchaseUpdatedSubIfNeeded() { - if purchaseUpdatedSub == nil { + listenerLock.withLock { + guard purchaseUpdatedSub == nil else { + return + } RnIapLog.payload("purchaseUpdatedListener.register", nil) purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in guard let self else { @@ -1094,7 +1164,10 @@ class HybridRnIap: HybridRnIapSpec { } private func attachDuplicatePurchaseUpdatedSubIfNeeded() { - if purchaseUpdatedDuplicateSub == nil { + listenerLock.withLock { + guard purchaseUpdatedDuplicateSub == nil else { + return + } RnIapLog.payload("purchaseUpdatedListener.register.duplicates", nil) let options = PurchaseUpdatedListenerOptions( dedupeTransactionIOS: false @@ -1244,8 +1317,10 @@ class HybridRnIap: HybridRnIapSpec { // Clear event listeners, error dedup state, and delivery state (thread-safe) listenerLock.withLock { purchaseUpdatedListeners.removeAll() + purchaseUpdatedListenerIdentities.removeAll() purchaseUpdatedDuplicateListeners.removeAll() - purchaseUpdatedListenerBuckets.removeAll() + purchaseUpdatedDuplicateListenerIdentities.removeAll() + purchaseUpdatedListenerRegistrations.removeAll() purchaseErrorListeners.removeAll() promotedProductListeners.removeAll() subscriptionBillingIssueListeners.removeAll() diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift index 10e05af2..ed32d9d0 100644 --- a/packages/apple/Sources/Helpers/IapState.swift +++ b/packages/apple/Sources/Helpers/IapState.swift @@ -8,10 +8,12 @@ import StoreKit private struct PurchaseUpdateEmissionHistory { private let limit: Int private var ids: Set = [] - private var order: [String] = [] + private var ring: [String?] + private var nextEvictionIndex = 0 init(limit: Int) { - self.limit = limit + self.limit = max(1, limit) + self.ring = Array(repeating: nil, count: self.limit) } mutating func record(_ id: String) -> Bool { @@ -19,16 +21,18 @@ private struct PurchaseUpdateEmissionHistory { return false } - order.append(id) - if order.count > limit { - ids.remove(order.removeFirst()) + if let evicted = ring[nextEvictionIndex] { + ids.remove(evicted) } + ring[nextEvictionIndex] = id + nextEvictionIndex = (nextEvictionIndex + 1) % limit return true } mutating func removeAll() { ids.removeAll() - order.removeAll() + ring = Array(repeating: nil, count: limit) + nextEvictionIndex = 0 } } From fc3ebd2b7cb8549a3671f200237c3e24708e4100 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 16:54:00 +0900 Subject: [PATCH 09/16] fix(listeners): close replay option gaps --- .../expo-iap/src/__tests__/index.test.ts | 36 +++++++++-- libraries/expo-iap/src/index.ts | 5 ++ .../lib/flutter_inapp_purchase.dart | 3 + .../flutter_inapp_purchase_channel_test.dart | 60 +++++++++++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index bac734a8..4cfd63d4 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -76,7 +76,7 @@ describe('Public API (index.ts)', () => { const setOptions = (ExpoIapModule as any) .setPurchaseUpdatedListenerOptions as jest.Mock; const fn = jest.fn(); - purchaseUpdatedListener(fn, { + const subscription = purchaseUpdatedListener(fn, { dedupeTransactionIOS: false, }); expect(setOptions).toHaveBeenCalledWith({ @@ -86,6 +86,7 @@ describe('Public API (index.ts)', () => { OpenIapEvent.PurchaseUpdated, expect.any(Function), ); + subscription.remove(); }); it('filters duplicate replay events for default listeners when a non-deduping listener is active', () => { @@ -94,9 +95,12 @@ describe('Public API (index.ts)', () => { const nonDedupingListener = jest.fn(); purchaseUpdatedListener(defaultListener); - purchaseUpdatedListener(nonDedupingListener, { - dedupeTransactionIOS: false, - }); + const nonDedupingSubscription = purchaseUpdatedListener( + nonDedupingListener, + { + dedupeTransactionIOS: false, + }, + ); const defaultHandler = addListener.mock.calls[0][1]; const nonDedupingHandler = addListener.mock.calls[1][1]; @@ -113,6 +117,7 @@ describe('Public API (index.ts)', () => { expect(defaultListener).toHaveBeenCalledTimes(1); expect(nonDedupingListener).toHaveBeenCalledTimes(2); + nonDedupingSubscription.remove(); }); it('resets default listener duplicate history after endConnection', async () => { @@ -138,6 +143,29 @@ describe('Public API (index.ts)', () => { expect(listener).toHaveBeenCalledTimes(2); }); + it('removes non-deduping purchase updated listeners idempotently', () => { + const setOptions = (ExpoIapModule as any) + .setPurchaseUpdatedListenerOptions as jest.Mock; + + const firstSubscription = purchaseUpdatedListener(jest.fn(), { + dedupeTransactionIOS: false, + }); + const secondSubscription = purchaseUpdatedListener(jest.fn(), { + dedupeTransactionIOS: false, + }); + + firstSubscription.remove(); + firstSubscription.remove(); + secondSubscription.remove(); + + expect(setOptions.mock.calls).toEqual([ + [{dedupeTransactionIOS: false}], + [{dedupeTransactionIOS: false}], + [{dedupeTransactionIOS: false}], + [{dedupeTransactionIOS: true}], + ]); + }); + it('registers purchase error listener', () => { const addListener = (ExpoIapModule as any).addListener as jest.Mock; const fn = jest.fn(); diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index cc8abc1e..7494e68d 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -280,9 +280,14 @@ export const purchaseUpdatedListener = ( nonDedupingPurchaseUpdatedListenerCountIOS += 1; configurePurchaseUpdatedListenerOptionsIOS(false); + let removed = false; return { remove: () => { + if (removed) { + return; + } + removed = true; try { emitterSubscription?.remove?.(); } finally { diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index ec3e17d4..0f274c59 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -2781,6 +2781,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); if (isIOS) { await _setPurchaseUpdatedListenerOptions(options); + return _purchaseUpdatedListenerStreamIOS( + dedupeTransactionIOS: dedupeTransactionIOS != false, + ).first; } return purchaseUpdatedListener.first; }, diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index 66db139b..860bf857 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -1286,6 +1286,66 @@ void main() { }, ); + test( + 'subscriptionHandlers.purchaseUpdated honors non-deduping iOS option', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + if (call.method == 'initConnection') { + return true; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + + await iap.initConnection(); + final defaultSub = iap.purchaseUpdatedListener.listen((_) {}); + await Future.delayed(Duration.zero); + + final purchasePayload = { + 'platform': 'ios', + 'store': 'apple', + 'productId': 'iap.premium', + 'transactionId': 'txn-handler-dedupe-replay', + 'purchaseState': 'PURCHASED', + 'transactionReceipt': 'receipt-data', + 'transactionDate': 1700000000000, + }; + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + codec.encodeMethodCall( + MethodCall('purchase-updated', jsonEncode(purchasePayload)), + ), + (_) {}, + ); + + final future = iap.subscriptionHandlers.purchaseUpdated!( + dedupeTransactionIOS: false, + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + codec.encodeMethodCall( + MethodCall('purchase-updated', jsonEncode(purchasePayload)), + ), + (_) {}, + ); + + final purchase = await future.timeout(const Duration(seconds: 1)); + expect(purchase.id, 'txn-handler-dedupe-replay'); + + await defaultSub.cancel(); + }, + ); + test('purchase-error emits results to both error streams', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { From 74d8b29e712339cca8d7e96f7f294c063ea2af75 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 17:16:16 +0900 Subject: [PATCH 10/16] fix(listeners): simplify purchase replay handling --- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 38 ------------ .../react-native-iap/ios/HybridRnIap.swift | 59 +------------------ .../docs/src/pages/docs/updates/releases.tsx | 24 ++++---- 3 files changed, 14 insertions(+), 107 deletions(-) diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 9df97a2d..4ba0c9c9 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -93,7 +93,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // Purchase updated listener purchaseSubscription = openIapModule.addPurchaseUpdatedListener { dictionary -> - println("[KMP-IAP iOS] Purchase updated received: $dictionary") val purchase = convertAnyToPurchase(dictionary) if (purchase != null) { coroutineScope.launch { @@ -104,7 +103,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // Purchase error listener errorSubscription = openIapModule.addPurchaseErrorListener { dictionary -> - println("[KMP-IAP iOS] Purchase error received: $dictionary") val map = (dictionary as? Map<*, *>)?.mapKeys { it.key.toString() } ?: return@addPurchaseErrorListener val codeString = map["code"] as? String ?: "unknown" val errorCode = ErrorCode.entries.find { it.rawValue == codeString } ?: ErrorCode.Unknown @@ -120,7 +118,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // Promoted product listener promotedProductSubscription = openIapModule.addPromotedProductListener { sku -> - println("[KMP-IAP iOS] Promoted product received: $sku") coroutineScope.launch { _promotedProductFlow.emit(sku) } @@ -128,7 +125,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // Subscription billing-issue listener (iOS 18+ Message.billingIssue via OpenIapModule) subscriptionBillingIssueSubscription = openIapModule.addSubscriptionBillingIssueListener { dictionary -> - println("[KMP-IAP iOS] subscriptionBillingIssue received: $dictionary") val purchase = convertAnyToPurchase(dictionary) if (purchase != null) { coroutineScope.launch { @@ -483,34 +479,22 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { val skus = params.skus val type = params.type?.rawValue - println("[KMP-IAP iOS] Fetching products with skus: $skus, type: $type") - println("[KMP-IAP iOS] openIapModule: $openIapModule") - openIapModule.fetchProductsWithSkus(skus, type = type) { result, error -> - println("[KMP-IAP iOS] fetchProducts callback - result: $result, error: ${error?.localizedDescription}") - if (error != null) { - println("[KMP-IAP iOS] Error fetching products: ${error.localizedDescription}") continuation.resumeWithException(Exception(error.localizedDescription)) } else if (result != null) { - println("[KMP-IAP iOS] Result type: ${result::class.simpleName}") - println("[KMP-IAP iOS] Result: $result") - // Convert [Any] to products or subscriptions based on type when (params.type) { ProductQueryType.Subs -> { val subscriptions = convertAnyListToProductSubscriptions(result) - println("[KMP-IAP iOS] Converted to ${subscriptions.size} subscriptions") continuation.resume(FetchProductsResultSubscriptions(subscriptions)) } else -> { val products = convertAnyListToProducts(result) - println("[KMP-IAP iOS] Converted to ${products.size} products") continuation.resume(FetchProductsResultProducts(products)) } } } else { - println("[KMP-IAP iOS] No result and no error, returning empty list") continuation.resume(FetchProductsResultProducts(emptyList())) } } @@ -654,7 +638,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { try { ActiveSubscription.fromJson(map) } catch (e: Exception) { - println("[KMP-IAP iOS] Failed to parse ActiveSubscription: ${e.message}") null } } ?: emptyList() @@ -683,7 +666,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { try { AppTransaction.fromJson(it) } catch (e: Exception) { - println("[KMP-IAP iOS] Failed to parse AppTransaction: ${e.message}") null } } @@ -901,7 +883,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { try { SubscriptionStatusIOS.fromJson(map) } catch (e: Exception) { - println("[KMP-IAP iOS] Failed to parse SubscriptionStatusIOS: ${e.message}") null } } ?: emptyList() @@ -1106,7 +1087,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { null } } catch (e: Exception) { - println("[KMP-IAP] Error converting to Purchase: ${e.message}") null } } @@ -1119,7 +1099,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { val list = data as? List<*> ?: return emptyList() list.mapNotNull { convertAnyToPurchase(it) } } catch (e: Exception) { - println("[KMP-IAP] Error converting to Purchase list: ${e.message}") emptyList() } } @@ -1134,7 +1113,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { convertAnyToPurchaseIOS(item) } } catch (e: Exception) { - println("[KMP-IAP] Error converting to PurchaseIOS list: ${e.message}") emptyList() } } @@ -1142,32 +1120,22 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { @Suppress("UNCHECKED_CAST") private fun convertAnyListToProducts(data: Any?): List { if (data == null) { - println("[KMP-IAP] convertAnyListToProducts: data is null") return emptyList() } - println("[KMP-IAP] convertAnyListToProducts: data type = ${data::class.simpleName}") - return try { val list = data as? List<*> if (list == null) { - println("[KMP-IAP] convertAnyListToProducts: failed to cast to List, data = $data") return emptyList() } - println("[KMP-IAP] convertAnyListToProducts: list size = ${list.size}") - list.mapNotNull { item -> - println("[KMP-IAP] convertAnyListToProducts: processing item type = ${item?.let { it::class.simpleName }}") - val dict = (item as? Map<*, *>) if (dict == null) { - println("[KMP-IAP] convertAnyListToProducts: item is not a Map, it's $item") return@mapNotNull null } val map = dict.mapKeys { it.key.toString() } - println("[KMP-IAP] convertAnyListToProducts: map keys = ${map.keys}") // Parse subscription offers from the data (if product has subscription info) val subscriptionOffers = convertAnyListToSubscriptionOffers( @@ -1197,8 +1165,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { ) } } catch (e: Exception) { - println("[KMP-IAP] Error converting to Product list: ${e.message}") - e.printStackTrace() emptyList() } } @@ -1246,7 +1212,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { ) } } catch (e: Exception) { - println("[KMP-IAP] Error converting to ProductSubscription list: ${e.message}") emptyList() } } @@ -1280,7 +1245,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } ?: ProductTypeIOS.Consumable ) } catch (e: Exception) { - println("[KMP-IAP] Error converting to ProductIOS: ${e.message}") null } } @@ -1523,7 +1487,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { ?: (map["timestampIOS"] as? Number)?.toDouble() ) } catch (e: Exception) { - println("[KMP-IAP] Error converting to SubscriptionOffer: ${e.message}") null } } @@ -1539,7 +1502,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { val list = data as? List<*> ?: return emptyList() list.mapNotNull { convertAnyToSubscriptionOffer(it) } } catch (e: Exception) { - println("[KMP-IAP] Error converting to SubscriptionOffer list: ${e.message}") emptyList() } } diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index a1baf5af..d73778d7 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -10,7 +10,6 @@ class HybridRnIap: HybridRnIapSpec { } private struct PurchaseUpdatedListenerRegistration { - let identity: ObjectIdentifier let bucket: PurchaseUpdatedListenerBucket } @@ -26,9 +25,7 @@ class HybridRnIap: HybridRnIapSpec { private var promotedProductSub: Subscription? // Event listeners private var purchaseUpdatedListeners: [(NitroPurchase) -> Void] = [] - private var purchaseUpdatedListenerIdentities: [ObjectIdentifier] = [] private var purchaseUpdatedDuplicateListeners: [(NitroPurchase) -> Void] = [] - private var purchaseUpdatedDuplicateListenerIdentities: [ObjectIdentifier] = [] private var purchaseUpdatedListenerRegistrations: [PurchaseUpdatedListenerRegistration] = [] private var purchaseErrorListeners: [(NitroPurchaseResult) -> Void] = [] private var promotedProductListeners: [(NitroProduct) -> Void] = [] @@ -964,20 +961,15 @@ class HybridRnIap: HybridRnIapSpec { return true }() let receiveDuplicateTransactionUpdatesIOS = !dedupeTransactionIOS - let identity = purchaseUpdatedListenerIdentity(listener) listenerLock.withLock { if receiveDuplicateTransactionUpdatesIOS { purchaseUpdatedDuplicateListeners.append(listener) - purchaseUpdatedDuplicateListenerIdentities.append(identity) purchaseUpdatedListenerRegistrations.append(PurchaseUpdatedListenerRegistration( - identity: identity, bucket: .nonDeduping )) } else { purchaseUpdatedListeners.append(listener) - purchaseUpdatedListenerIdentities.append(identity) purchaseUpdatedListenerRegistrations.append(PurchaseUpdatedListenerRegistration( - identity: identity, bucket: .deduping )) } @@ -993,16 +985,8 @@ class HybridRnIap: HybridRnIapSpec { listenerLock.withLock { purchaseErrorListeners.append(listener) } } - func removePurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws { - let identity = purchaseUpdatedListenerIdentity(listener) + func removePurchaseUpdatedListener(listener _: @escaping (NitroPurchase) -> Void) throws { listenerLock.withLock { - if removePurchaseUpdatedListener(identity: identity) { - return - } - - // Nitro may not preserve a stable Swift closure identity across the bridge. - // Fall back to LIFO removal, matching the native subscription lifecycle used - // by the JS wrapper's EventSubscription.remove(). guard let registration = purchaseUpdatedListenerRegistrations.popLast() else { return } @@ -1010,55 +994,16 @@ class HybridRnIap: HybridRnIapSpec { } } - private func purchaseUpdatedListenerIdentity(_ listener: @escaping (NitroPurchase) -> Void) -> ObjectIdentifier { - ObjectIdentifier(listener as AnyObject) - } - - private func removePurchaseUpdatedListener(identity: ObjectIdentifier) -> Bool { - if let index = purchaseUpdatedListenerIdentities.lastIndex(of: identity) { - purchaseUpdatedListeners.remove(at: index) - purchaseUpdatedListenerIdentities.remove(at: index) - removePurchaseUpdatedRegistration(identity: identity, bucket: .deduping) - return true - } - - if let index = purchaseUpdatedDuplicateListenerIdentities.lastIndex(of: identity) { - purchaseUpdatedDuplicateListeners.remove(at: index) - purchaseUpdatedDuplicateListenerIdentities.remove(at: index) - removePurchaseUpdatedRegistration(identity: identity, bucket: .nonDeduping) - return true - } - - return false - } - - private func removePurchaseUpdatedRegistration( - identity: ObjectIdentifier, - bucket: PurchaseUpdatedListenerBucket - ) { - if let index = purchaseUpdatedListenerRegistrations.lastIndex(where: { - $0.identity == identity && $0.bucket == bucket - }) { - purchaseUpdatedListenerRegistrations.remove(at: index) - } - } - private func removeLastPurchaseUpdatedListener(from bucket: PurchaseUpdatedListenerBucket) { switch bucket { case .deduping: if !purchaseUpdatedListeners.isEmpty { purchaseUpdatedListeners.removeLast() } - if !purchaseUpdatedListenerIdentities.isEmpty { - purchaseUpdatedListenerIdentities.removeLast() - } case .nonDeduping: if !purchaseUpdatedDuplicateListeners.isEmpty { purchaseUpdatedDuplicateListeners.removeLast() } - if !purchaseUpdatedDuplicateListenerIdentities.isEmpty { - purchaseUpdatedDuplicateListenerIdentities.removeLast() - } } } @@ -1317,9 +1262,7 @@ class HybridRnIap: HybridRnIapSpec { // Clear event listeners, error dedup state, and delivery state (thread-safe) listenerLock.withLock { purchaseUpdatedListeners.removeAll() - purchaseUpdatedListenerIdentities.removeAll() purchaseUpdatedDuplicateListeners.removeAll() - purchaseUpdatedDuplicateListenerIdentities.removeAll() purchaseUpdatedListenerRegistrations.removeAll() purchaseErrorListeners.removeAll() promotedProductListeners.removeAll() diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index e6739424..26ef66de 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -57,7 +57,9 @@ function Releases() { true to receive one purchase success event per iOS transaction ID, or set dedupeTransactionIOS: false when you want StoreKit replay events for diagnostics or custom duplicate - handling. Android ignores this iOS-only option. Track the fix in{' '} + handling. Android ignores this iOS-only option. The planned patch + rollout carries this behavior through openiap-apple, openiap-google, + and all six framework SDKs. Track the fix in{' '} -
    Package Releases
    +
    Planned Package Releases
      -
    • - - OpenIAP Spec 2.0.2 - -
    • +
    • OpenIAP Spec 2.0.2
    • +
    • openiap-apple 2.1.9
    • +
    • openiap-google 2.1.5
    • +
    • react-native-iap 15.2.4
    • +
    • expo-iap 4.2.8
    • +
    • flutter_inapp_purchase 9.2.8
    • +
    • godot-iap 2.2.10
    • +
    • kmp-iap 2.2.8
    • +
    • OpenIap.Maui 1.0.4
    From 81509fb90587d6e77b796ae0aabf905f27d6bf71 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 17:42:37 +0900 Subject: [PATCH 11/16] fix(listeners): use tokenized native removal --- libraries/expo-iap/ios/ExpoIapHelper.swift | 22 ++++-- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 29 +++++--- .../react-native-iap/ios/HybridRnIap.swift | 67 +++++++++++-------- .../src/__tests__/index.test.ts | 37 +++++++++- libraries/react-native-iap/src/index.ts | 41 +++++++++++- .../react-native-iap/src/specs/RnIap.nitro.ts | 11 ++- 6 files changed, 158 insertions(+), 49 deletions(-) diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift index b88435d0..ea17e6ec 100644 --- a/libraries/expo-iap/ios/ExpoIapHelper.swift +++ b/libraries/expo-iap/ios/ExpoIapHelper.swift @@ -21,6 +21,7 @@ final class IapException: GenericException<(code: String, message: String, produ enum ExpoIapHelper { // Disambiguate Subscription type to the one provided by OpenIAP + private static let listenerLock = NSRecursiveLock() private static var listeners: [OpenIAP.Subscription] = [] private static var purchaseUpdatedSub: OpenIAP.Subscription? private static var purchaseUpdatedHandler: ((Purchase) -> Void)? @@ -135,11 +136,14 @@ enum ExpoIapHelper { promotedProduct: @escaping (String) async -> Void, subscriptionBillingIssue: @escaping (Purchase) -> Void ) { + listenerLock.lock() + defer { listenerLock.unlock() } + // Clean up any existing listeners first - cleanupListeners() + cleanupListenersLocked() purchaseUpdatedHandler = purchaseUpdated - attachPurchaseUpdatedListener() + attachPurchaseUpdatedListenerLocked() let purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { error in Task { @MainActor in @@ -167,16 +171,19 @@ enum ExpoIapHelper { } static func setPurchaseUpdatedListenerOptions(_ options: PurchaseUpdatedListenerOptions?) { + listenerLock.lock() + defer { listenerLock.unlock() } + purchaseUpdatedOptions = options ?? PurchaseUpdatedListenerOptions() guard purchaseUpdatedHandler != nil else { return } if let purchaseUpdatedSub { OpenIapModule.shared.removeListener(purchaseUpdatedSub) self.purchaseUpdatedSub = nil } - attachPurchaseUpdatedListener() + attachPurchaseUpdatedListenerLocked() } - private static func attachPurchaseUpdatedListener() { + private static func attachPurchaseUpdatedListenerLocked() { guard let purchaseUpdatedHandler, purchaseUpdatedSub == nil else { return } purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener({ purchase in @@ -187,6 +194,13 @@ enum ExpoIapHelper { } static func cleanupListeners() { + listenerLock.lock() + defer { listenerLock.unlock() } + + cleanupListenersLocked() + } + + private static func cleanupListenersLocked() { if let purchaseUpdatedSub { OpenIapModule.shared.removeListener(purchaseUpdatedSub) self.purchaseUpdatedSub = nil diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index bef88d5a..f7576cf1 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -68,6 +68,10 @@ class OpenIapException(private val errorJson: String) : Exception() { } class HybridRnIap : HybridRnIapSpec() { + private data class PurchaseUpdatedListenerRegistration( + val token: Double, + val listener: (NitroPurchase) -> Unit + ) // Get ReactApplicationContext lazily from NitroModules private val context: ReactApplicationContext by lazy { @@ -79,7 +83,8 @@ class HybridRnIap : HybridRnIapSpec() { private val productTypeBySku = mutableMapOf() // Event listeners - private val purchaseUpdatedListeners = mutableListOf<(NitroPurchase) -> Unit>() + private val purchaseUpdatedListeners = mutableListOf() + private var nextPurchaseUpdatedListenerToken = 1.0 private val purchaseErrorListeners = mutableListOf<(NitroPurchaseResult) -> Unit>() private val promotedProductListenersIOS = mutableListOf<(NitroProduct) -> Unit>() private val userChoiceBillingListenersAndroid = mutableListOf<(UserChoiceBillingDetails) -> Unit>() @@ -302,7 +307,10 @@ class HybridRnIap : HybridRnIapSpec() { productTypeBySku.clear() isInitialized = false listenersAttached = false - synchronized(purchaseUpdatedListeners) { purchaseUpdatedListeners.clear() } + synchronized(purchaseUpdatedListeners) { + purchaseUpdatedListeners.clear() + nextPurchaseUpdatedListenerToken = 1.0 + } synchronized(purchaseErrorListeners) { purchaseErrorListeners.clear() } promotedProductListenersIOS.clear() synchronized(userChoiceBillingListenersAndroid) { userChoiceBillingListenersAndroid.clear() } @@ -786,9 +794,12 @@ class HybridRnIap : HybridRnIapSpec() { override fun addPurchaseUpdatedListener( listener: (purchase: NitroPurchase) -> Unit, options: NitroPurchaseUpdatedListenerOptions? - ) { - synchronized(purchaseUpdatedListeners) { - purchaseUpdatedListeners.add(listener) + ): Double { + return synchronized(purchaseUpdatedListeners) { + val token = nextPurchaseUpdatedListenerToken + nextPurchaseUpdatedListenerToken += 1.0 + purchaseUpdatedListeners.add(PurchaseUpdatedListenerRegistration(token, listener)) + token } } @@ -798,9 +809,9 @@ class HybridRnIap : HybridRnIapSpec() { } } - override fun removePurchaseUpdatedListener(listener: (purchase: NitroPurchase) -> Unit) { + override fun removePurchaseUpdatedListener(token: Double) { synchronized(purchaseUpdatedListeners) { - purchaseUpdatedListeners.remove(listener) + purchaseUpdatedListeners.removeAll { it.token == token } } } @@ -835,7 +846,9 @@ class HybridRnIap : HybridRnIapSpec() { "sendPurchaseUpdate", mapOf("productId" to purchase.productId, "platform" to purchase.platform) ) - val snapshot = synchronized(purchaseUpdatedListeners) { ArrayList(purchaseUpdatedListeners) } + val snapshot = synchronized(purchaseUpdatedListeners) { + purchaseUpdatedListeners.map { it.listener } + } snapshot.forEach { it(purchase) } } diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index d73778d7..dd4179a4 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -10,6 +10,7 @@ class HybridRnIap: HybridRnIapSpec { } private struct PurchaseUpdatedListenerRegistration { + let token: Double let bucket: PurchaseUpdatedListenerBucket } @@ -24,8 +25,9 @@ class HybridRnIap: HybridRnIapSpec { private var purchaseErrorSub: Subscription? private var promotedProductSub: Subscription? // Event listeners - private var purchaseUpdatedListeners: [(NitroPurchase) -> Void] = [] - private var purchaseUpdatedDuplicateListeners: [(NitroPurchase) -> Void] = [] + private var nextPurchaseUpdatedListenerToken: Double = 1 + private var purchaseUpdatedListeners: [(token: Double, listener: (NitroPurchase) -> Void)] = [] + private var purchaseUpdatedDuplicateListeners: [(token: Double, listener: (NitroPurchase) -> Void)] = [] private var purchaseUpdatedListenerRegistrations: [PurchaseUpdatedListenerRegistration] = [] private var purchaseErrorListeners: [(NitroPurchaseResult) -> Void] = [] private var promotedProductListeners: [(NitroProduct) -> Void] = [] @@ -953,7 +955,7 @@ class HybridRnIap: HybridRnIapSpec { func addPurchaseUpdatedListener( listener: @escaping (NitroPurchase) -> Void, options: NitroPurchaseUpdatedListenerOptions? - ) throws { + ) throws -> Double { let dedupeTransactionIOS: Bool = { if case .second(let enabled) = options?.dedupeTransactionIOS { return enabled @@ -961,48 +963,56 @@ class HybridRnIap: HybridRnIapSpec { return true }() let receiveDuplicateTransactionUpdatesIOS = !dedupeTransactionIOS - listenerLock.withLock { + let token = listenerLock.withLock { + let token = nextPurchaseUpdatedListenerToken + nextPurchaseUpdatedListenerToken += 1 + + let registration = PurchaseUpdatedListenerRegistration( + token: token, + bucket: receiveDuplicateTransactionUpdatesIOS ? .nonDeduping : .deduping + ) if receiveDuplicateTransactionUpdatesIOS { - purchaseUpdatedDuplicateListeners.append(listener) - purchaseUpdatedListenerRegistrations.append(PurchaseUpdatedListenerRegistration( - bucket: .nonDeduping - )) + purchaseUpdatedDuplicateListeners.append((token: token, listener: listener)) } else { - purchaseUpdatedListeners.append(listener) - purchaseUpdatedListenerRegistrations.append(PurchaseUpdatedListenerRegistration( - bucket: .deduping - )) + purchaseUpdatedListeners.append((token: token, listener: listener)) } + purchaseUpdatedListenerRegistrations.append(registration) + return token } + if receiveDuplicateTransactionUpdatesIOS { attachDuplicatePurchaseUpdatedSubIfNeeded() } else { attachPurchaseUpdatedSubIfNeeded() } + return token + } + + func removePurchaseUpdatedListener(token: Double) throws { + listenerLock.withLock { + removePurchaseUpdatedListenerRegistration(token: token) + } } func addPurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws { listenerLock.withLock { purchaseErrorListeners.append(listener) } } - func removePurchaseUpdatedListener(listener _: @escaping (NitroPurchase) -> Void) throws { - listenerLock.withLock { - guard let registration = purchaseUpdatedListenerRegistrations.popLast() else { - return - } - removeLastPurchaseUpdatedListener(from: registration.bucket) + private func removePurchaseUpdatedListenerRegistration(token: Double) { + guard let registrationIndex = purchaseUpdatedListenerRegistrations.lastIndex(where: { + $0.token == token + }) else { + return } - } - - private func removeLastPurchaseUpdatedListener(from bucket: PurchaseUpdatedListenerBucket) { - switch bucket { + let registration = purchaseUpdatedListenerRegistrations.remove(at: registrationIndex) + switch registration.bucket { case .deduping: - if !purchaseUpdatedListeners.isEmpty { - purchaseUpdatedListeners.removeLast() + if let index = purchaseUpdatedListeners.lastIndex(where: { $0.token == token }) { + purchaseUpdatedListeners.remove(at: index) } case .nonDeduping: - if !purchaseUpdatedDuplicateListeners.isEmpty { - purchaseUpdatedDuplicateListeners.removeLast() + if let index = purchaseUpdatedDuplicateListeners.lastIndex(where: { $0.token == token }) { + purchaseUpdatedDuplicateListeners.remove(at: index) } } } @@ -1167,10 +1177,10 @@ class HybridRnIap: HybridRnIapSpec { private func sendPurchaseUpdate(_ purchase: NitroPurchase, includeDuplicateListeners: Bool) { let snapshot: [(NitroPurchase) -> Void] = listenerLock.withLock { if includeDuplicateListeners { - return Array(purchaseUpdatedDuplicateListeners) + return purchaseUpdatedDuplicateListeners.map(\.listener) } - return Array(purchaseUpdatedListeners) + return purchaseUpdatedListeners.map(\.listener) } for listener in snapshot { @@ -1264,6 +1274,7 @@ class HybridRnIap: HybridRnIapSpec { purchaseUpdatedListeners.removeAll() purchaseUpdatedDuplicateListeners.removeAll() purchaseUpdatedListenerRegistrations.removeAll() + nextPurchaseUpdatedListenerToken = 1 purchaseErrorListeners.removeAll() promotedProductListeners.removeAll() subscriptionBillingIssueListeners.removeAll() diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 3e48d06e..d2348d3c 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -92,6 +92,10 @@ describe('Public API (src/index.ts)', () => { }); beforeEach(() => { jest.clearAllMocks(); + let purchaseUpdatedToken = 1; + mockIap.addPurchaseUpdatedListener.mockImplementation( + () => purchaseUpdatedToken++, + ); // Default to iOS in tests; override per-case (Platform as any).OS = 'ios'; // Re-require module to ensure fresh state if needed @@ -154,7 +158,7 @@ describe('Public API (src/index.ts)', () => { }), ); - // remove only removes from JS set, not native + // remove detaches the JS listener and removes the native token when empty sub.remove(); // Verify listener no longer fires after removal listener.mockClear(); @@ -193,6 +197,37 @@ describe('Public API (src/index.ts)', () => { expect(duplicateListener).toHaveBeenCalledTimes(1); }); + it('removes purchase updated native listener by token after the last JS listener is removed', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + const sub1 = IAP.purchaseUpdatedListener(listener1); + const sub2 = IAP.purchaseUpdatedListener(listener2); + + expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1); + sub1.remove(); + expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled(); + + sub2.remove(); + sub2.remove(); + expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledTimes(1); + expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(1); + }); + + it('removes non-deduping purchase updated native listener by its own token', () => { + const defaultSub = IAP.purchaseUpdatedListener(jest.fn()); + const duplicateSub = IAP.purchaseUpdatedListener(jest.fn(), { + dedupeTransactionIOS: false, + }); + + expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2); + duplicateSub.remove(); + expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(2); + + defaultSub.remove(); + expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(1); + expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledTimes(2); + }); + it('purchaseErrorListener forwards error objects and supports removal', () => { const listener = jest.fn(); const sub = IAP.purchaseErrorListener(listener); diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 61dd4149..0b2d5850 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -239,6 +239,8 @@ const purchaseUpdateDuplicateJsListeners = new Set< >(); let purchaseUpdateNativeAttached = false; let purchaseUpdateDuplicateNativeAttached = false; +let purchaseUpdateNativeToken: number | null = null; +let purchaseUpdateDuplicateNativeToken: number | null = null; const emitPurchaseUpdateToListeners = ( nitroPurchase: Parameters[0], listeners: Set<(purchase: Purchase) => void>, @@ -320,6 +322,8 @@ const promotedProductNativeHandler: NitroPromotedProductListener = ( export const resetListenerState = (): void => { purchaseUpdateNativeAttached = false; purchaseUpdateDuplicateNativeAttached = false; + purchaseUpdateNativeToken = null; + purchaseUpdateDuplicateNativeToken = null; purchaseErrorNativeAttached = false; promotedProductNativeAttached = false; userChoiceBillingNativeAttached = false; @@ -349,7 +353,10 @@ export const purchaseUpdatedListener = ( if (!purchaseUpdateNativeAttached && !receiveDuplicateTransactionUpdatesIOS) { try { - IAP.instance.addPurchaseUpdatedListener(purchaseUpdateNativeHandler); + const token = IAP.instance.addPurchaseUpdatedListener( + purchaseUpdateNativeHandler, + ); + purchaseUpdateNativeToken = typeof token === 'number' ? token : null; purchaseUpdateNativeAttached = true; } catch (e) { const msg = toErrorMessage(e); @@ -372,10 +379,12 @@ export const purchaseUpdatedListener = ( NitroPurchaseUpdatedListenerOptionsParam = { dedupeTransactionIOS: false, }; - IAP.instance.addPurchaseUpdatedListener( + const token = IAP.instance.addPurchaseUpdatedListener( purchaseUpdateDuplicateNativeHandler, nativeOptions, ); + purchaseUpdateDuplicateNativeToken = + typeof token === 'number' ? token : null; purchaseUpdateDuplicateNativeAttached = true; } catch (e) { const msg = toErrorMessage(e); @@ -389,9 +398,37 @@ export const purchaseUpdatedListener = ( } } + let removed = false; return { remove: () => { + if (removed) { + return; + } + removed = true; listeners.delete(listener); + if (listeners.size > 0) { + return; + } + + const token = receiveDuplicateTransactionUpdatesIOS + ? purchaseUpdateDuplicateNativeToken + : purchaseUpdateNativeToken; + if (token == null) { + return; + } + + try { + IAP.instance.removePurchaseUpdatedListener(token); + if (receiveDuplicateTransactionUpdatesIOS) { + purchaseUpdateDuplicateNativeToken = null; + purchaseUpdateDuplicateNativeAttached = false; + } else { + purchaseUpdateNativeToken = null; + purchaseUpdateNativeAttached = false; + } + } catch (e) { + RnIapConsole.warn('[purchaseUpdatedListener] native remove failed:', e); + } }, }; }; diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index fa927fd9..65f86e25 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -147,7 +147,8 @@ export interface NitroReceiptValidationHorizonOptions { userId: VerifyPurchaseHorizonOptions['userId']; } -export interface NitroPurchaseUpdatedListenerOptions extends PurchaseUpdatedListenerOptions {} +export interface NitroPurchaseUpdatedListenerOptions + extends PurchaseUpdatedListenerOptions {} export interface NitroReceiptValidationParams { apple?: NitroReceiptValidationAppleOptions | null; @@ -731,7 +732,7 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { addPurchaseUpdatedListener( listener: (purchase: NitroPurchase) => void, options?: NitroPurchaseUpdatedListenerOptions, - ): void; + ): number; /** * Add a listener for purchase errors @@ -743,11 +744,9 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { /** * Remove a purchase updated listener - * @param listener - Function to remove from listeners + * @param token - Token returned from addPurchaseUpdatedListener */ - removePurchaseUpdatedListener( - listener: (purchase: NitroPurchase) => void, - ): void; + removePurchaseUpdatedListener(token: number): void; /** * Remove a purchase error listener From 2d46e42fa4348ab2aedb0c012eca140e97e15390 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 18:00:30 +0900 Subject: [PATCH 12/16] fix(listeners): clean up native listener removal --- .../src/__mocks__/expo-modules-core.js | 1 + .../expo-iap/src/__tests__/index.test.ts | 32 ++++++++++++++++++- libraries/expo-iap/src/index.ts | 22 +++++++++++-- .../react-native-iap/ios/HybridRnIap.swift | 20 ++++++++++-- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/libraries/expo-iap/src/__mocks__/expo-modules-core.js b/libraries/expo-iap/src/__mocks__/expo-modules-core.js index 7ed25367..8fb9c953 100644 --- a/libraries/expo-iap/src/__mocks__/expo-modules-core.js +++ b/libraries/expo-iap/src/__mocks__/expo-modules-core.js @@ -41,6 +41,7 @@ const mockNativeModule = { launchExternalLinkAndroid: jest.fn(), createBillingProgramReportingDetailsAndroid: jest.fn(), addListener: jest.fn(), + removeListener: jest.fn(), removeListeners: jest.fn(), }; diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index 4cfd63d4..f5a9951d 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -60,7 +60,7 @@ describe('Public API (index.ts)', () => { it('registers purchase updated listener', () => { const addListener = (ExpoIapModule as any).addListener as jest.Mock; const fn = jest.fn(); - purchaseUpdatedListener(fn); + const subscription = purchaseUpdatedListener(fn); expect(addListener).toHaveBeenCalledWith( OpenIapEvent.PurchaseUpdated, expect.any(Function), @@ -69,6 +69,7 @@ describe('Public API (index.ts)', () => { const event = {id: 't', productId: 'p', platform: 'IOS'} as any; passed(event); expect(fn).toHaveBeenCalledWith({...event, platform: 'ios'}); + expect(typeof subscription.remove).toBe('function'); }); it('registers non-deduping purchase updated listener on iOS', () => { @@ -89,6 +90,35 @@ describe('Public API (index.ts)', () => { subscription.remove(); }); + it('removes listener through native subscription when available', () => { + const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const nativeRemove = jest.fn(); + addListener.mockReturnValueOnce({remove: nativeRemove}); + + const subscription = purchaseUpdatedListener(jest.fn()); + subscription.remove(); + subscription.remove(); + + expect(nativeRemove).toHaveBeenCalledTimes(1); + }); + + it('falls back to native removeListener when addListener returns void', () => { + const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const removeListener = (ExpoIapModule as any).removeListener as jest.Mock; + addListener.mockReturnValueOnce(undefined); + + const subscription = purchaseUpdatedListener(jest.fn()); + const nativeListener = addListener.mock.calls[0][1]; + subscription.remove(); + subscription.remove(); + + expect(removeListener).toHaveBeenCalledTimes(1); + expect(removeListener).toHaveBeenCalledWith( + OpenIapEvent.PurchaseUpdated, + nativeListener, + ); + }); + it('filters duplicate replay events for default listeners when a non-deduping listener is active', () => { const addListener = (ExpoIapModule as any).addListener as jest.Mock; const defaultListener = jest.fn(); diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 7494e68d..65da8347 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -107,10 +107,28 @@ type NativePurchaseUpdatedOptionsModule = { // Resolved lazily so importing this module doesn't throw on unsupported platforms. export const emitter: ExpoIapEmitter = { addListener(eventName, listener) { - return getNativeModule().addListener(eventName, listener); + const nativeModule = getNativeModule(); + const nativeSubscription = nativeModule.addListener(eventName, listener); + let removed = false; + + return { + remove: () => { + if (removed) { + return; + } + removed = true; + + if (typeof nativeSubscription?.remove === 'function') { + nativeSubscription.remove(); + return; + } + + nativeModule.removeListener?.(eventName, listener); + }, + }; }, removeListener(eventName, listener) { - return getNativeModule().removeListener(eventName, listener); + return getNativeModule().removeListener?.(eventName, listener); }, }; diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index dd4179a4..5c30696c 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -989,20 +989,24 @@ class HybridRnIap: HybridRnIapSpec { } func removePurchaseUpdatedListener(token: Double) throws { - listenerLock.withLock { + let removedSubscription = listenerLock.withLock { removePurchaseUpdatedListenerRegistration(token: token) } + if let removedSubscription { + RnIapLog.payload("removeListener", removedSubscription.label) + OpenIapModule.shared.removeListener(removedSubscription.subscription) + } } func addPurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws { listenerLock.withLock { purchaseErrorListeners.append(listener) } } - private func removePurchaseUpdatedListenerRegistration(token: Double) { + private func removePurchaseUpdatedListenerRegistration(token: Double) -> (label: String, subscription: Subscription)? { guard let registrationIndex = purchaseUpdatedListenerRegistrations.lastIndex(where: { $0.token == token }) else { - return + return nil } let registration = purchaseUpdatedListenerRegistrations.remove(at: registrationIndex) switch registration.bucket { @@ -1010,10 +1014,20 @@ class HybridRnIap: HybridRnIapSpec { if let index = purchaseUpdatedListeners.lastIndex(where: { $0.token == token }) { purchaseUpdatedListeners.remove(at: index) } + guard purchaseUpdatedListeners.isEmpty, let sub = purchaseUpdatedSub else { + return nil + } + purchaseUpdatedSub = nil + return ("purchaseUpdated", sub) case .nonDeduping: if let index = purchaseUpdatedDuplicateListeners.lastIndex(where: { $0.token == token }) { purchaseUpdatedDuplicateListeners.remove(at: index) } + guard purchaseUpdatedDuplicateListeners.isEmpty, let sub = purchaseUpdatedDuplicateSub else { + return nil + } + purchaseUpdatedDuplicateSub = nil + return ("purchaseUpdatedDuplicate", sub) } } From d5b2cf05837a268330490afb71359a9f47c4a4b5 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 18:18:59 +0900 Subject: [PATCH 13/16] fix(listeners): reset flutter replay options --- libraries/expo-iap/src/index.ts | 20 ++++- .../lib/flutter_inapp_purchase.dart | 77 +++++++++++++++--- .../flutter_inapp_purchase_channel_test.dart | 79 +++++++++++++++++++ 3 files changed, 164 insertions(+), 12 deletions(-) diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 65da8347..cd044e26 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -137,12 +137,14 @@ const purchaseUpdatedDedupeHistoryLimitIOS = 512; const purchaseUpdatedDedupeHistoryIOS = { ids: new Set(), order: [] as string[], + start: 0, }; let purchaseUpdatedDedupeGenerationIOS = 0; type PurchaseUpdatedDedupeHistoryIOS = { ids: Set; order: string[]; + start: number; }; const purchaseUpdatedTransactionIdIOS = (purchase: Purchase) => { @@ -162,11 +164,19 @@ const rememberPurchaseUpdatedTransactionIOS = ( history.ids.add(transactionId); history.order.push(transactionId); - if (history.order.length > purchaseUpdatedDedupeHistoryLimitIOS) { - const evicted = history.order.shift(); + if ( + history.order.length - history.start > + purchaseUpdatedDedupeHistoryLimitIOS + ) { + const evicted = history.order[history.start]; + history.start += 1; if (evicted != null) { history.ids.delete(evicted); } + if (history.start > 128 && history.start * 2 > history.order.length) { + history.order = history.order.slice(history.start); + history.start = 0; + } } }; @@ -175,6 +185,7 @@ const clearPurchaseUpdatedDedupeHistoryIOS = ( ) => { history.ids.clear(); history.order.length = 0; + history.start = 0; }; const resetPurchaseUpdatedDedupeHistoryIOS = () => { @@ -257,7 +268,10 @@ export const purchaseUpdatedListener = ( Platform.OS === 'ios' && options?.dedupeTransactionIOS === false; const listenerDedupeHistoryIOS: PurchaseUpdatedDedupeHistoryIOS = { ids: new Set(purchaseUpdatedDedupeHistoryIOS.ids), - order: [...purchaseUpdatedDedupeHistoryIOS.order], + order: purchaseUpdatedDedupeHistoryIOS.order.slice( + purchaseUpdatedDedupeHistoryIOS.start, + ), + start: 0, }; let listenerDedupeGenerationIOS = purchaseUpdatedDedupeGenerationIOS; diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 0f274c59..dbb8effa 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -133,6 +133,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { StreamController.broadcast(); final _purchaseUpdatedDedupeHistoryIOS = _PurchaseUpdatedDedupeHistoryIOS(); int _purchaseUpdatedDedupeGenerationIOS = 0; + int _nonDedupingPurchaseUpdatedListenerCountIOS = 0; /// Purchase updated event stream Stream get purchaseUpdatedListener => isIOS @@ -154,25 +155,42 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { return purchaseUpdatedListener; } + final dedupeTransactionIOS = options?.dedupeTransactionIOS != false; StreamSubscription? subscription; late StreamController controller; + var retainedNonDedupingListener = false; controller = StreamController.broadcast( - onListen: () { - _setPurchaseUpdatedListenerOptions(options).then((_) { + onListen: () async { + try { + if (dedupeTransactionIOS) { + await _setDedupingPurchaseUpdatedListenerOptionsIOS(options); + } else { + retainedNonDedupingListener = true; + await _retainNonDedupingPurchaseUpdatedListenerIOS(); + } if (!controller.hasListener) return; subscription = _purchaseUpdatedListenerStreamIOS( - dedupeTransactionIOS: options?.dedupeTransactionIOS != false, + dedupeTransactionIOS: dedupeTransactionIOS, ).listen( controller.add, onError: controller.addError, onDone: controller.close, ); - }, onError: controller.addError); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } }, onCancel: () async { final activeSubscription = subscription; subscription = null; - await activeSubscription?.cancel(); + try { + await activeSubscription?.cancel(); + } finally { + if (retainedNonDedupingListener) { + retainedNonDedupingListener = false; + await _releaseNonDedupingPurchaseUpdatedListenerIOS(); + } + } }, ); return controller.stream; @@ -235,6 +253,36 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); }); + Future _setDedupingPurchaseUpdatedListenerOptionsIOS( + gentype.PurchaseUpdatedListenerOptions? options, + ) async { + if (_nonDedupingPurchaseUpdatedListenerCountIOS > 0) return; + await _setPurchaseUpdatedListenerOptions(options); + } + + Future _retainNonDedupingPurchaseUpdatedListenerIOS() async { + _nonDedupingPurchaseUpdatedListenerCountIOS += 1; + if (_nonDedupingPurchaseUpdatedListenerCountIOS == 1) { + await _setPurchaseUpdatedListenerOptions( + const gentype.PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: false, + ), + ); + } + } + + Future _releaseNonDedupingPurchaseUpdatedListenerIOS() async { + if (_nonDedupingPurchaseUpdatedListenerCountIOS == 0) return; + _nonDedupingPurchaseUpdatedListenerCountIOS -= 1; + if (_nonDedupingPurchaseUpdatedListenerCountIOS == 0) { + await _setPurchaseUpdatedListenerOptions( + const gentype.PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: true, + ), + ); + } + } + /// Purchase error event stream Stream get purchaseErrorListener => _purchaseErrorListener.stream; @@ -2780,10 +2828,21 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { dedupeTransactionIOS: dedupeTransactionIOS, ); if (isIOS) { - await _setPurchaseUpdatedListenerOptions(options); - return _purchaseUpdatedListenerStreamIOS( - dedupeTransactionIOS: dedupeTransactionIOS != false, - ).first; + final shouldDedupe = dedupeTransactionIOS != false; + if (shouldDedupe) { + await _setDedupingPurchaseUpdatedListenerOptionsIOS(options); + } else { + await _retainNonDedupingPurchaseUpdatedListenerIOS(); + } + try { + return await _purchaseUpdatedListenerStreamIOS( + dedupeTransactionIOS: shouldDedupe, + ).first; + } finally { + if (!shouldDedupe) { + await _releaseNonDedupingPurchaseUpdatedListenerIOS(); + } + } } return purchaseUpdatedListener.first; }, diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index 860bf857..075e915f 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -1227,8 +1227,10 @@ void main() { test( 'default purchase listener filters replays while non-deduping listener receives them', () async { + final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); if (call.method == 'initConnection') { return true; } @@ -1283,14 +1285,84 @@ void main() { await defaultSub.cancel(); await nonDedupingSub.cancel(); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final optionCalls = calls + .where((call) => call.method == 'setPurchaseUpdatedListenerOptions') + .toList(); + expect(optionCalls.map((call) => call.arguments), [ + {'dedupeTransactionIOS': false}, + {'dedupeTransactionIOS': true}, + ]); + }, + ); + + test( + 'resets native dedupe option after last non-deduping iOS listener cancels', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); + if (call.method == 'initConnection') { + return true; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + + await iap.initConnection(); + final firstSub = iap + .purchaseUpdatedListenerWithOptions( + const types.PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: false, + ), + ) + .listen((_) {}); + final secondSub = iap + .purchaseUpdatedListenerWithOptions( + const types.PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: false, + ), + ) + .listen((_) {}); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + await firstSub.cancel(); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + var optionCalls = calls + .where((call) => call.method == 'setPurchaseUpdatedListenerOptions') + .toList(); + expect(optionCalls.map((call) => call.arguments), [ + {'dedupeTransactionIOS': false}, + ]); + + await secondSub.cancel(); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + optionCalls = calls + .where((call) => call.method == 'setPurchaseUpdatedListenerOptions') + .toList(); + expect(optionCalls.map((call) => call.arguments), [ + {'dedupeTransactionIOS': false}, + {'dedupeTransactionIOS': true}, + ]); }, ); test( 'subscriptionHandlers.purchaseUpdated honors non-deduping iOS option', () async { + final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); if (call.method == 'initConnection') { return true; } @@ -1341,6 +1413,13 @@ void main() { final purchase = await future.timeout(const Duration(seconds: 1)); expect(purchase.id, 'txn-handler-dedupe-replay'); + final optionCalls = calls + .where((call) => call.method == 'setPurchaseUpdatedListenerOptions') + .toList(); + expect(optionCalls.map((call) => call.arguments), [ + {'dedupeTransactionIOS': false}, + {'dedupeTransactionIOS': true}, + ]); await defaultSub.cancel(); }, From 4403692a3ecd34775b11ffaa0b183655172f84f0 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 18:32:20 +0900 Subject: [PATCH 14/16] docs(apple): clarify auto finish replay handling --- packages/apple/Sources/OpenIapModule.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 9217a8ac..66fc792f 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -380,6 +380,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { id: transactionId, pendingTransaction: shouldAutoFinish ? nil : transaction ) + // Dedupe only controls listener delivery; auto-finish must still + // complete the StoreKit transaction lifecycle for replayed updates. if shouldAutoFinish { await transaction.finish() await state.removePending(id: transactionId) From 0cbb5985ce36bc34780834958bce5e549cf5cccb Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 20:13:41 +0900 Subject: [PATCH 15/16] refactor(rn): clarify nitro option decoding --- .../react-native-iap/ios/HybridRnIap.swift | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index 5c30696c..6822c5fa 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -956,12 +956,7 @@ class HybridRnIap: HybridRnIapSpec { listener: @escaping (NitroPurchase) -> Void, options: NitroPurchaseUpdatedListenerOptions? ) throws -> Double { - let dedupeTransactionIOS: Bool = { - if case .second(let enabled) = options?.dedupeTransactionIOS { - return enabled - } - return true - }() + let dedupeTransactionIOS = purchaseUpdatedDedupeTransactionIOS(from: options) let receiveDuplicateTransactionUpdatesIOS = !dedupeTransactionIOS let token = listenerLock.withLock { let token = nextPurchaseUpdatedListenerToken @@ -1002,6 +997,20 @@ class HybridRnIap: HybridRnIapSpec { listenerLock.withLock { purchaseErrorListeners.append(listener) } } + private func purchaseUpdatedDedupeTransactionIOS( + from options: NitroPurchaseUpdatedListenerOptions? + ) -> Bool { + guard let dedupeTransactionIOS = options?.dedupeTransactionIOS else { + return true + } + switch dedupeTransactionIOS { + case .second(let enabled): + return enabled + case .first: + return true + } + } + private func removePurchaseUpdatedListenerRegistration(token: Double) -> (label: String, subscription: Subscription)? { guard let registrationIndex = purchaseUpdatedListenerRegistrations.lastIndex(where: { $0.token == token From 27586b81e3550adc788102590b27cd1df7f4dac7 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 13 May 2026 20:26:04 +0900 Subject: [PATCH 16/16] fix(expo): reapply replay option after reconnect --- .../expo-iap/src/__tests__/index.test.ts | 25 +++++++++++++++++++ libraries/expo-iap/src/index.ts | 13 ++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index f5a9951d..a812f6e8 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -173,6 +173,31 @@ describe('Public API (index.ts)', () => { expect(listener).toHaveBeenCalledTimes(2); }); + it('reapplies non-deduping purchase updated option after reconnect', async () => { + (Platform as any).OS = 'ios'; + (ExpoIapModule.initConnection as jest.Mock).mockResolvedValue(true); + (ExpoIapModule.endConnection as jest.Mock).mockResolvedValue(true); + const setOptions = (ExpoIapModule as any) + .setPurchaseUpdatedListenerOptions as jest.Mock; + + const subscription = purchaseUpdatedListener(jest.fn(), { + dedupeTransactionIOS: false, + }); + expect(setOptions).toHaveBeenLastCalledWith({ + dedupeTransactionIOS: false, + }); + + setOptions.mockClear(); + await endConnection(); + await initConnection(); + + expect(setOptions).toHaveBeenCalledTimes(1); + expect(setOptions).toHaveBeenCalledWith({ + dedupeTransactionIOS: false, + }); + subscription.remove(); + }); + it('removes non-deduping purchase updated listeners idempotently', () => { const setOptions = (ExpoIapModule as any) .setPurchaseUpdatedListenerOptions as jest.Mock; diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index cd044e26..89b5b7d7 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -566,8 +566,17 @@ export const subscriptionBillingIssueListener = ( * * @see {@link https://www.openiap.dev/docs/apis/init-connection} */ -export const initConnection: MutationField<'initConnection'> = async (config) => - ExpoIapModule.initConnection(config ?? null); +export const initConnection: MutationField<'initConnection'> = async (config) => { + const result = await ExpoIapModule.initConnection(config ?? null); + if ( + result === true && + Platform.OS === 'ios' && + nonDedupingPurchaseUpdatedListenerCountIOS > 0 + ) { + configurePurchaseUpdatedListenerOptionsIOS(false); + } + return result; +}; /** * Close the store connection and release resources.