diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift index 4d077821..ea17e6ec 100644 --- a/libraries/expo-iap/ios/ExpoIapHelper.swift +++ b/libraries/expo-iap/ios/ExpoIapHelper.swift @@ -21,7 +21,11 @@ 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)? + private static var purchaseUpdatedOptions = PurchaseUpdatedListenerOptions() static func sanitizeDictionary(_ dictionary: [String: Any?]) -> [String: Any] { var result: [String: Any] = [:] @@ -132,14 +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() - let purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { purchase in - Task { @MainActor in - purchaseUpdated(purchase) - } - } + purchaseUpdatedHandler = purchaseUpdated + attachPurchaseUpdatedListenerLocked() let purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { error in Task { @MainActor in @@ -159,13 +163,54 @@ enum ExpoIapHelper { } } - listeners = [purchaseUpdatedSub, purchaseErrorSub, promotedProductSub, billingIssueSub] + listeners = [ + purchaseErrorSub, + promotedProductSub, + billingIssueSub, + ] + } + + 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 + } + attachPurchaseUpdatedListenerLocked() + } + + private static func attachPurchaseUpdatedListenerLocked() { + 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 + listenerLock.lock() + defer { listenerLock.unlock() } + + cleanupListenersLocked() + } + + private static func cleanupListenersLocked() { + 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) { diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index c220f614..1cbc807e 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -51,6 +51,14 @@ public final class ExpoIapModule: Module { return succeeded } + AsyncFunction("setPurchaseUpdatedListenerOptions") { (options: [String: Any]?) async throws -> Void in + let listenerOptions = PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: + options?["dedupeTransactionIOS"] 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..8fb9c953 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(), @@ -40,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 3b822f45..a812f6e8 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,156 @@ 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', () => { + const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const setOptions = (ExpoIapModule as any) + .setPurchaseUpdatedListenerOptions as jest.Mock; + const fn = jest.fn(); + const subscription = purchaseUpdatedListener(fn, { + dedupeTransactionIOS: false, + }); + expect(setOptions).toHaveBeenCalledWith({ + dedupeTransactionIOS: false, + }); + expect(addListener).toHaveBeenCalledWith( + OpenIapEvent.PurchaseUpdated, + expect.any(Function), + ); + 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(); + const nonDedupingListener = jest.fn(); + + purchaseUpdatedListener(defaultListener); + const nonDedupingSubscription = 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); + nonDedupingSubscription.remove(); + }); + + 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('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; + + 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', () => { diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index bf37bf84..89b5b7d7 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, @@ -94,19 +95,121 @@ 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. // 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); }, }; +let nonDedupingPurchaseUpdatedListenerCountIOS = 0; +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) => { + 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 - 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; + } + } +}; + +const clearPurchaseUpdatedDedupeHistoryIOS = ( + history: PurchaseUpdatedDedupeHistoryIOS, +) => { + history.ids.clear(); + history.order.length = 0; + history.start = 0; +}; + +const resetPurchaseUpdatedDedupeHistoryIOS = () => { + clearPurchaseUpdatedDedupeHistoryIOS(purchaseUpdatedDedupeHistoryIOS); + purchaseUpdatedDedupeGenerationIOS += 1; +}; + +const configurePurchaseUpdatedListenerOptionsIOS = ( + dedupeTransactionIOS: boolean, +) => { + if (Platform.OS !== 'ios') return; + + const nativeModule = getNativeModule() as NativePurchaseUpdatedOptionsModule; + const promise = nativeModule.setPurchaseUpdatedListenerOptions?.({ + dedupeTransactionIOS, + }); + 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'. */ @@ -159,16 +262,77 @@ const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] => export const purchaseUpdatedListener = ( listener: (event: Purchase) => void, + options?: PurchaseUpdatedListenerOptions | null, ) => { + const receiveDuplicateTransactionUpdatesIOS = + Platform.OS === 'ios' && options?.dedupeTransactionIOS === false; + const listenerDedupeHistoryIOS: PurchaseUpdatedDedupeHistoryIOS = { + ids: new Set(purchaseUpdatedDedupeHistoryIOS.ids), + order: purchaseUpdatedDedupeHistoryIOS.order.slice( + purchaseUpdatedDedupeHistoryIOS.start, + ), + start: 0, + }; + 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( OpenIapEvent.PurchaseUpdated, wrappedListener, ); - return emitterSubscription; + + if (!receiveDuplicateTransactionUpdatesIOS) { + return emitterSubscription; + } + + nonDedupingPurchaseUpdatedListenerCountIOS += 1; + configurePurchaseUpdatedListenerOptionsIOS(false); + let removed = false; + + return { + remove: () => { + if (removed) { + return; + } + removed = true; + try { + emitterSubscription?.remove?.(); + } finally { + nonDedupingPurchaseUpdatedListenerCountIOS = Math.max( + 0, + nonDedupingPurchaseUpdatedListenerCountIOS - 1, + ); + configurePurchaseUpdatedListenerOptionsIOS( + nonDedupingPurchaseUpdatedListenerCountIOS === 0, + ); + } + }, + }; }; export const purchaseErrorListener = ( @@ -402,16 +566,30 @@ 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. * * @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. @@ -1099,10 +1277,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..fc6fef35 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1292,6 +1292,15 @@ export interface PurchaseOptions { export type PurchaseState = 'pending' | 'purchased' | 'unknown'; +export interface PurchaseUpdatedListenerOptions { + /** + * 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. + */ + dedupeTransactionIOS?: (boolean | null); +} + export type PurchaseVerificationProvider = 'iapkit'; export interface Query { @@ -1734,7 +1743,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 +1772,9 @@ export interface Subscription { } + +export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined; + export interface SubscriptionInfoIOS { introductoryOffer?: (SubscriptionOfferIOS | null); promotionalOffers?: (SubscriptionOfferIOS[] | null); @@ -2218,7 +2235,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..e4416022 100644 --- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift @@ -17,6 +17,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { 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 = [] @@ -65,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' } @@ -399,61 +403,78 @@ 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 - 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) - } - } - } - - 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( + dedupeTransactionIOS: + args?["dedupeTransactionIOS"] 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 = purchaseErrorToken { OpenIapModule.shared.removeListener(token) } @@ -464,6 +485,16 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { 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..dbb8effa 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,157 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { gentype.DeveloperProvidedBillingDetailsAndroid>.broadcast(); final StreamController _subscriptionBillingIssueListener = StreamController.broadcast(); + final _purchaseUpdatedDedupeHistoryIOS = _PurchaseUpdatedDedupeHistoryIOS(); + int _purchaseUpdatedDedupeGenerationIOS = 0; + int _nonDedupingPurchaseUpdatedListenerCountIOS = 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. + /// + /// 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; default streams still filter replayed IDs unless they + /// opt out with `dedupeTransactionIOS: false`. + Stream purchaseUpdatedListenerWithOptions( + gentype.PurchaseUpdatedListenerOptions? options, + ) { + if (!isIOS) { + return purchaseUpdatedListener; + } + + final dedupeTransactionIOS = options?.dedupeTransactionIOS != false; + StreamSubscription? subscription; + late StreamController controller; + var retainedNonDedupingListener = false; + controller = StreamController.broadcast( + onListen: () async { + try { + if (dedupeTransactionIOS) { + await _setDedupingPurchaseUpdatedListenerOptionsIOS(options); + } else { + retainedNonDedupingListener = true; + await _retainNonDedupingPurchaseUpdatedListenerIOS(); + } + if (!controller.hasListener) return; + subscription = _purchaseUpdatedListenerStreamIOS( + dedupeTransactionIOS: dedupeTransactionIOS, + ).listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } + }, + onCancel: () async { + final activeSubscription = subscription; + subscription = null; + try { + await activeSubscription?.cancel(); + } finally { + if (retainedNonDedupingListener) { + retainedNonDedupingListener = false; + await _releaseNonDedupingPurchaseUpdatedListenerIOS(); + } + } + }, + ); + 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, + ) => + _configurePurchaseListener(() async { + await _setPurchaseListener(); + await _channel.invokeMethod( + 'setPurchaseUpdatedListenerOptions', + options?.toJson(), + ); + }); + + 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 => @@ -128,6 +307,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(); @@ -138,28 +327,11 @@ 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-error': debugPrint( @@ -246,6 +418,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: @@ -271,7 +472,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } try { - await _setPurchaseListener(); + await _configurePurchaseListener(_setPurchaseListener); // Build config map for alternative billing and billing program Map? config; @@ -317,6 +518,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { await _channel.invokeMethod('endConnection'); _isInitialized = false; + if (isIOS) { + _resetPurchaseUpdatedDedupeHistoryIOS(); + } return true; } on PlatformException catch (error) { throw _purchaseErrorFromPlatformException( @@ -2619,7 +2823,29 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { }, purchaseError: () async => await purchaseErrorListener.first as gentype.PurchaseError, - purchaseUpdated: () async => await purchaseUpdatedListener.first, + purchaseUpdated: ({bool? dedupeTransactionIOS}) async { + final options = gentype.PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: dedupeTransactionIOS, + ); + if (isIOS) { + 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; + }, 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..2558620f 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -4347,6 +4347,29 @@ class PurchaseOptions { } } +class PurchaseUpdatedListenerOptions { + const PurchaseUpdatedListenerOptions({ + this.dedupeTransactionIOS, + }); + + /// 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( + dedupeTransactionIOS: json['dedupeTransactionIOS'] as bool?, + ); + } + + Map toJson() { + return { + 'dedupeTransactionIOS': dedupeTransactionIOS, + }; + } +} + class RequestPurchaseAndroidProps { const RequestPurchaseAndroidProps({ this.developerBillingOption, @@ -5452,7 +5475,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? dedupeTransactionIOS, + }); /// 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 +5700,9 @@ class QueryHandlers { typedef SubscriptionDeveloperProvidedBillingAndroidHandler = Future Function(); typedef SubscriptionPromotedProductIOSHandler = Future Function(); typedef SubscriptionPurchaseErrorHandler = Future Function(); -typedef SubscriptionPurchaseUpdatedHandler = Future Function(); +typedef SubscriptionPurchaseUpdatedHandler = Future Function({ + bool? dedupeTransactionIOS, +}); typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); 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..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 @@ -1224,6 +1224,207 @@ void main() { expect(listenerPurchase.productId, 'iap.premium'); }); + 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; + } + 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(); + 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; + } + 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'); + final optionCalls = calls + .where((call) => call.method == 'setPurchaseUpdatedListenerOptions') + .toList(); + expect(optionCalls.map((call) => call.arguments), [ + {'dedupeTransactionIOS': false}, + {'dedupeTransactionIOS': true}, + ]); + + await defaultSub.cancel(); + }, + ); + test('purchase-error emits results to both error streams', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index db5145a9..75dc46ce 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]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() + 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..13bb03b9 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. 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("dedupeTransactionIOS") and data["dedupeTransactionIOS"] != null: + obj.dedupe_transaction_ios = data["dedupeTransactionIOS"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if dedupe_transaction_ios != null: + dict["dedupeTransactionIOS"] = dedupe_transaction_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..01c15f3a 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 dedupeTransactionIOS = true // MARK: - Initialization required init(_ context: InitContext) { @@ -113,6 +114,29 @@ 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) + 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 { + openIap.removeListener(sub) + purchaseUpdateSubscription = nil + } + setupPurchaseUpdatedListener() + } + + return true + } + @Callable public func endConnection() -> Bool { GodotIapLog.payload("endConnection", payload: nil) @@ -1301,11 +1325,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 +1349,17 @@ public class GodotIap: RefCounted, @unchecked Sendable { } } + private func setupPurchaseUpdatedListener() { + let options = PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: dedupeTransactionIOS + ) + 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..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 @@ -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,16 @@ interface KmpInAppPurchase : MutationResolver, QueryResolver, SubscriptionResolv */ val purchaseUpdatedListener: Flow + /** + * Listener for observing purchase updates with subscription options. + * + * 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 + /** * 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..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 @@ -4415,6 +4415,27 @@ public data class PurchaseOptions( ) } +public data class PurchaseUpdatedListenerOptions( + /** + * 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 dedupeTransactionIOS: Boolean? = null +) { + companion object { + fun fromJson(json: Map): PurchaseUpdatedListenerOptions { + return PurchaseUpdatedListenerOptions( + dedupeTransactionIOS = json["dedupeTransactionIOS"] as? Boolean, + ) + } + } + + fun toJson(): Map = mapOf( + "dedupeTransactionIOS" to dedupeTransactionIOS, + ) +} + public data class RequestPurchaseAndroidProps( /** * Developer billing option parameters for external payments flow (8.3.0+). @@ -5562,8 +5583,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 +5722,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..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 @@ -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,6 +31,22 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { ) override val purchaseUpdatedListener: Flow = _purchaseUpdatedFlow.asSharedFlow() + override fun purchaseUpdatedListener(options: PurchaseUpdatedListenerOptions?): Flow { + if (options?.dedupeTransactionIOS != false) { + return purchaseUpdatedListener + } + + return callbackFlow { + val subscription = openIapModule.addPurchaseUpdatedListener( + { dictionary -> + convertAnyToPurchase(dictionary)?.let { trySend(it) } + }, + false + ) + awaitClose { openIapModule.removeListener(subscription) } + } + } + private val _purchaseErrorFlow = MutableSharedFlow( replay = 0, extraBufferCapacity = 64, @@ -75,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 { @@ -86,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 @@ -102,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) } @@ -110,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 { @@ -465,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())) } } @@ -636,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() @@ -665,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 } } @@ -883,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() @@ -1011,9 +1010,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") @@ -1089,7 +1087,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { null } } catch (e: Exception) { - println("[KMP-IAP] Error converting to Purchase: ${e.message}") null } } @@ -1102,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() } } @@ -1117,7 +1113,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { convertAnyToPurchaseIOS(item) } } catch (e: Exception) { - println("[KMP-IAP] Error converting to PurchaseIOS list: ${e.message}") emptyList() } } @@ -1125,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( @@ -1180,8 +1165,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { ) } } catch (e: Exception) { - println("[KMP-IAP] Error converting to Product list: ${e.message}") - e.printStackTrace() emptyList() } } @@ -1229,7 +1212,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { ) } } catch (e: Exception) { - println("[KMP-IAP] Error converting to ProductSubscription list: ${e.message}") emptyList() } } @@ -1263,7 +1245,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } ?: ProductTypeIOS.Consumable ) } catch (e: Exception) { - println("[KMP-IAP] Error converting to ProductIOS: ${e.message}") null } } @@ -1506,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 } } @@ -1522,8 +1502,7 @@ 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() } } -} \ 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..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,6 +217,11 @@ void VerifyPurchaseWithProvider( [Export("addPurchaseUpdatedListener:")] NSObject AddPurchaseUpdatedListener(Action callback); + [Export("addPurchaseUpdatedListener:dedupeTransactionIOS:")] + NSObject AddPurchaseUpdatedListener( + Action callback, + 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 51237849..b2cbde0b 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, + /// 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); + /// /// 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..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() @@ -116,12 +117,62 @@ public OpenIapIOS() } public IObservable PurchaseUpdated => _purchaseUpdated; + public IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null) => + options?.DedupeTransactionIOS == false + ? CreatePurchaseUpdatedObservable(dedupeTransactionIOS: false) + : _purchaseUpdated; public IObservable PurchaseError => _purchaseError; public IObservable PromotedProductIOS => _promotedProductIOS; public IObservable SubscriptionBillingIssue => _subscriptionBillingIssue; public IObservable UserChoiceBillingAndroid => EmptyObservable.Instance; public IObservable DeveloperProvidedBillingAndroid => EmptyObservable.Instance; + private IObservable CreatePurchaseUpdatedObservable(bool dedupeTransactionIOS) + { + 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, + dedupeTransactionIOS); + _dynamicPurchaseUpdatedTokens.Add(token); + } + + return new DisposableAction(() => + { + bool shouldRemove; + lock (_listenerLock) + { + shouldRemove = _dynamicPurchaseUpdatedTokens.Remove(token); + } + if (shouldRemove) + { + RemoveListener(token, nameof(PurchaseUpdatedWithOptions)); + } + GC.KeepAlive(callback); + }); + }); + } + private void WireListeners() { lock (_listenerLock) @@ -146,6 +197,7 @@ public void Dispose() NSObject? purchaseErrorToken; NSObject? promotedProductToken; NSObject? billingIssueToken; + List dynamicPurchaseUpdatedTokens; lock (_listenerLock) { @@ -156,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/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/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index 91e82bf4..e89111dd 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -3762,6 +3762,15 @@ public sealed record PurchaseOptions public bool? IncludeSuspendedAndroid { get; init; } } +public sealed record PurchaseUpdatedListenerOptions +{ + /// 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 { /// List of product SKUs @@ -4347,7 +4356,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..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() } @@ -783,9 +791,15 @@ class HybridRnIap : HybridRnIapSpec() { get() = 0L // Event listener methods - override fun addPurchaseUpdatedListener(listener: (purchase: NitroPurchase) -> Unit) { - synchronized(purchaseUpdatedListeners) { - purchaseUpdatedListeners.add(listener) + override fun addPurchaseUpdatedListener( + listener: (purchase: NitroPurchase) -> Unit, + options: NitroPurchaseUpdatedListenerOptions? + ): Double { + return synchronized(purchaseUpdatedListeners) { + val token = nextPurchaseUpdatedListenerToken + nextPurchaseUpdatedListenerToken += 1.0 + purchaseUpdatedListeners.add(PurchaseUpdatedListenerRegistration(token, listener)) + token } } @@ -795,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 } } } @@ -832,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 1aac6e47..6822c5fa 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -4,6 +4,16 @@ 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 + } + + private struct PurchaseUpdatedListenerRegistration { + let token: Double + let bucket: PurchaseUpdatedListenerBucket + } + // MARK: - Properties private var updateListenerTask: Task? private var isInitialized: Bool = false @@ -11,20 +21,20 @@ 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 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] = [] private var subscriptionBillingIssueListeners: [(NitroPurchase) -> Void] = [] 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() @@ -942,16 +952,92 @@ 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 -> Double { + let dedupeTransactionIOS = purchaseUpdatedDedupeTransactionIOS(from: options) + let receiveDuplicateTransactionUpdatesIOS = !dedupeTransactionIOS + let token = listenerLock.withLock { + let token = nextPurchaseUpdatedListenerToken + nextPurchaseUpdatedListenerToken += 1 + + let registration = PurchaseUpdatedListenerRegistration( + token: token, + bucket: receiveDuplicateTransactionUpdatesIOS ? .nonDeduping : .deduping + ) + if receiveDuplicateTransactionUpdatesIOS { + purchaseUpdatedDuplicateListeners.append((token: token, listener: listener)) + } else { + purchaseUpdatedListeners.append((token: token, listener: listener)) + } + purchaseUpdatedListenerRegistrations.append(registration) + return token + } + + if receiveDuplicateTransactionUpdatesIOS { + attachDuplicatePurchaseUpdatedSubIfNeeded() + } else { + attachPurchaseUpdatedSubIfNeeded() + } + return token + } + + func removePurchaseUpdatedListener(token: Double) throws { + 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) } } - func removePurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws { - listenerLock.withLock { purchaseUpdatedListeners.removeAll() } + 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 + }) else { + return nil + } + let registration = purchaseUpdatedListenerRegistrations.remove(at: registrationIndex) + switch registration.bucket { + case .deduping: + 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) + } } func removePurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws { @@ -970,26 +1056,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 +1115,61 @@ class HybridRnIap: HybridRnIapSpec { } } + private func attachPurchaseUpdatedSubIfNeeded() { + listenerLock.withLock { + guard purchaseUpdatedSub == nil else { + return + } + 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() { + listenerLock.withLock { + guard purchaseUpdatedDuplicateSub == nil else { + return + } + RnIapLog.payload("purchaseUpdatedListener.register.duplicates", nil) + let options = PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: false + ) + purchaseUpdatedDuplicateSub = OpenIapModule.shared.purchaseUpdatedListener({ [weak self] openIapPurchase in + guard let self else { + RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, non-deduping 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,48 +1197,13 @@ class HybridRnIap: HybridRnIapSpec { } } - private func sendPurchaseUpdate(_ purchase: NitroPurchase) { - 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 + private func sendPurchaseUpdate(_ purchase: NitroPurchase, includeDuplicateListeners: Bool) { let snapshot: [(NitroPurchase) -> Void] = listenerLock.withLock { - 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) + if includeDuplicateListeners { + return purchaseUpdatedDuplicateListeners.map(\.listener) } - 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 + return purchaseUpdatedListeners.map(\.listener) } for listener in snapshot { @@ -1178,6 +1265,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) @@ -1191,6 +1282,7 @@ class HybridRnIap: HybridRnIapSpec { OpenIapModule.shared.removeListener(sub) } purchaseUpdatedSub = nil + purchaseUpdatedDuplicateSub = nil purchaseErrorSub = nil promotedProductSub = nil subscriptionBillingIssueSub = nil @@ -1203,13 +1295,14 @@ class HybridRnIap: HybridRnIapSpec { // Clear event listeners, error dedup state, and delivery state (thread-safe) listenerLock.withLock { purchaseUpdatedListeners.removeAll() + purchaseUpdatedDuplicateListeners.removeAll() + purchaseUpdatedListenerRegistrations.removeAll() + nextPurchaseUpdatedListenerToken = 1 purchaseErrorListeners.removeAll() promotedProductListeners.removeAll() 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/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 8771602c..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(); @@ -162,6 +166,68 @@ describe('Public API (src/index.ts)', () => { expect(listener).not.toHaveBeenCalled(); }); + it('routes non-deduping purchaseUpdatedListener through opt-in native listener', () => { + const defaultListener = jest.fn(); + const duplicateListener = jest.fn(); + IAP.purchaseUpdatedListener(defaultListener); + IAP.purchaseUpdatedListener(duplicateListener, { + dedupeTransactionIOS: false, + }); + + expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2); + expect(mockIap.addPurchaseUpdatedListener.mock.calls[1][1]).toEqual({ + dedupeTransactionIOS: false, + }); + + 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('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/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index cf47efd5..227b7c18 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 + * `dedupeTransactionIOS` to false 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..0b2d5850 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,20 @@ 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; +let purchaseUpdateNativeToken: number | null = null; +let purchaseUpdateDuplicateNativeToken: number | null = null; +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 +261,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 +321,9 @@ const promotedProductNativeHandler: NitroPromotedProductListener = ( */ export const resetListenerState = (): void => { purchaseUpdateNativeAttached = false; + purchaseUpdateDuplicateNativeAttached = false; + purchaseUpdateNativeToken = null; + purchaseUpdateDuplicateNativeToken = null; purchaseErrorNativeAttached = false; promotedProductNativeAttached = false; userChoiceBillingNativeAttached = false; @@ -306,6 +331,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,12 +341,22 @@ export const resetListenerState = (): void => { export const purchaseUpdatedListener = ( listener: (purchase: Purchase) => void, + options?: PurchaseUpdatedListenerOptions | null, ): EventSubscription => { - purchaseUpdateJsListeners.add(listener); + const receiveDuplicateTransactionUpdatesIOS = + Platform.OS === 'ios' && options?.dedupeTransactionIOS === false; + const listeners = receiveDuplicateTransactionUpdatesIOS + ? purchaseUpdateDuplicateJsListeners + : purchaseUpdateJsListeners; - if (!purchaseUpdateNativeAttached) { + listeners.add(listener); + + 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); @@ -334,9 +370,65 @@ export const purchaseUpdatedListener = ( } } + if ( + !purchaseUpdateDuplicateNativeAttached && + receiveDuplicateTransactionUpdatesIOS + ) { + try { + const nativeOptions: NitroPurchaseUpdatedListenerOptions & + NitroPurchaseUpdatedListenerOptionsParam = { + dedupeTransactionIOS: false, + }; + const token = IAP.instance.addPurchaseUpdatedListener( + purchaseUpdateDuplicateNativeHandler, + nativeOptions, + ); + purchaseUpdateDuplicateNativeToken = + typeof token === 'number' ? token : null; + 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; + } + } + } + + let removed = false; return { remove: () => { - purchaseUpdateJsListeners.delete(listener); + 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); + } }, }; }; @@ -619,7 +711,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..65f86e25 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,9 @@ export interface NitroReceiptValidationHorizonOptions { userId: VerifyPurchaseHorizonOptions['userId']; } +export interface NitroPurchaseUpdatedListenerOptions + extends PurchaseUpdatedListenerOptions {} + export interface NitroReceiptValidationParams { apple?: NitroReceiptValidationAppleOptions | null; google?: NitroReceiptValidationGoogleOptions | null; @@ -725,7 +729,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, + ): number; /** * Add a listener for purchase errors @@ -737,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 diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 7eb00860..fc6fef35 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1292,6 +1292,15 @@ export interface PurchaseOptions { export type PurchaseState = 'pending' | 'purchased' | 'unknown'; +export interface PurchaseUpdatedListenerOptions { + /** + * 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. + */ + dedupeTransactionIOS?: (boolean | null); +} + export type PurchaseVerificationProvider = 'iapkit'; export interface Query { @@ -1734,7 +1743,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 +1772,9 @@ export interface Subscription { } + +export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined; + export interface SubscriptionInfoIOS { introductoryOffer?: (SubscriptionOfferIOS | null); promotionalOffers?: (SubscriptionOfferIOS[] | null); @@ -2218,7 +2235,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 310cac01..ed32d9d0 100644 --- a/packages/apple/Sources/Helpers/IapState.swift +++ b/packages/apple/Sources/Helpers/IapState.swift @@ -4,15 +4,59 @@ 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 ring: [String?] + private var nextEvictionIndex = 0 + + init(limit: Int) { + self.limit = max(1, limit) + self.ring = Array(repeating: nil, count: self.limit) + } + + mutating func record(_ id: String) -> Bool { + guard ids.insert(id).inserted else { + return false + } + + if let evicted = ring[nextEvictionIndex] { + ids.remove(evicted) + } + ring[nextEvictionIndex] = id + nextEvictionIndex = (nextEvictionIndex + 1) % limit + return true + } + + mutating func removeAll() { + ids.removeAll() + ring = Array(repeating: nil, count: limit) + nextEvictionIndex = 0 + } +} + +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +private struct PurchaseUpdatedListenerRegistration { + let id: UUID + let listener: PurchaseUpdatedListener + let dedupeTransactionIOS: 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: 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)] = [] @@ -21,6 +65,7 @@ actor IapState { func setInitialized(_ value: Bool) { isInitialized = value } func reset() { pendingTransactions.removeAll() + purchaseUpdateEmissionHistory.removeAll() isInitialized = false promotedProductId = nil pendingPromotedProductReplayId = nil @@ -32,6 +77,18 @@ actor IapState { func removePending(id: String) { pendingTransactions.removeValue(forKey: id) } func pendingSnapshot() -> [Transaction] { Array(pendingTransactions.values) } + // MARK: - Purchase Update Emissions + 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 func setPromotedProductId(_ id: String?) { promotedProductId = id @@ -48,8 +105,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, + dedupeTransactionIOS: options?.dedupeTransactionIOS ?? true + )) } func addPurchaseErrorListener(_ pair: (UUID, PurchaseErrorListener)) { purchaseErrorListeners.append((id: pair.0, listener: pair.1)) @@ -92,8 +157,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.dedupeTransactionIOS 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..8cde48d8 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -1687,6 +1687,19 @@ public struct PurchaseOptions: Codable { } } +public struct PurchaseUpdatedListenerOptions: Codable { + /// 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( + dedupeTransactionIOS: Bool? = nil + ) { + self.dedupeTransactionIOS = dedupeTransactionIOS + } +} + 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 +2708,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 +2944,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..c69d94aa 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, dedupeTransactionIOS: true) + } + + @objc(addPurchaseUpdatedListener:dedupeTransactionIOS:) + func addPurchaseUpdatedListener( + _ callback: @escaping (NSDictionary) -> Void, + dedupeTransactionIOS: Bool + ) -> NSObject { + let options = PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: dedupeTransactionIOS + ) + 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 db22f686..66fc792f 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -376,16 +376,26 @@ 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, + 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() - } else { - await state.storePending(id: transactionId, transaction: transaction) + await state.removePending(id: transactionId) } - // 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. Default listeners receive each transaction id once; + // non-deduping listeners can opt into the replay for diagnostics. + emitPurchaseUpdate( + purchase, + isDuplicate: !shouldEmit, + duplicateSource: "requestPurchase", + duplicateTransactionId: transactionId + ) return .purchase(purchase) @@ -1369,9 +1379,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 } @@ -1633,10 +1652,22 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { continue } - // Store pending and emit - await self.state.storePending(id: transactionId, transaction: transaction) let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) + // Default listeners receive each transaction id once per connection + // session. Non-deduping 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 + } OpenIapLog.debug("✅ [TransactionListener] Emitting transaction: \(transactionId) for product: \(transaction.productID)") self.emitPurchaseUpdate(purchase) } catch { @@ -1770,9 +1801,49 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } } - private func emitPurchaseUpdate(_ purchase: Purchase) { + private func logDuplicatePurchaseUpdate( + source: String, + transactionId: String, + productId: String, + listenerCount: Int + ) { + let action = listenerCount > 0 + ? "Delivered duplicate purchase-updated event to \(listenerCount) non-deduping listener(s)." + : "Suppressed duplicate purchase-updated listener emission." + let message = """ + [PurchaseUpdateDedup] \(action) + - Source: \(source) + - Product: \(productId) + - Transaction ID: \(transactionId) + - Reason: this transaction id was already emitted during the current connection session. + - 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( + _ 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 + ) + if listeners.isEmpty { + return + } + } 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 f4a6c768..d49d9468 100644 --- a/packages/apple/Tests/OpenIapTests.swift +++ b/packages/apple/Tests/OpenIapTests.swift @@ -85,6 +85,58 @@ 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) + } + + @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( + dedupeTransactionIOS: false + ) + ) + + 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..e5649f19 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{' '} + 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

{{ 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); + }, + { dedupeTransactionIOS: false } +);`} + ), + swift: ( + {`let subscription = OpenIapModule.shared.purchaseUpdatedListener( + { purchase in + print("StoreKit replay or first delivery: \\(purchase.id)") + }, + options: PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: false + ) +)`} + ), + kmp: ( + {`val updates = kmpIAP.purchaseUpdatedListener( + PurchaseUpdatedListenerOptions( + dedupeTransactionIOS = false + ) +)`} + ), + dart: ( + {`final updates = FlutterInappPurchase.instance + .purchaseUpdatedListenerWithOptions( + const PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: false, + ), +);`} + ), + csharp: ( + {`var updates = Iap.Instance.PurchaseUpdatedWithOptions( + new PurchaseUpdatedListenerOptions + { + DedupeTransactionIOS = false, + });`} + ), + gdscript: ( + {`var options = Types.PurchaseUpdatedListenerOptions.new() +options.dedupe_transaction_ios = false +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..f2f6662f --- /dev/null +++ b/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx @@ -0,0 +1,130 @@ +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'; + +/** + * Renders the docs page for purchase update listener options. + * @returns The PurchaseUpdatedListenerOptions documentation page. + */ +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
+ dedupeTransactionIOS + + boolean? + + 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. +
+ + + Default Behavior + +

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

+ + + Examples + + + {{ + typescript: ( + {`purchaseUpdatedListener(onPurchase, { + dedupeTransactionIOS: false, +});`} + ), + swift: ( + {`OpenIapModule.shared.purchaseUpdatedListener( + onPurchase, + options: PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: false + ) +)`} + ), + kmp: ( + {`kmpIAP.purchaseUpdatedListener( + PurchaseUpdatedListenerOptions( + dedupeTransactionIOS = false + ) +)`} + ), + dart: ( + {`FlutterInappPurchase.instance.purchaseUpdatedListenerWithOptions( + const PurchaseUpdatedListenerOptions( + dedupeTransactionIOS: false, + ), +);`} + ), + csharp: ( + {`Iap.Instance.PurchaseUpdatedWithOptions( + new PurchaseUpdatedListenerOptions + { + DedupeTransactionIOS = false, + });`} + ), + gdscript: ( + {`var options = Types.PurchaseUpdatedListenerOptions.new() +options.dedupe_transaction_ios = false +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 6c65fd40..26ef66de 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -26,6 +26,130 @@ 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{' '} + dedupeTransactionIOS flag. StoreKit can replay the same + unfinished transaction through request and transaction-update paths + 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. The planned patch + rollout carries this behavior through openiap-apple, openiap-google, + and all six framework SDKs. Track the fix in{' '} + + issue #152 + {' '} + and{' '} + + PR #153 + + . +

+ +
    +
  • + Listener-level opt-in — React Native and Expo + 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 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 + include PurchaseUpdatedListenerOptions across Swift, + Kotlin, TypeScript, Dart, GDScript, and C#. +
  • +
  • + Usage guide — see{' '} + + purchaseUpdatedListener + {' '} + for the default behavior and opt-in examples. +
  • +
+ +
+
Planned Package Releases
+
    +
  • 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
  • +
+
+
+ ), + }, + // May 13, 2026 — godot-iap 2.2.9 macOS Gatekeeper packaging fix { id: 'godot-iap-2-2-9-macos-gatekeeper-packaging', 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..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 @@ -4294,6 +4294,27 @@ public data class PurchaseOptions( ) } +public data class PurchaseUpdatedListenerOptions( + /** + * 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 dedupeTransactionIOS: Boolean? = null +) { + companion object { + fun fromJson(json: Map): PurchaseUpdatedListenerOptions { + return PurchaseUpdatedListenerOptions( + dedupeTransactionIOS = json["dedupeTransactionIOS"] as? Boolean, + ) + } + } + + fun toJson(): Map = mapOf( + "dedupeTransactionIOS" to dedupeTransactionIOS, + ) +} + public data class RequestPurchaseAndroidProps( /** * Developer billing option parameters for external payments flow (8.3.0+). @@ -5441,8 +5462,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 +5601,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..e89111dd 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -3762,6 +3762,15 @@ public sealed record PurchaseOptions public bool? IncludeSuspendedAndroid { get; init; } } +public sealed record PurchaseUpdatedListenerOptions +{ + /// 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 { /// List of product SKUs @@ -4347,7 +4356,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..7e1e6572 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -4413,6 +4413,27 @@ public data class PurchaseOptions( ) } +public data class PurchaseUpdatedListenerOptions( + /** + * 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 dedupeTransactionIOS: Boolean? = null +) { + companion object { + fun fromJson(json: Map): PurchaseUpdatedListenerOptions { + return PurchaseUpdatedListenerOptions( + dedupeTransactionIOS = json["dedupeTransactionIOS"] as? Boolean, + ) + } + } + + fun toJson(): Map = mapOf( + "dedupeTransactionIOS" to dedupeTransactionIOS, + ) +} + public data class RequestPurchaseAndroidProps( /** * Developer billing option parameters for external payments flow (8.3.0+). @@ -5560,8 +5581,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 +5720,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..8cde48d8 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -1687,6 +1687,19 @@ public struct PurchaseOptions: Codable { } } +public struct PurchaseUpdatedListenerOptions: Codable { + /// 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( + dedupeTransactionIOS: Bool? = nil + ) { + self.dedupeTransactionIOS = dedupeTransactionIOS + } +} + 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 +2708,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 +2944,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..2558620f 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -4347,6 +4347,29 @@ class PurchaseOptions { } } +class PurchaseUpdatedListenerOptions { + const PurchaseUpdatedListenerOptions({ + this.dedupeTransactionIOS, + }); + + /// 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( + dedupeTransactionIOS: json['dedupeTransactionIOS'] as bool?, + ); + } + + Map toJson() { + return { + 'dedupeTransactionIOS': dedupeTransactionIOS, + }; + } +} + class RequestPurchaseAndroidProps { const RequestPurchaseAndroidProps({ this.developerBillingOption, @@ -5452,7 +5475,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? dedupeTransactionIOS, + }); /// 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 +5700,9 @@ class QueryHandlers { typedef SubscriptionDeveloperProvidedBillingAndroidHandler = Future Function(); typedef SubscriptionPromotedProductIOSHandler = Future Function(); typedef SubscriptionPurchaseErrorHandler = Future Function(); -typedef SubscriptionPurchaseUpdatedHandler = Future Function(); +typedef SubscriptionPurchaseUpdatedHandler = Future Function({ + 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 29d37286..13bb03b9 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. 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("dedupeTransactionIOS") and data["dedupeTransactionIOS"] != null: + obj.dedupe_transaction_ios = data["dedupeTransactionIOS"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if dedupe_transaction_ios != null: + dict["dedupeTransactionIOS"] = dedupe_transaction_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..fc6fef35 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1292,6 +1292,15 @@ export interface PurchaseOptions { export type PurchaseState = 'pending' | 'purchased' | 'unknown'; +export interface PurchaseUpdatedListenerOptions { + /** + * 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. + */ + dedupeTransactionIOS?: (boolean | null); +} + export type PurchaseVerificationProvider = 'iapkit'; export interface Query { @@ -1734,7 +1743,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 +1772,9 @@ export interface Subscription { } + +export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined; + export interface SubscriptionInfoIOS { introductoryOffer?: (SubscriptionOfferIOS | null); promotionalOffers?: (SubscriptionOfferIOS[] | null); @@ -2218,7 +2235,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..1bc2f085 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -151,6 +151,16 @@ input PurchaseOptions { includeSuspendedAndroid: Boolean } +# Listener registration options for purchase update events +input PurchaseUpdatedListenerOptions { + """ + 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. + """ + dedupeTransactionIOS: Boolean +} + # Parameters for requestPurchase input RequestPurchaseProps { """