react-native-iap 16.4.1 → 16.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +2 -0
  2. package/android/build.gradle +19 -21
  3. package/android/fix-prefab.gradle +11 -24
  4. package/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +200 -142
  5. package/android/src/main/java/com/margelo/nitro/iap/TokenizedListenerRegistry.kt +0 -1
  6. package/ios/HybridRnIap.swift +832 -165
  7. package/ios/RnIapHelper.swift +1 -1
  8. package/lib/module/hooks/useIAP.js +48 -32
  9. package/lib/module/hooks/useIAP.js.map +1 -1
  10. package/lib/module/index.js +207 -153
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/module/index.kepler.js +1 -0
  13. package/lib/module/index.kepler.js.map +1 -1
  14. package/lib/module/kit-api.js +9 -5
  15. package/lib/module/kit-api.js.map +1 -1
  16. package/lib/module/types.js +1 -0
  17. package/lib/module/types.js.map +1 -1
  18. package/lib/module/utils/error.js +5 -4
  19. package/lib/module/utils/error.js.map +1 -1
  20. package/lib/module/vega-adapter.js +4 -1
  21. package/lib/module/vega-adapter.js.map +1 -1
  22. package/lib/typescript/src/hooks/useIAP.d.ts +31 -15
  23. package/lib/typescript/src/hooks/useIAP.d.ts.map +1 -1
  24. package/lib/typescript/src/index.d.ts +28 -14
  25. package/lib/typescript/src/index.d.ts.map +1 -1
  26. package/lib/typescript/src/index.kepler.d.ts.map +1 -1
  27. package/lib/typescript/src/kit-api.d.ts +9 -5
  28. package/lib/typescript/src/kit-api.d.ts.map +1 -1
  29. package/lib/typescript/src/specs/RnIap.nitro.d.ts +10 -0
  30. package/lib/typescript/src/specs/RnIap.nitro.d.ts.map +1 -1
  31. package/lib/typescript/src/types.d.ts +12 -2
  32. package/lib/typescript/src/types.d.ts.map +1 -1
  33. package/lib/typescript/src/utils/error.d.ts.map +1 -1
  34. package/lib/typescript/src/vega-adapter.d.ts.map +1 -1
  35. package/nitrogen/generated/android/NitroIap+autolinking.cmake +1 -0
  36. package/nitrogen/generated/android/c++/JHybridRnIapSpec.cpp +9 -0
  37. package/nitrogen/generated/android/c++/JNitroPurchaseRequest.hpp +7 -1
  38. package/nitrogen/generated/android/c++/JNitroPurchaseRequestType.hpp +58 -0
  39. package/nitrogen/generated/android/c++/JNitroVerifyPurchaseWithIapkitHorizonProps.hpp +66 -0
  40. package/nitrogen/generated/android/c++/JNitroVerifyPurchaseWithIapkitProps.hpp +8 -1
  41. package/nitrogen/generated/android/c++/JNitroVerifyPurchaseWithProviderProps.hpp +3 -0
  42. package/nitrogen/generated/android/c++/JVariant_NullType_NitroVerifyPurchaseWithIapkitHorizonProps.cpp +26 -0
  43. package/nitrogen/generated/android/c++/JVariant_NullType_NitroVerifyPurchaseWithIapkitHorizonProps.hpp +74 -0
  44. package/nitrogen/generated/android/c++/JVariant_NullType_NitroVerifyPurchaseWithIapkitProps.hpp +3 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/NitroPurchaseRequest.kt +8 -3
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/NitroPurchaseRequestType.kt +23 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/NitroVerifyPurchaseWithIapkitHorizonProps.kt +56 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/NitroVerifyPurchaseWithIapkitProps.kt +7 -2
  49. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/Variant_NullType_NitroVerifyPurchaseWithIapkitHorizonProps.kt +62 -0
  50. package/nitrogen/generated/ios/NitroIap-Swift-Cxx-Bridge.hpp +65 -0
  51. package/nitrogen/generated/ios/NitroIap-Swift-Cxx-Umbrella.hpp +6 -0
  52. package/nitrogen/generated/ios/c++/HybridRnIapSpecSwift.hpp +6 -0
  53. package/nitrogen/generated/ios/swift/NitroPurchaseRequest.swift +13 -2
  54. package/nitrogen/generated/ios/swift/NitroPurchaseRequestType.swift +40 -0
  55. package/nitrogen/generated/ios/swift/NitroVerifyPurchaseWithIapkitHorizonProps.swift +66 -0
  56. package/nitrogen/generated/ios/swift/NitroVerifyPurchaseWithIapkitProps.swift +38 -1
  57. package/nitrogen/generated/ios/swift/Variant_NullType_NitroVerifyPurchaseWithIapkitHorizonProps.swift +30 -0
  58. package/nitrogen/generated/shared/c++/NitroPurchaseRequest.hpp +9 -2
  59. package/nitrogen/generated/shared/c++/NitroPurchaseRequestType.hpp +76 -0
  60. package/nitrogen/generated/shared/c++/NitroVerifyPurchaseWithIapkitHorizonProps.hpp +90 -0
  61. package/nitrogen/generated/shared/c++/NitroVerifyPurchaseWithIapkitProps.hpp +8 -1
  62. package/openiap-versions.json +3 -3
  63. package/package.json +1 -1
  64. package/src/hooks/useIAP.ts +96 -58
  65. package/src/index.kepler.ts +1 -0
  66. package/src/index.ts +249 -196
  67. package/src/kit-api.ts +10 -6
  68. package/src/specs/RnIap.nitro.ts +12 -0
  69. package/src/types.ts +13 -2
  70. package/src/utils/error.ts +7 -3
  71. package/src/vega-adapter.ts +9 -5
@@ -2,8 +2,54 @@ import Foundation
2
2
  import NitroModules
3
3
  import OpenIAP
4
4
 
5
+ private final class PendingEventBuffer<Event> {
6
+ private let capacity: Int
7
+ private let label: String
8
+ private var events: [Event] = []
9
+ private var isFlushing = false
10
+
11
+ init(capacity: Int, label: String) {
12
+ precondition(capacity > 0)
13
+ self.capacity = capacity
14
+ self.label = label
15
+ }
16
+
17
+ func enqueueIfNeeded(hasListeners: Bool, event: Event) -> Bool {
18
+ guard !hasListeners || isFlushing else { return false }
19
+ if events.count >= capacity {
20
+ events.removeFirst()
21
+ RnIapLog.warn("\(label) overflow; dropping oldest")
22
+ }
23
+ events.append(event)
24
+ return true
25
+ }
26
+
27
+ func beginFlushIfNeeded() -> Bool {
28
+ guard !isFlushing, !events.isEmpty else { return false }
29
+ isFlushing = true
30
+ return true
31
+ }
32
+
33
+ func takeBatchOrFinish(hasListeners: Bool) -> [Event]? {
34
+ guard hasListeners, !events.isEmpty else {
35
+ isFlushing = false
36
+ return nil
37
+ }
38
+ let batch = events
39
+ events.removeAll()
40
+ return batch
41
+ }
42
+
43
+ func clear() {
44
+ events.removeAll()
45
+ isFlushing = false
46
+ }
47
+ }
48
+
5
49
  @available(iOS 15.0, macOS 14.0, tvOS 15.0, watchOS 8.0, *)
6
50
  class HybridRnIap: HybridRnIapSpec {
51
+ private static let maxPendingEvents = 200
52
+
7
53
  private enum PurchaseUpdatedListenerBucket {
8
54
  case deduping
9
55
  case nonDeduping
@@ -35,6 +81,22 @@ class HybridRnIap: HybridRnIapSpec {
35
81
  private var lastPurchaseErrorKey: String? = nil
36
82
  private var lastPurchaseErrorTimestamp: TimeInterval = 0
37
83
  private var purchasePayloadById: [String: [String: Any]] = [:]
84
+ private var deliveredPurchaseUpdateIds = Set<String>()
85
+ private var pendingDuplicatePurchaseUpdateSuppressions: [String: Int] = [:]
86
+ private var pendingRequestPurchaseErrorSuppressions: [String: Int] = [:]
87
+ private var pendingOnDemandInitErrorSuppressions: [String: Int] = [:]
88
+ private let pendingPurchaseUpdates = PendingEventBuffer<NitroPurchase>(
89
+ capacity: HybridRnIap.maxPendingEvents,
90
+ label: "pendingPurchaseUpdates"
91
+ )
92
+ private let pendingDuplicatePurchaseUpdates = PendingEventBuffer<NitroPurchase>(
93
+ capacity: HybridRnIap.maxPendingEvents,
94
+ label: "pendingDuplicatePurchaseUpdates"
95
+ )
96
+ private let pendingPurchaseErrors = PendingEventBuffer<NitroPurchaseResult>(
97
+ capacity: HybridRnIap.maxPendingEvents,
98
+ label: "pendingPurchaseErrors"
99
+ )
38
100
  // Thread safety lock for listener arrays and error dedup state
39
101
  private let listenerLock = NSLock()
40
102
  private let lifecycleLock = NSLock()
@@ -51,22 +113,54 @@ class HybridRnIap: HybridRnIapSpec {
51
113
 
52
114
 
53
115
  func initConnection(config: Variant_NullType_InitConnectionConfig?) throws -> Promise<Bool> {
54
- let operation = enqueueLifecycleOperation {
55
- let configValue: InitConnectionConfig? = {
56
- if case .second(let c) = config { return c }
57
- return nil
58
- }()
116
+ let configValue: InitConnectionConfig? = {
117
+ if case .second(let c) = config { return c }
118
+ return nil
119
+ }()
120
+ let operation = enqueueConnect(configValue)
121
+ return Promise.async { try await operation.value }
122
+ }
123
+
124
+ // StoreKit initialization still owns caches and observers despite having no persistent connection.
125
+ private func ensureConnection() async throws {
126
+ if isConnectionInitialized() { return }
127
+ _ = try await enqueueConnect(nil, reuseExistingConnection: true).value
128
+ // Never run a store operation without listeners installed by the same lifecycle path.
129
+ guard isConnectionInitialized() else {
130
+ throw OpenIapException.make(code: .initConnection, message: "Connection not initialized. Call initConnection() first.")
131
+ }
132
+ }
133
+
134
+ private func enqueueConnect(
135
+ _ configValue: InitConnectionConfig?,
136
+ reuseExistingConnection: Bool = false,
137
+ connect: (() async throws -> Bool)? = nil
138
+ ) -> Task<Bool, Error> {
139
+ enqueueLifecycleOperation {
140
+ if reuseExistingConnection, self.isConnectionInitialized() { return true }
59
141
  RnIapLog.payload("initConnection", configValue)
60
142
  let epoch = self.listenerLock.withLock { self.connectionEpoch }
61
143
 
62
144
  do {
63
145
  // Note: iOS doesn't support alternative billing config parameter
64
146
  // Config is ignored on iOS platform
65
- let ok = try await OpenIapModule.shared.initConnection()
147
+ self.attachCoreListenersIfNeeded(epoch: epoch)
148
+ let ok: Bool
149
+ if let connect {
150
+ ok = try await connect()
151
+ } else {
152
+ ok = try await OpenIapModule.shared.initConnection()
153
+ }
66
154
  RnIapLog.result("initConnection", ok)
67
155
  let isCurrent = self.listenerLock.withLock {
68
156
  guard self.connectionEpoch == epoch else { return false }
69
157
  self.isInitialized = ok
158
+ if reuseExistingConnection, !ok {
159
+ self.pendingOnDemandInitErrorSuppressions[
160
+ ErrorCode.iapNotAvailable.rawValue,
161
+ default: 0
162
+ ] += 1
163
+ }
70
164
  return true
71
165
  }
72
166
  guard isCurrent else { return false }
@@ -80,7 +174,7 @@ class HybridRnIap: HybridRnIapSpec {
80
174
  self.isInitialized = false
81
175
  return true
82
176
  }
83
- if isCurrent {
177
+ if isCurrent, !reuseExistingConnection {
84
178
  RnIapLog.failure("initConnection", error: error)
85
179
  let err = RnIapHelper.makePurchaseErrorResult(
86
180
  code: .initConnection,
@@ -91,28 +185,47 @@ class HybridRnIap: HybridRnIapSpec {
91
185
  return false
92
186
  }
93
187
  }
94
- return Promise.async { try await operation.value }
95
188
  }
96
-
189
+
190
+ func enqueueConnectOperation(
191
+ _ connect: @escaping () async throws -> Bool
192
+ ) -> Task<Bool, Error> {
193
+ enqueueConnect(nil, connect: connect)
194
+ }
195
+
196
+ func enqueueOnDemandConnectOperation(
197
+ _ connect: @escaping () async throws -> Bool
198
+ ) -> Task<Bool, Error> {
199
+ enqueueConnect(nil, reuseExistingConnection: true, connect: connect)
200
+ }
201
+
97
202
  func endConnection() throws -> Promise<Bool> {
203
+ let operation = enqueueEndOperation {
204
+ try await OpenIapModule.shared.endConnection()
205
+ }
206
+ return Promise.async { try await operation.value }
207
+ }
208
+
209
+ func enqueueEndOperation(
210
+ _ endConnection: @escaping () async throws -> Bool
211
+ ) -> Task<Bool, Error> {
98
212
  let operation = enqueueLifecycleOperation {
213
+ RnIapLog.payload("endConnection", nil)
214
+ let result = try await endConnection()
99
215
  let subscriptions = self.detachConnectionState()
100
216
  self.removeSubscriptions(subscriptions)
101
217
  await MainActor.run {
102
218
  self.productTypeBySku.removeAll()
103
219
  self.purchasePayloadById.removeAll()
104
220
  }
105
- RnIapLog.payload("endConnection", nil)
106
- let result = try await OpenIapModule.shared.endConnection()
107
221
  RnIapLog.result("endConnection", result)
108
222
  return result
109
223
  }
110
- return Promise.async { try await operation.value }
224
+ return operation
111
225
  }
112
226
 
113
227
  func fetchProducts(skus: [String], type: String) throws -> Promise<[NitroProduct]> {
114
228
  return Promise.async {
115
- try self.ensureConnection()
116
229
  RnIapLog.payload("fetchProducts", [
117
230
  "skus": skus,
118
231
  "type": type
@@ -122,7 +235,6 @@ class HybridRnIap: HybridRnIapSpec {
122
235
  throw OpenIapException.make(code: .emptySkuList)
123
236
  }
124
237
 
125
- var productsById: [String: NitroProduct] = [:]
126
238
  let normalizedType = type.lowercased()
127
239
  let queryTypes: [ProductQueryType]
128
240
  if normalizedType == "all" {
@@ -131,21 +243,27 @@ class HybridRnIap: HybridRnIapSpec {
131
243
  queryTypes = [RnIapHelper.parseProductQueryType(type)]
132
244
  }
133
245
 
134
- for queryType in queryTypes {
135
- let request = try OpenIapSerialization.productRequest(skus: skus, type: queryType)
136
- RnIapLog.payload(
137
- "fetchProducts.native", [
138
- "skus": skus,
139
- "type": queryType.rawValue
140
- ]
141
- )
142
- let result = try await OpenIapModule.shared.fetchProducts(request)
143
- let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.products(result))
144
- RnIapLog.result("fetchProducts.native", payloads)
145
- for payload in payloads {
146
- let nitroProduct = RnIapHelper.convertProductDictionary(payload)
147
- productsById[nitroProduct.id] = nitroProduct
246
+ let productsById = try await self.runConnectedOperation {
247
+ var fetched: [String: NitroProduct] = [:]
248
+ for queryType in queryTypes {
249
+ let request = try OpenIapSerialization.productRequest(skus: skus, type: queryType)
250
+ RnIapLog.payload(
251
+ "fetchProducts.native", [
252
+ "skus": skus,
253
+ "type": queryType.rawValue
254
+ ]
255
+ )
256
+ let result = try await OpenIapModule.shared.fetchProducts(request)
257
+ let payloads = RnIapHelper.sanitizeArray(
258
+ OpenIapSerialization.products(result)
259
+ )
260
+ RnIapLog.result("fetchProducts.native", payloads)
261
+ for payload in payloads {
262
+ let product = RnIapHelper.convertProductDictionary(payload)
263
+ fetched[product.id] = product
264
+ }
148
265
  }
266
+ return fetched
149
267
  }
150
268
 
151
269
  var products: [NitroProduct] = []
@@ -200,16 +318,6 @@ class HybridRnIap: HybridRnIapSpec {
200
318
  return defaultResult
201
319
  }
202
320
 
203
- guard self.isConnectionInitialized() else {
204
- let err = RnIapHelper.makePurchaseErrorResult(
205
- code: .initConnection,
206
- message: "IAP store connection not initialized",
207
- iosRequest.sku
208
- )
209
- self.sendPurchaseError(err, productId: iosRequest.sku)
210
- return defaultResult
211
- }
212
-
213
321
  do {
214
322
  var iosPayload: [String: Any] = ["sku": iosRequest.sku]
215
323
  if case .second(let quantity) = iosRequest.quantity { iosPayload["quantity"] = Int(quantity) }
@@ -242,9 +350,14 @@ class HybridRnIap: HybridRnIapSpec {
242
350
  iosPayload["winBackOffer"] = ["offerId": winBackOffer.offerId]
243
351
  }
244
352
 
245
- let cachedType = await MainActor.run { self.productTypeBySku[iosRequest.sku] }
246
- let resolvedType = RnIapHelper.parseProductQueryType(cachedType)
247
- let purchaseType: ProductQueryType = resolvedType == .all ? .inApp : resolvedType
353
+ let purchaseType: ProductQueryType
354
+ if let requestType = request.type {
355
+ purchaseType = requestType == .subs ? .subs : .inApp
356
+ } else {
357
+ let cachedType = await MainActor.run { self.productTypeBySku[iosRequest.sku] }
358
+ let resolvedType = RnIapHelper.parseProductQueryType(cachedType)
359
+ purchaseType = resolvedType == .all ? .inApp : resolvedType
360
+ }
248
361
  await MainActor.run {
249
362
  self.productTypeBySku[iosRequest.sku] = purchaseType.rawValue
250
363
  }
@@ -258,7 +371,9 @@ class HybridRnIap: HybridRnIapSpec {
258
371
  "requestPurchase.native", iosPayload
259
372
  )
260
373
 
261
- let result = try await OpenIapModule.shared.requestPurchase(props)
374
+ let result = try await self.runRequestPurchaseOperation {
375
+ try await OpenIapModule.shared.requestPurchase(props)
376
+ }
262
377
  if result != nil {
263
378
  RnIapLog.result("requestPurchase", "delegated to OpenIAP")
264
379
  } else {
@@ -268,8 +383,15 @@ class HybridRnIap: HybridRnIapSpec {
268
383
  return defaultResult
269
384
  } catch let purchaseError as PurchaseError {
270
385
  RnIapLog.failure("requestPurchase", error: purchaseError)
271
- // OpenIAP already publishes purchaseError events for PurchaseError instances.
272
- // Avoid emitting a duplicate event back to JS; simply return.
386
+ return defaultResult
387
+ } catch let connectionError as OpenIapException {
388
+ RnIapLog.failure("requestPurchase", error: connectionError)
389
+ let err = RnIapHelper.makePurchaseErrorResult(
390
+ code: .initConnection,
391
+ message: "IAP store connection ended before the purchase started",
392
+ iosRequest.sku
393
+ )
394
+ self.sendPurchaseError(err, productId: iosRequest.sku)
273
395
  return defaultResult
274
396
  } catch {
275
397
  RnIapLog.failure("requestPurchase", error: error)
@@ -286,7 +408,6 @@ class HybridRnIap: HybridRnIapSpec {
286
408
 
287
409
  func getAvailablePurchases(options: NitroAvailablePurchasesOptions?) throws -> Promise<[NitroPurchase]> {
288
410
  return Promise.async {
289
- try self.ensureConnection()
290
411
  do {
291
412
  // Unwrap Variant ios options
292
413
  let iosOpts: NitroAvailablePurchasesIosOptions?
@@ -310,13 +431,18 @@ class HybridRnIap: HybridRnIapSpec {
310
431
  ]
311
432
  let purchaseOptions = try OpenIapSerialization.purchaseOptions(from: optionsDictionary)
312
433
  RnIapLog.payload("getAvailablePurchases", optionsDictionary)
313
- let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions)
434
+ let purchases = try await self.runConnectedOperation {
435
+ try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions)
436
+ }
314
437
  let payloads = RnIapHelper.sanitizeArray(try RnIapHelper.purchasesRequired(purchases))
315
438
  RnIapLog.result("getAvailablePurchases", payloads)
316
439
  return payloads.map { RnIapHelper.convertPurchaseDictionary($0) }
317
440
  } catch let purchaseError as PurchaseError {
318
441
  RnIapLog.failure("getAvailablePurchases", error: purchaseError)
319
442
  throw OpenIapException.from(purchaseError)
443
+ } catch let connectionError as OpenIapException {
444
+ RnIapLog.failure("getAvailablePurchases", error: connectionError)
445
+ throw connectionError
320
446
  } catch {
321
447
  RnIapLog.failure("getAvailablePurchases", error: error)
322
448
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -326,11 +452,12 @@ class HybridRnIap: HybridRnIapSpec {
326
452
 
327
453
  func getActiveSubscriptions(subscriptionIds: [String]?) throws -> Promise<[NitroActiveSubscription]> {
328
454
  return Promise.async {
329
- try self.ensureConnection()
330
455
  do {
331
456
  RnIapLog.payload("getActiveSubscriptions", subscriptionIds ?? [])
332
457
  // Call OpenIAP's native getActiveSubscriptions - includes renewalInfoIOS!
333
- let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds)
458
+ let subscriptions = try await self.runConnectedOperation {
459
+ try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds)
460
+ }
334
461
  let payloads = RnIapHelper.sanitizeArray(
335
462
  try subscriptions.map { try RnIapHelper.encodeRequired($0) }
336
463
  )
@@ -339,6 +466,9 @@ class HybridRnIap: HybridRnIapSpec {
339
466
  } catch let purchaseError as PurchaseError {
340
467
  RnIapLog.failure("getActiveSubscriptions", error: purchaseError)
341
468
  throw OpenIapException.from(purchaseError)
469
+ } catch let connectionError as OpenIapException {
470
+ RnIapLog.failure("getActiveSubscriptions", error: connectionError)
471
+ throw connectionError
342
472
  } catch {
343
473
  RnIapLog.failure("getActiveSubscriptions", error: error)
344
474
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -348,15 +478,19 @@ class HybridRnIap: HybridRnIapSpec {
348
478
 
349
479
  func hasActiveSubscriptions(subscriptionIds: [String]?) throws -> Promise<Bool> {
350
480
  return Promise.async {
351
- try self.ensureConnection()
352
481
  do {
353
482
  RnIapLog.payload("hasActiveSubscriptions", subscriptionIds ?? [])
354
- let hasActive = try await OpenIapModule.shared.hasActiveSubscriptions(subscriptionIds)
483
+ let hasActive = try await self.runConnectedOperation {
484
+ try await OpenIapModule.shared.hasActiveSubscriptions(subscriptionIds)
485
+ }
355
486
  RnIapLog.result("hasActiveSubscriptions", hasActive)
356
487
  return hasActive
357
488
  } catch let purchaseError as PurchaseError {
358
489
  RnIapLog.failure("hasActiveSubscriptions", error: purchaseError)
359
490
  throw OpenIapException.from(purchaseError)
491
+ } catch let connectionError as OpenIapException {
492
+ RnIapLog.failure("hasActiveSubscriptions", error: connectionError)
493
+ throw connectionError
360
494
  } catch {
361
495
  RnIapLog.failure("hasActiveSubscriptions", error: error)
362
496
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -367,7 +501,6 @@ class HybridRnIap: HybridRnIapSpec {
367
501
  func finishTransaction(params: NitroFinishTransactionParams) throws -> Promise<Variant_Bool_NitroPurchaseResult> {
368
502
  return Promise.async {
369
503
  guard case .second(let iosParams) = params.ios else { return .first(true) }
370
- try self.ensureConnection()
371
504
  do {
372
505
  RnIapLog.payload(
373
506
  "finishTransaction", ["transactionId": iosParams.transactionId]
@@ -385,16 +518,26 @@ class HybridRnIap: HybridRnIapSpec {
385
518
  let sanitizedPayload = RnIapHelper.sanitizeDictionary(purchasePayload)
386
519
  RnIapLog.payload("finishTransaction.nativePayload", sanitizedPayload)
387
520
  let purchaseInput = try OpenIapSerialization.purchaseInput(from: purchasePayload)
388
- _ = try await OpenIapModule.shared.finishTransaction(purchase: purchaseInput, isConsumable: nil)
521
+ _ = try await self.runConnectedOperation {
522
+ try await OpenIapModule.shared.finishTransaction(
523
+ purchase: purchaseInput,
524
+ isConsumable: nil
525
+ )
526
+ }
389
527
  RnIapLog.result("finishTransaction", true)
390
528
  _ = await MainActor.run {
391
529
  self.purchasePayloadById.removeValue(forKey: iosParams.transactionId)
392
530
  }
393
531
  return .first(true)
532
+ } catch let purchaseError as PurchaseError {
533
+ RnIapLog.failure("finishTransaction", error: purchaseError)
534
+ throw OpenIapException.from(purchaseError)
535
+ } catch let openIapException as OpenIapException {
536
+ RnIapLog.failure("finishTransaction", error: openIapException)
537
+ throw openIapException
394
538
  } catch {
395
539
  RnIapLog.failure("finishTransaction", error: error)
396
- let tid = iosParams.transactionId
397
- throw OpenIapException.make(code: .purchaseError, message: "Transaction not found: \(tid)")
540
+ throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
398
541
  }
399
542
  }
400
543
  }
@@ -410,7 +553,9 @@ class HybridRnIap: HybridRnIapSpec {
410
553
 
411
554
  RnIapLog.payload("verifyPurchase", ["sku": sku])
412
555
  let props = try OpenIapSerialization.verifyPurchaseProps(from: ["apple": ["sku": sku]])
413
- let verifyResult = try await OpenIapModule.shared.verifyPurchase(props)
556
+ let verifyResult = try await self.runConnectedOperation {
557
+ try await OpenIapModule.shared.verifyPurchase(props)
558
+ }
414
559
  guard case let .verifyPurchaseResultIos(result) = verifyResult else {
415
560
  throw OpenIapException.make(code: .featureNotSupported, message: "Expected iOS validation result")
416
561
  }
@@ -437,6 +582,9 @@ class HybridRnIap: HybridRnIapSpec {
437
582
  } catch let purchaseError as PurchaseError {
438
583
  RnIapLog.failure("verifyPurchase", error: purchaseError)
439
584
  throw OpenIapException.from(purchaseError)
585
+ } catch let connectionError as OpenIapException {
586
+ RnIapLog.failure("verifyPurchase", error: connectionError)
587
+ throw connectionError
440
588
  } catch {
441
589
  RnIapLog.failure("verifyPurchase", error: error)
442
590
  throw OpenIapException.make(code: .purchaseVerificationFailed, message: error.localizedDescription)
@@ -471,6 +619,13 @@ class HybridRnIap: HybridRnIapSpec {
471
619
  if case .second(let google) = iapkit.google {
472
620
  iapkitDict["google"] = ["purchaseToken": google.purchaseToken]
473
621
  }
622
+ if case .second(let horizon) = iapkit.horizon {
623
+ var horizonDict: [String: Any] = ["sku": horizon.sku]
624
+ if case .second(let userId) = horizon.userId {
625
+ horizonDict["userId"] = userId
626
+ }
627
+ iapkitDict["horizon"] = horizonDict
628
+ }
474
629
  if case .second(let amazon) = iapkit.amazon {
475
630
  var amazonDict: [String: Any] = [
476
631
  "receiptId": amazon.receiptId
@@ -491,7 +646,9 @@ class HybridRnIap: HybridRnIapSpec {
491
646
  // Use JSONSerialization + JSONDecoder like expo-iap does
492
647
  let jsonData = try JSONSerialization.data(withJSONObject: propsDict)
493
648
  let props = try JSONDecoder().decode(VerifyPurchaseWithProviderProps.self, from: jsonData)
494
- let result = try await OpenIapModule.shared.verifyPurchaseWithProvider(props)
649
+ let result = try await self.runConnectedOperation {
650
+ try await OpenIapModule.shared.verifyPurchaseWithProvider(props)
651
+ }
495
652
  RnIapLog.result("verifyPurchaseWithProvider", ["provider": result.provider, "hasIapkit": result.iapkit != nil])
496
653
  // Convert result to Nitro types
497
654
  var nitroIapkitResult: NitroVerifyPurchaseWithIapkitResult? = nil
@@ -537,6 +694,9 @@ class HybridRnIap: HybridRnIapSpec {
537
694
  // Convert PurchaseError to OpenIapException to preserve message through Nitro bridge
538
695
  RnIapLog.failure("verifyPurchaseWithProvider", error: purchaseError)
539
696
  throw OpenIapException.from(purchaseError)
697
+ } catch let connectionError as OpenIapException {
698
+ RnIapLog.failure("verifyPurchaseWithProvider", error: connectionError)
699
+ throw connectionError
540
700
  } catch {
541
701
  RnIapLog.failure("verifyPurchaseWithProvider", error: error)
542
702
  throw OpenIapException.make(code: .purchaseVerificationFailed, message: error.localizedDescription)
@@ -548,12 +708,17 @@ class HybridRnIap: HybridRnIapSpec {
548
708
  return Promise.async {
549
709
  do {
550
710
  RnIapLog.payload("getStorefront", nil)
551
- let storefront = try await OpenIapModule.shared.getStorefront()
711
+ let storefront = try await self.runConnectedOperation {
712
+ try await OpenIapModule.shared.getStorefront()
713
+ }
552
714
  RnIapLog.result("getStorefront", storefront)
553
715
  return storefront
554
716
  } catch let purchaseError as PurchaseError {
555
717
  RnIapLog.failure("getStorefront", error: purchaseError)
556
718
  throw OpenIapException.from(purchaseError)
719
+ } catch let connectionError as OpenIapException {
720
+ RnIapLog.failure("getStorefront", error: connectionError)
721
+ throw connectionError
557
722
  } catch {
558
723
  RnIapLog.failure("getStorefront", error: error)
559
724
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -567,7 +732,10 @@ class HybridRnIap: HybridRnIapSpec {
567
732
  do {
568
733
  RnIapLog.payload("getAppTransactionIOS", nil)
569
734
  if #available(iOS 16.0, *) {
570
- if let appTx = try await OpenIapModule.shared.getAppTransactionIOS() {
735
+ let appTx = try await self.runConnectedOperation {
736
+ try await OpenIapModule.shared.getAppTransactionIOS()
737
+ }
738
+ if let appTx {
571
739
  var result: [String: Any?] = [
572
740
  "bundleId": appTx.bundleId,
573
741
  "appVersion": appTx.appVersion,
@@ -597,19 +765,27 @@ class HybridRnIap: HybridRnIapSpec {
597
765
  RnIapLog.result("getAppTransactionIOS", nil)
598
766
  return .first(.null)
599
767
  }
768
+ } catch let purchaseError as PurchaseError {
769
+ RnIapLog.failure("getAppTransactionIOS", error: purchaseError)
770
+ throw OpenIapException.from(purchaseError)
771
+ } catch let connectionError as OpenIapException {
772
+ RnIapLog.failure("getAppTransactionIOS", error: connectionError)
773
+ throw connectionError
600
774
  } catch {
601
775
  RnIapLog.failure("getAppTransactionIOS", error: error)
602
- return .first(.null)
776
+ throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
603
777
  }
604
778
  }
605
779
  }
606
780
 
607
781
  func getPromotedProductIOS() throws -> Promise<Variant_NullType_NitroProduct> {
608
782
  return Promise.async {
609
- try self.ensureConnection()
610
783
  do {
611
784
  RnIapLog.payload("getPromotedProductIOS", nil)
612
- guard let product = try await OpenIapModule.shared.getPromotedProductIOS() else {
785
+ let product = try await self.runConnectedOperation {
786
+ try await OpenIapModule.shared.getPromotedProductIOS()
787
+ }
788
+ guard let product else {
613
789
  RnIapLog.result("getPromotedProductIOS", nil)
614
790
  return .first(.null)
615
791
  }
@@ -619,6 +795,9 @@ class HybridRnIap: HybridRnIapSpec {
619
795
  } catch let purchaseError as PurchaseError {
620
796
  RnIapLog.failure("getPromotedProductIOS", error: purchaseError)
621
797
  throw OpenIapException.from(purchaseError)
798
+ } catch let connectionError as OpenIapException {
799
+ RnIapLog.failure("getPromotedProductIOS", error: connectionError)
800
+ throw connectionError
622
801
  } catch {
623
802
  RnIapLog.failure("getPromotedProductIOS", error: error)
624
803
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -630,7 +809,10 @@ class HybridRnIap: HybridRnIapSpec {
630
809
  return Promise.async {
631
810
  do {
632
811
  RnIapLog.payload("presentCodeRedemptionSheetIOS", nil)
633
- guard let purchase = try await OpenIapModule.shared.presentCodeRedemptionSheetIOS() else {
812
+ let purchase = try await self.runConnectedOperation {
813
+ try await OpenIapModule.shared.presentCodeRedemptionSheetIOS()
814
+ }
815
+ guard let purchase else {
634
816
  RnIapLog.result("presentCodeRedemptionSheetIOS", nil)
635
817
  return .first(.null)
636
818
  }
@@ -643,6 +825,9 @@ class HybridRnIap: HybridRnIapSpec {
643
825
  }
644
826
  }
645
827
  return .second(RnIapHelper.convertPurchaseDictionary(payload))
828
+ } catch let connectionError as OpenIapException {
829
+ RnIapLog.failure("presentCodeRedemptionSheetIOS", error: connectionError)
830
+ throw connectionError
646
831
  } catch {
647
832
  RnIapLog.failure("presentCodeRedemptionSheetIOS", error: error)
648
833
  throw OpenIapException.make(
@@ -656,7 +841,9 @@ class HybridRnIap: HybridRnIapSpec {
656
841
  func clearTransactionIOS() throws -> Promise<Void> {
657
842
  return Promise.async {
658
843
  RnIapLog.payload("clearTransactionIOS", nil)
659
- let ok = try await OpenIapModule.shared.clearTransactionIOS()
844
+ let ok = try await self.runConnectedOperation {
845
+ try await OpenIapModule.shared.clearTransactionIOS()
846
+ }
660
847
  RnIapLog.result("clearTransactionIOS", ok)
661
848
  }
662
849
  }
@@ -665,10 +852,11 @@ class HybridRnIap: HybridRnIapSpec {
665
852
 
666
853
  func subscriptionStatusIOS(sku: String) throws -> Promise<Variant_NullType__NitroSubscriptionStatus_> {
667
854
  return Promise.async {
668
- try self.ensureConnection()
669
855
  do {
670
856
  RnIapLog.payload("subscriptionStatusIOS", ["sku": sku])
671
- let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku)
857
+ let statuses = try await self.runConnectedOperation {
858
+ try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku)
859
+ }
672
860
  let payloads = statuses.map { RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
673
861
  RnIapLog.result("subscriptionStatusIOS", payloads)
674
862
  let result: [NitroSubscriptionStatus] = payloads.map { payload in
@@ -692,19 +880,26 @@ class HybridRnIap: HybridRnIapSpec {
692
880
  return NitroSubscriptionStatus(state: stateValue, platform: platform, renewalInfo: renewalInfo)
693
881
  }
694
882
  return .second(result)
883
+ } catch let purchaseError as PurchaseError {
884
+ RnIapLog.failure("subscriptionStatusIOS", error: purchaseError)
885
+ throw OpenIapException.from(purchaseError)
886
+ } catch let connectionError as OpenIapException {
887
+ RnIapLog.failure("subscriptionStatusIOS", error: connectionError)
888
+ throw connectionError
695
889
  } catch {
696
890
  RnIapLog.failure("subscriptionStatusIOS", error: error)
697
- return .second([])
891
+ throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
698
892
  }
699
893
  }
700
894
  }
701
895
 
702
896
  func currentEntitlementIOS(sku: String) throws -> Promise<Variant_NullType_NitroPurchase> {
703
897
  return Promise.async {
704
- try self.ensureConnection()
705
898
  do {
706
899
  RnIapLog.payload("currentEntitlementIOS", ["sku": sku])
707
- let purchase = try await OpenIapModule.shared.currentEntitlementIOS(sku: sku)
900
+ let purchase = try await self.runConnectedOperation {
901
+ try await OpenIapModule.shared.currentEntitlementIOS(sku: sku)
902
+ }
708
903
  if let purchase {
709
904
  let raw = OpenIapSerialization.encode(purchase)
710
905
  let payload = RnIapHelper.sanitizeDictionary(raw)
@@ -718,19 +913,26 @@ class HybridRnIap: HybridRnIapSpec {
718
913
  }
719
914
  RnIapLog.result("currentEntitlementIOS", nil)
720
915
  return .first(.null)
916
+ } catch let purchaseError as PurchaseError {
917
+ RnIapLog.failure("currentEntitlementIOS", error: purchaseError)
918
+ throw OpenIapException.from(purchaseError)
919
+ } catch let connectionError as OpenIapException {
920
+ RnIapLog.failure("currentEntitlementIOS", error: connectionError)
921
+ throw connectionError
721
922
  } catch {
722
923
  RnIapLog.failure("currentEntitlementIOS", error: error)
723
- throw OpenIapException.make(code: .skuNotFound, productId: sku)
924
+ throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
724
925
  }
725
926
  }
726
927
  }
727
928
 
728
929
  func latestTransactionIOS(sku: String) throws -> Promise<Variant_NullType_NitroPurchase> {
729
930
  return Promise.async {
730
- try self.ensureConnection()
731
931
  do {
732
932
  RnIapLog.payload("latestTransactionIOS", ["sku": sku])
733
- let purchase = try await OpenIapModule.shared.latestTransactionIOS(sku: sku)
933
+ let purchase = try await self.runConnectedOperation {
934
+ try await OpenIapModule.shared.latestTransactionIOS(sku: sku)
935
+ }
734
936
  if let purchase {
735
937
  let raw = OpenIapSerialization.encode(purchase)
736
938
  let payload = RnIapHelper.sanitizeDictionary(raw)
@@ -744,9 +946,15 @@ class HybridRnIap: HybridRnIapSpec {
744
946
  }
745
947
  RnIapLog.result("latestTransactionIOS", nil)
746
948
  return .first(.null)
949
+ } catch let purchaseError as PurchaseError {
950
+ RnIapLog.failure("latestTransactionIOS", error: purchaseError)
951
+ throw OpenIapException.from(purchaseError)
952
+ } catch let connectionError as OpenIapException {
953
+ RnIapLog.failure("latestTransactionIOS", error: connectionError)
954
+ throw connectionError
747
955
  } catch {
748
956
  RnIapLog.failure("latestTransactionIOS", error: error)
749
- throw OpenIapException.make(code: .skuNotFound, productId: sku)
957
+ throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
750
958
  }
751
959
  }
752
960
  }
@@ -755,7 +963,9 @@ class HybridRnIap: HybridRnIapSpec {
755
963
  return Promise.async {
756
964
  do {
757
965
  RnIapLog.payload("getPendingTransactionsIOS", nil)
758
- let pending = try await OpenIapModule.shared.getPendingTransactionsIOS()
966
+ let pending = try await self.runConnectedOperation {
967
+ try await OpenIapModule.shared.getPendingTransactionsIOS()
968
+ }
759
969
  var unionPurchases: [OpenIAP.Purchase] = []
760
970
  for purchase in pending {
761
971
  let union = OpenIAP.Purchase.purchaseIos(purchase)
@@ -773,6 +983,9 @@ class HybridRnIap: HybridRnIapSpec {
773
983
  } catch let purchaseError as PurchaseError {
774
984
  RnIapLog.failure("getPendingTransactionsIOS", error: purchaseError)
775
985
  throw OpenIapException.from(purchaseError)
986
+ } catch let connectionError as OpenIapException {
987
+ RnIapLog.failure("getPendingTransactionsIOS", error: connectionError)
988
+ throw connectionError
776
989
  } catch {
777
990
  RnIapLog.failure("getPendingTransactionsIOS", error: error)
778
991
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -784,7 +997,9 @@ class HybridRnIap: HybridRnIapSpec {
784
997
  return Promise.async {
785
998
  do {
786
999
  RnIapLog.payload("getAllTransactionsIOS", nil)
787
- let all = try await OpenIapModule.shared.getAllTransactionsIOS()
1000
+ let all = try await self.runConnectedOperation {
1001
+ try await OpenIapModule.shared.getAllTransactionsIOS()
1002
+ }
788
1003
  var unionPurchases: [OpenIAP.Purchase] = []
789
1004
  var payloadUpdates: [String: [String: Any]] = [:]
790
1005
  for purchase in all {
@@ -807,6 +1022,9 @@ class HybridRnIap: HybridRnIapSpec {
807
1022
  } catch let purchaseError as PurchaseError {
808
1023
  RnIapLog.failure("getAllTransactionsIOS", error: purchaseError)
809
1024
  throw OpenIapException.from(purchaseError)
1025
+ } catch let connectionError as OpenIapException {
1026
+ RnIapLog.failure("getAllTransactionsIOS", error: connectionError)
1027
+ throw connectionError
810
1028
  } catch {
811
1029
  RnIapLog.failure("getAllTransactionsIOS", error: error)
812
1030
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -818,12 +1036,17 @@ class HybridRnIap: HybridRnIapSpec {
818
1036
  return Promise.async {
819
1037
  do {
820
1038
  RnIapLog.payload("syncIOS", nil)
821
- let ok = try await OpenIapModule.shared.syncIOS()
1039
+ let ok = try await self.runConnectedOperation {
1040
+ try await OpenIapModule.shared.syncIOS()
1041
+ }
822
1042
  RnIapLog.result("syncIOS", ok)
823
1043
  return ok
824
1044
  } catch let purchaseError as PurchaseError {
825
1045
  RnIapLog.failure("syncIOS", error: purchaseError)
826
1046
  throw OpenIapException.from(purchaseError)
1047
+ } catch let connectionError as OpenIapException {
1048
+ RnIapLog.failure("syncIOS", error: connectionError)
1049
+ throw connectionError
827
1050
  } catch {
828
1051
  RnIapLog.failure("syncIOS", error: error)
829
1052
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -833,10 +1056,11 @@ class HybridRnIap: HybridRnIapSpec {
833
1056
 
834
1057
  func showManageSubscriptionsIOS() throws -> Promise<[NitroPurchase]> {
835
1058
  return Promise.async {
836
- try self.ensureConnection()
837
1059
  do {
838
1060
  RnIapLog.payload("showManageSubscriptionsIOS", nil)
839
- let changedPurchases = try await OpenIapModule.shared.showManageSubscriptionsIOS()
1061
+ let changedPurchases = try await self.runConnectedOperation {
1062
+ try await OpenIapModule.shared.showManageSubscriptionsIOS()
1063
+ }
840
1064
  let unionPurchases = changedPurchases.map { OpenIAP.Purchase.purchaseIos($0) }
841
1065
  let payloads = RnIapHelper.sanitizeArray(try RnIapHelper.purchasesRequired(unionPurchases))
842
1066
  RnIapLog.result("showManageSubscriptionsIOS", payloads)
@@ -844,6 +1068,9 @@ class HybridRnIap: HybridRnIapSpec {
844
1068
  } catch let purchaseError as PurchaseError {
845
1069
  RnIapLog.failure("showManageSubscriptionsIOS", error: purchaseError)
846
1070
  throw OpenIapException.from(purchaseError)
1071
+ } catch let connectionError as OpenIapException {
1072
+ RnIapLog.failure("showManageSubscriptionsIOS", error: connectionError)
1073
+ throw connectionError
847
1074
  } catch {
848
1075
  RnIapLog.failure("showManageSubscriptionsIOS", error: error)
849
1076
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -853,15 +1080,19 @@ class HybridRnIap: HybridRnIapSpec {
853
1080
 
854
1081
  func deepLinkToSubscriptionsIOS() throws -> Promise<Bool> {
855
1082
  return Promise.async {
856
- try self.ensureConnection()
857
1083
  do {
858
1084
  RnIapLog.payload("deepLinkToSubscriptionsIOS", nil)
859
- try await OpenIapModule.shared.deepLinkToSubscriptions(nil)
1085
+ try await self.runConnectedOperation {
1086
+ try await OpenIapModule.shared.deepLinkToSubscriptions(nil)
1087
+ }
860
1088
  RnIapLog.result("deepLinkToSubscriptionsIOS", true)
861
1089
  return true
862
1090
  } catch let purchaseError as PurchaseError {
863
1091
  RnIapLog.failure("deepLinkToSubscriptionsIOS", error: purchaseError)
864
1092
  throw OpenIapException.from(purchaseError)
1093
+ } catch let connectionError as OpenIapException {
1094
+ RnIapLog.failure("deepLinkToSubscriptionsIOS", error: connectionError)
1095
+ throw connectionError
865
1096
  } catch {
866
1097
  RnIapLog.failure("deepLinkToSubscriptionsIOS", error: error)
867
1098
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -872,7 +1103,9 @@ class HybridRnIap: HybridRnIapSpec {
872
1103
  func isEligibleForIntroOfferIOS(groupID: String) throws -> Promise<Bool> {
873
1104
  return Promise.async {
874
1105
  RnIapLog.payload("isEligibleForIntroOfferIOS", ["groupID": groupID])
875
- let value = try await OpenIapModule.shared.isEligibleForIntroOfferIOS(groupID: groupID)
1106
+ let value = try await self.runConnectedOperation {
1107
+ try await OpenIapModule.shared.isEligibleForIntroOfferIOS(groupID: groupID)
1108
+ }
876
1109
  RnIapLog.result("isEligibleForIntroOfferIOS", value)
877
1110
  return value
878
1111
  }
@@ -880,15 +1113,19 @@ class HybridRnIap: HybridRnIapSpec {
880
1113
 
881
1114
  func getReceiptDataIOS() throws -> Promise<String> {
882
1115
  return Promise.async {
883
- try self.ensureConnection()
884
1116
  do {
885
1117
  RnIapLog.payload("getReceiptDataIOS", nil)
886
- let receipt = try await RnIapHelper.loadReceiptData(refresh: false)
1118
+ let receipt = try await self.runConnectedOperation {
1119
+ try await RnIapHelper.loadReceiptData(refresh: false)
1120
+ }
887
1121
  RnIapLog.result("getReceiptDataIOS", "<receipt>")
888
1122
  return receipt
889
1123
  } catch let purchaseError as PurchaseError {
890
1124
  RnIapLog.failure("getReceiptDataIOS", error: purchaseError)
891
1125
  throw OpenIapException.from(purchaseError)
1126
+ } catch let connectionError as OpenIapException {
1127
+ RnIapLog.failure("getReceiptDataIOS", error: connectionError)
1128
+ throw connectionError
892
1129
  } catch {
893
1130
  RnIapLog.failure("getReceiptDataIOS", error: error)
894
1131
  throw OpenIapException.make(code: .purchaseVerificationFailed, message: error.localizedDescription)
@@ -898,15 +1135,19 @@ class HybridRnIap: HybridRnIapSpec {
898
1135
 
899
1136
  func requestReceiptRefreshIOS() throws -> Promise<String> {
900
1137
  return Promise.async {
901
- try self.ensureConnection()
902
1138
  do {
903
1139
  RnIapLog.payload("requestReceiptRefreshIOS", nil)
904
- let receipt = try await RnIapHelper.loadReceiptData(refresh: true)
1140
+ let receipt = try await self.runConnectedOperation {
1141
+ try await RnIapHelper.loadReceiptData(refresh: true)
1142
+ }
905
1143
  RnIapLog.result("requestReceiptRefreshIOS", "<receipt>")
906
1144
  return receipt
907
1145
  } catch let purchaseError as PurchaseError {
908
1146
  RnIapLog.failure("requestReceiptRefreshIOS", error: purchaseError)
909
1147
  throw OpenIapException.from(purchaseError)
1148
+ } catch let connectionError as OpenIapException {
1149
+ RnIapLog.failure("requestReceiptRefreshIOS", error: connectionError)
1150
+ throw connectionError
910
1151
  } catch {
911
1152
  RnIapLog.failure("requestReceiptRefreshIOS", error: error)
912
1153
  throw OpenIapException.make(code: .purchaseVerificationFailed, message: error.localizedDescription)
@@ -916,9 +1157,10 @@ class HybridRnIap: HybridRnIapSpec {
916
1157
 
917
1158
  func isTransactionVerifiedIOS(sku: String) throws -> Promise<Bool> {
918
1159
  return Promise.async {
919
- try self.ensureConnection()
920
1160
  RnIapLog.payload("isTransactionVerifiedIOS", ["sku": sku])
921
- let value = try await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku)
1161
+ let value = try await self.runConnectedOperation {
1162
+ try await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku)
1163
+ }
922
1164
  RnIapLog.result("isTransactionVerifiedIOS", value)
923
1165
  return value
924
1166
  }
@@ -926,19 +1168,26 @@ class HybridRnIap: HybridRnIapSpec {
926
1168
 
927
1169
  func getTransactionJwsIOS(sku: String) throws -> Promise<Variant_NullType_String> {
928
1170
  return Promise.async {
929
- try self.ensureConnection()
930
1171
  do {
931
1172
  RnIapLog.payload("getTransactionJwsIOS", ["sku": sku])
932
- let jws = try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku)
1173
+ let jws = try await self.runConnectedOperation {
1174
+ try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku)
1175
+ }
933
1176
  let maskedJws: Any? = (jws == nil) ? nil : "<jws>"
934
1177
  RnIapLog.result("getTransactionJwsIOS", maskedJws)
935
1178
  if let jws {
936
1179
  return .second(jws)
937
1180
  }
938
1181
  return .first(.null)
1182
+ } catch let purchaseError as PurchaseError {
1183
+ RnIapLog.failure("getTransactionJwsIOS", error: purchaseError)
1184
+ throw OpenIapException.from(purchaseError)
1185
+ } catch let connectionError as OpenIapException {
1186
+ RnIapLog.failure("getTransactionJwsIOS", error: connectionError)
1187
+ throw connectionError
939
1188
  } catch {
940
1189
  RnIapLog.failure("getTransactionJwsIOS", error: error)
941
- throw OpenIapException.make(code: .transactionValidationFailed, message: "Can't find transaction for sku \(sku)")
1190
+ throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
942
1191
  }
943
1192
  }
944
1193
  }
@@ -947,15 +1196,23 @@ class HybridRnIap: HybridRnIapSpec {
947
1196
  return Promise.async {
948
1197
  do {
949
1198
  RnIapLog.payload("beginRefundRequestIOS", ["sku": sku])
950
- let result = try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku)
1199
+ let result = try await self.runConnectedOperation {
1200
+ try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku)
1201
+ }
951
1202
  RnIapLog.result("beginRefundRequestIOS", result)
952
1203
  if let result {
953
1204
  return .second(result)
954
1205
  }
955
1206
  return .first(.null)
1207
+ } catch let purchaseError as PurchaseError {
1208
+ RnIapLog.failure("beginRefundRequestIOS", error: purchaseError)
1209
+ throw OpenIapException.from(purchaseError)
1210
+ } catch let connectionError as OpenIapException {
1211
+ RnIapLog.failure("beginRefundRequestIOS", error: connectionError)
1212
+ throw connectionError
956
1213
  } catch {
957
1214
  RnIapLog.failure("beginRefundRequestIOS", error: error)
958
- return .first(.null)
1215
+ throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
959
1216
  }
960
1217
  }
961
1218
  }
@@ -972,7 +1229,9 @@ class HybridRnIap: HybridRnIapSpec {
972
1229
  Task {
973
1230
  guard self.isCurrentConnection(epoch) else { return }
974
1231
  RnIapLog.payload("promotedProductListenerIOS.fetch", nil)
975
- if let product = try? await OpenIapModule.shared.getPromotedProductIOS() {
1232
+ if let product = try? await self.enqueueConnectedOperation({
1233
+ try await OpenIapModule.shared.getPromotedProductIOS()
1234
+ }).value {
976
1235
  guard self.isCurrentConnection(epoch) else { return }
977
1236
  let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(product))
978
1237
  RnIapLog.result("promotedProductListenerIOS.fetch", payload)
@@ -994,7 +1253,7 @@ class HybridRnIap: HybridRnIapSpec {
994
1253
  ) throws -> Double {
995
1254
  let dedupeTransactionIOS = purchaseUpdatedDedupeTransactionIOS(from: options)
996
1255
  let receiveDuplicateTransactionUpdatesIOS = !dedupeTransactionIOS
997
- let (token, epoch) = listenerLock.withLock {
1256
+ let (token, epoch, shouldFlush, flushEpoch) = listenerLock.withLock {
998
1257
  let token = nextPurchaseUpdatedListenerToken
999
1258
  nextPurchaseUpdatedListenerToken += 1
1000
1259
 
@@ -1008,9 +1267,26 @@ class HybridRnIap: HybridRnIapSpec {
1008
1267
  purchaseUpdatedListeners.append((token: token, listener: listener))
1009
1268
  }
1010
1269
  purchaseUpdatedListenerRegistrations.append(registration)
1011
- return (token, isInitialized ? connectionEpoch : nil)
1270
+ let shouldFlush: Bool
1271
+ if receiveDuplicateTransactionUpdatesIOS {
1272
+ shouldFlush = pendingDuplicatePurchaseUpdates.beginFlushIfNeeded()
1273
+ } else {
1274
+ shouldFlush = pendingPurchaseUpdates.beginFlushIfNeeded()
1275
+ }
1276
+ return (
1277
+ token,
1278
+ isInitialized ? connectionEpoch : nil,
1279
+ shouldFlush,
1280
+ connectionEpoch
1281
+ )
1012
1282
  }
1013
1283
 
1284
+ if shouldFlush {
1285
+ schedulePendingPurchaseUpdateFlush(
1286
+ includeDuplicateListeners: receiveDuplicateTransactionUpdatesIOS,
1287
+ expectedEpoch: flushEpoch
1288
+ )
1289
+ }
1014
1290
  if let epoch {
1015
1291
  if receiveDuplicateTransactionUpdatesIOS {
1016
1292
  attachDuplicatePurchaseUpdatedSubIfNeeded(expectedEpoch: epoch)
@@ -1032,9 +1308,13 @@ class HybridRnIap: HybridRnIapSpec {
1032
1308
  }
1033
1309
 
1034
1310
  func addPurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws {
1035
- let epoch = listenerLock.withLock {
1311
+ let (epoch, shouldFlush, flushEpoch) = listenerLock.withLock {
1036
1312
  purchaseErrorListeners.append(listener)
1037
- return isInitialized ? connectionEpoch : nil
1313
+ let shouldFlush = pendingPurchaseErrors.beginFlushIfNeeded()
1314
+ return (isInitialized ? connectionEpoch : nil, shouldFlush, connectionEpoch)
1315
+ }
1316
+ if shouldFlush {
1317
+ schedulePendingPurchaseErrorFlush(expectedEpoch: flushEpoch)
1038
1318
  }
1039
1319
  if let epoch {
1040
1320
  attachPurchaseErrorSubIfNeeded(expectedEpoch: epoch)
@@ -1120,18 +1400,289 @@ class HybridRnIap: HybridRnIapSpec {
1120
1400
  }
1121
1401
  }
1122
1402
 
1123
- private func attachListenersIfNeeded(epoch: UInt64) {
1403
+ func enqueueConnectedOperation<T>(
1404
+ _ operation: @escaping () async throws -> T
1405
+ ) -> Task<T, Error> {
1406
+ enqueueLifecycleOperation {
1407
+ guard self.isConnectionInitialized() else {
1408
+ throw OpenIapException.make(
1409
+ code: .initConnection,
1410
+ message: "Connection ended before the store operation started."
1411
+ )
1412
+ }
1413
+ return try await operation()
1414
+ }
1415
+ }
1416
+
1417
+ private func runConnectedOperation<T>(
1418
+ _ operation: @escaping () async throws -> T
1419
+ ) async throws -> T {
1420
+ try await ensureConnection()
1421
+ return try await enqueueConnectedOperation(operation).value
1422
+ }
1423
+
1424
+ func currentConnectionEpoch() -> UInt64 {
1425
+ listenerLock.withLock { connectionEpoch }
1426
+ }
1427
+
1428
+ func runRequestPurchaseOperation(
1429
+ _ operation: @escaping () async throws -> OpenIAP.RequestPurchaseResult?
1430
+ ) async throws -> OpenIAP.RequestPurchaseResult? {
1431
+ try await runConnectedOperation {
1432
+ let epoch = self.currentConnectionEpoch()
1433
+ do {
1434
+ let result = try await operation()
1435
+ await self.deliverRequestPurchaseResultIfNeeded(
1436
+ result,
1437
+ expectedEpoch: epoch
1438
+ )
1439
+ return result
1440
+ } catch let purchaseError as PurchaseError {
1441
+ self.deliverRequestPurchaseError(purchaseError)
1442
+ throw purchaseError
1443
+ }
1444
+ }
1445
+ }
1446
+
1447
+ private func claimPurchaseUpdateDelivery(
1448
+ transactionId: String,
1449
+ expectedEpoch: UInt64
1450
+ ) -> Bool {
1451
+ listenerLock.withLock {
1452
+ guard isInitialized, connectionEpoch == expectedEpoch else { return false }
1453
+ return deliveredPurchaseUpdateIds.insert(transactionId).inserted
1454
+ }
1455
+ }
1456
+
1457
+ private func claimRequestPurchaseDelivery(
1458
+ transactionId: String,
1459
+ expectedEpoch: UInt64
1460
+ ) -> (deduping: Bool, nonDeduping: Bool) {
1461
+ listenerLock.withLock {
1462
+ guard isInitialized, connectionEpoch == expectedEpoch else {
1463
+ return (false, false)
1464
+ }
1465
+ let deduping = deliveredPurchaseUpdateIds.insert(transactionId).inserted
1466
+ let nonDeduping = !purchaseUpdatedDuplicateListeners.isEmpty
1467
+ if nonDeduping {
1468
+ pendingDuplicatePurchaseUpdateSuppressions[transactionId, default: 0] += 1
1469
+ }
1470
+ return (deduping, nonDeduping)
1471
+ }
1472
+ }
1473
+
1474
+ private func claimDuplicatePurchaseUpdateDelivery(
1475
+ transactionId: String,
1476
+ expectedEpoch: UInt64
1477
+ ) -> Bool {
1478
+ listenerLock.withLock {
1479
+ guard isInitialized, connectionEpoch == expectedEpoch else { return false }
1480
+ if let count = pendingDuplicatePurchaseUpdateSuppressions[transactionId] {
1481
+ if count == 1 {
1482
+ pendingDuplicatePurchaseUpdateSuppressions.removeValue(forKey: transactionId)
1483
+ } else {
1484
+ pendingDuplicatePurchaseUpdateSuppressions[transactionId] = count - 1
1485
+ }
1486
+ return false
1487
+ }
1488
+ return true
1489
+ }
1490
+ }
1491
+
1492
+ private func deliverRequestPurchaseResultIfNeeded(
1493
+ _ result: OpenIAP.RequestPurchaseResult?,
1494
+ expectedEpoch: UInt64
1495
+ ) async {
1496
+ guard let result else { return }
1497
+ let purchases: [OpenIAP.Purchase]
1498
+ switch result {
1499
+ case .purchase(let purchase):
1500
+ purchases = purchase.map { [$0] } ?? []
1501
+ case .purchases(let values):
1502
+ purchases = values ?? []
1503
+ }
1504
+
1505
+ for purchase in purchases {
1506
+ let rawPayload = OpenIapSerialization.purchase(purchase)
1507
+ guard let transactionId = rawPayload["id"] as? String else { continue }
1508
+ let delivery = claimRequestPurchaseDelivery(
1509
+ transactionId: transactionId,
1510
+ expectedEpoch: expectedEpoch
1511
+ )
1512
+ guard delivery.deduping || delivery.nonDeduping else { continue }
1513
+ let payload = RnIapHelper.sanitizeDictionary(rawPayload)
1514
+ let nitro = RnIapHelper.convertPurchaseDictionary(payload)
1515
+ await MainActor.run {
1516
+ guard self.isCurrentConnection(expectedEpoch) else { return }
1517
+ self.purchasePayloadById[transactionId] = rawPayload
1518
+ if delivery.deduping {
1519
+ self.sendPurchaseUpdate(nitro, includeDuplicateListeners: false)
1520
+ }
1521
+ if delivery.nonDeduping {
1522
+ self.sendPurchaseUpdate(nitro, includeDuplicateListeners: true)
1523
+ }
1524
+ }
1525
+ }
1526
+ }
1527
+
1528
+ private func deliverPurchaseUpdateIfNeeded(
1529
+ _ purchase: OpenIAP.Purchase,
1530
+ expectedEpoch: UInt64
1531
+ ) async {
1532
+ let rawPayload = OpenIapSerialization.purchase(purchase)
1533
+ if let transactionId = rawPayload["id"] as? String,
1534
+ !claimPurchaseUpdateDelivery(
1535
+ transactionId: transactionId,
1536
+ expectedEpoch: expectedEpoch
1537
+ ) {
1538
+ return
1539
+ }
1540
+ let payload = RnIapHelper.sanitizeDictionary(rawPayload)
1541
+ let nitro = RnIapHelper.convertPurchaseDictionary(payload)
1542
+ await MainActor.run {
1543
+ guard self.isCurrentConnection(expectedEpoch) else { return }
1544
+ RnIapLog.result("purchaseUpdatedListener", payload)
1545
+ if let transactionId = rawPayload["id"] as? String {
1546
+ self.purchasePayloadById[transactionId] = rawPayload
1547
+ }
1548
+ self.sendPurchaseUpdate(nitro, includeDuplicateListeners: false)
1549
+ }
1550
+ }
1551
+
1552
+ private func deliverDuplicatePurchaseUpdateIfNeeded(
1553
+ _ purchase: OpenIAP.Purchase,
1554
+ expectedEpoch: UInt64
1555
+ ) async {
1556
+ let rawPayload = OpenIapSerialization.purchase(purchase)
1557
+ if let transactionId = rawPayload["id"] as? String,
1558
+ !claimDuplicatePurchaseUpdateDelivery(
1559
+ transactionId: transactionId,
1560
+ expectedEpoch: expectedEpoch
1561
+ ) {
1562
+ return
1563
+ }
1564
+ let payload = RnIapHelper.sanitizeDictionary(rawPayload)
1565
+ let nitro = RnIapHelper.convertPurchaseDictionary(payload)
1566
+ await MainActor.run {
1567
+ guard self.isCurrentConnection(expectedEpoch) else { return }
1568
+ RnIapLog.result("purchaseUpdatedListener.duplicates", payload)
1569
+ if let transactionId = rawPayload["id"] as? String {
1570
+ self.purchasePayloadById[transactionId] = rawPayload
1571
+ }
1572
+ self.sendPurchaseUpdate(nitro, includeDuplicateListeners: true)
1573
+ }
1574
+ }
1575
+
1576
+ func enqueuePurchaseUpdateDelivery(
1577
+ _ purchase: OpenIAP.Purchase,
1578
+ expectedEpoch: UInt64,
1579
+ includeDuplicateListeners: Bool
1580
+ ) -> Task<Void, Error> {
1581
+ enqueueConnectedOperation {
1582
+ if includeDuplicateListeners {
1583
+ await self.deliverDuplicatePurchaseUpdateIfNeeded(
1584
+ purchase,
1585
+ expectedEpoch: expectedEpoch
1586
+ )
1587
+ } else {
1588
+ await self.deliverPurchaseUpdateIfNeeded(
1589
+ purchase,
1590
+ expectedEpoch: expectedEpoch
1591
+ )
1592
+ }
1593
+ }
1594
+ }
1595
+
1596
+ private func deliverRequestPurchaseError(_ error: PurchaseError) {
1597
+ let result = RnIapHelper.makePurchaseErrorResult(
1598
+ code: error.code,
1599
+ message: error.message,
1600
+ error.productId,
1601
+ debugMessage: error.debugMessage
1602
+ )
1603
+ let key = RnIapHelper.makeErrorDedupKey(
1604
+ code: error.code.rawValue,
1605
+ productId: error.productId
1606
+ )
1607
+ listenerLock.withLock {
1608
+ pendingRequestPurchaseErrorSuppressions[key, default: 0] += 1
1609
+ }
1610
+ sendPurchaseError(result, productId: error.productId, dedupe: false)
1611
+ }
1612
+
1613
+ private func consumeRequestPurchaseErrorSuppression(_ error: PurchaseError) -> Bool {
1614
+ let key = RnIapHelper.makeErrorDedupKey(
1615
+ code: error.code.rawValue,
1616
+ productId: error.productId
1617
+ )
1618
+ return listenerLock.withLock {
1619
+ guard let count = pendingRequestPurchaseErrorSuppressions[key] else {
1620
+ return false
1621
+ }
1622
+ if count == 1 {
1623
+ pendingRequestPurchaseErrorSuppressions.removeValue(forKey: key)
1624
+ } else {
1625
+ pendingRequestPurchaseErrorSuppressions[key] = count - 1
1626
+ }
1627
+ return true
1628
+ }
1629
+ }
1630
+
1631
+ func enqueuePurchaseErrorDelivery(
1632
+ _ error: PurchaseError,
1633
+ expectedEpoch: UInt64
1634
+ ) -> Task<Void, Error> {
1635
+ return enqueueLifecycleOperation {
1636
+ guard self.isCurrentEpoch(expectedEpoch),
1637
+ !self.consumeOnDemandInitErrorSuppression(error),
1638
+ !self.consumeRequestPurchaseErrorSuppression(error) else {
1639
+ return
1640
+ }
1641
+ let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(error))
1642
+ let nitroError = RnIapHelper.makePurchaseErrorResult(
1643
+ code: error.code,
1644
+ message: error.message,
1645
+ error.productId,
1646
+ debugMessage: error.debugMessage
1647
+ )
1648
+ await MainActor.run {
1649
+ guard self.isCurrentEpoch(expectedEpoch) else { return }
1650
+ RnIapLog.result("purchaseErrorListener", payload)
1651
+ self.sendPurchaseError(nitroError, productId: error.productId)
1652
+ }
1653
+ }
1654
+ }
1655
+
1656
+ private func consumeOnDemandInitErrorSuppression(_ error: PurchaseError) -> Bool {
1657
+ listenerLock.withLock {
1658
+ let key = error.code.rawValue
1659
+ guard let count = pendingOnDemandInitErrorSuppressions[key] else {
1660
+ return false
1661
+ }
1662
+ if count == 1 {
1663
+ pendingOnDemandInitErrorSuppressions.removeValue(forKey: key)
1664
+ } else {
1665
+ pendingOnDemandInitErrorSuppressions[key] = count - 1
1666
+ }
1667
+ return true
1668
+ }
1669
+ }
1670
+
1671
+ private func attachCoreListenersIfNeeded(epoch: UInt64) {
1124
1672
  attachPurchaseUpdatedSubIfNeeded(expectedEpoch: epoch)
1125
1673
  attachDuplicatePurchaseUpdatedSubIfNeeded(expectedEpoch: epoch)
1126
1674
  attachPurchaseErrorSubIfNeeded(expectedEpoch: epoch)
1675
+ }
1676
+
1677
+ private func attachListenersIfNeeded(epoch: UInt64) {
1678
+ attachCoreListenersIfNeeded(epoch: epoch)
1127
1679
  attachPromotedProductSubIfNeeded(expectedEpoch: epoch)
1128
1680
  attachSubscriptionBillingIssueSubIfNeeded(expectedEpoch: epoch)
1129
1681
  }
1130
1682
 
1131
1683
  private func attachPurchaseErrorSubIfNeeded(expectedEpoch: UInt64) {
1132
1684
  listenerLock.withLock {
1133
- guard isInitialized,
1134
- connectionEpoch == expectedEpoch,
1685
+ guard connectionEpoch == expectedEpoch,
1135
1686
  purchaseErrorSub == nil else { return }
1136
1687
  RnIapLog.payload("purchaseErrorListener.register", nil)
1137
1688
  purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { [weak self] error in
@@ -1139,17 +1690,12 @@ class HybridRnIap: HybridRnIapSpec {
1139
1690
  RnIapLog.warn("purchaseErrorListener: HybridRnIap deallocated, error event dropped")
1140
1691
  return
1141
1692
  }
1142
- Task { @MainActor in
1143
- guard self.isCurrentConnection(expectedEpoch) else { return }
1144
- let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(error))
1145
- RnIapLog.result("purchaseErrorListener", payload)
1146
- let nitroError = RnIapHelper.makePurchaseErrorResult(
1147
- code: error.code,
1148
- message: error.message,
1149
- error.productId,
1150
- debugMessage: error.debugMessage
1151
- )
1152
- self.sendPurchaseError(nitroError, productId: error.productId)
1693
+ let operation = self.enqueuePurchaseErrorDelivery(
1694
+ error,
1695
+ expectedEpoch: expectedEpoch
1696
+ )
1697
+ Task {
1698
+ _ = try? await operation.value
1153
1699
  }
1154
1700
  }
1155
1701
  RnIapLog.result("purchaseErrorListener.register", "attached")
@@ -1172,7 +1718,9 @@ class HybridRnIap: HybridRnIapSpec {
1172
1718
  RnIapLog.payload("promotedProductListenerIOS", ["productId": productId])
1173
1719
  do {
1174
1720
  let request = try OpenIapSerialization.productRequest(skus: [productId], type: .all)
1175
- let result = try await OpenIapModule.shared.fetchProducts(request)
1721
+ let result = try await self.enqueueConnectedOperation {
1722
+ try await OpenIapModule.shared.fetchProducts(request)
1723
+ }.value
1176
1724
  let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.products(result))
1177
1725
  RnIapLog.result("fetchProducts", payloads)
1178
1726
  if let payload = payloads.first {
@@ -1201,8 +1749,7 @@ class HybridRnIap: HybridRnIapSpec {
1201
1749
 
1202
1750
  private func attachPurchaseUpdatedSubIfNeeded(expectedEpoch: UInt64) {
1203
1751
  listenerLock.withLock {
1204
- guard isInitialized,
1205
- connectionEpoch == expectedEpoch,
1752
+ guard connectionEpoch == expectedEpoch,
1206
1753
  purchaseUpdatedSub == nil else { return }
1207
1754
  RnIapLog.payload("purchaseUpdatedListener.register", nil)
1208
1755
  purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in
@@ -1210,16 +1757,13 @@ class HybridRnIap: HybridRnIapSpec {
1210
1757
  RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, purchase event dropped")
1211
1758
  return
1212
1759
  }
1213
- Task { @MainActor in
1214
- guard self.isCurrentConnection(expectedEpoch) else { return }
1215
- let rawPayload = OpenIapSerialization.purchase(openIapPurchase)
1216
- let payload = RnIapHelper.sanitizeDictionary(rawPayload)
1217
- RnIapLog.result("purchaseUpdatedListener", payload)
1218
- if let identifier = rawPayload["id"] as? String {
1219
- self.purchasePayloadById[identifier] = rawPayload
1220
- }
1221
- let nitro = RnIapHelper.convertPurchaseDictionary(payload)
1222
- self.sendPurchaseUpdate(nitro, includeDuplicateListeners: false)
1760
+ let operation = self.enqueuePurchaseUpdateDelivery(
1761
+ openIapPurchase,
1762
+ expectedEpoch: expectedEpoch,
1763
+ includeDuplicateListeners: false
1764
+ )
1765
+ Task {
1766
+ _ = try? await operation.value
1223
1767
  }
1224
1768
  }
1225
1769
  RnIapLog.result("purchaseUpdatedListener.register", "attached")
@@ -1228,8 +1772,7 @@ class HybridRnIap: HybridRnIapSpec {
1228
1772
 
1229
1773
  private func attachDuplicatePurchaseUpdatedSubIfNeeded(expectedEpoch: UInt64) {
1230
1774
  listenerLock.withLock {
1231
- guard isInitialized,
1232
- connectionEpoch == expectedEpoch,
1775
+ guard connectionEpoch == expectedEpoch,
1233
1776
  purchaseUpdatedDuplicateSub == nil,
1234
1777
  !purchaseUpdatedDuplicateListeners.isEmpty else { return }
1235
1778
  RnIapLog.payload("purchaseUpdatedListener.register.duplicates", nil)
@@ -1241,16 +1784,13 @@ class HybridRnIap: HybridRnIapSpec {
1241
1784
  RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, non-deduping purchase event dropped")
1242
1785
  return
1243
1786
  }
1244
- Task { @MainActor in
1245
- guard self.isCurrentConnection(expectedEpoch) else { return }
1246
- let rawPayload = OpenIapSerialization.purchase(openIapPurchase)
1247
- let payload = RnIapHelper.sanitizeDictionary(rawPayload)
1248
- RnIapLog.result("purchaseUpdatedListener.duplicates", payload)
1249
- if let identifier = rawPayload["id"] as? String {
1250
- self.purchasePayloadById[identifier] = rawPayload
1251
- }
1252
- let nitro = RnIapHelper.convertPurchaseDictionary(payload)
1253
- self.sendPurchaseUpdate(nitro, includeDuplicateListeners: true)
1787
+ let operation = self.enqueuePurchaseUpdateDelivery(
1788
+ openIapPurchase,
1789
+ expectedEpoch: expectedEpoch,
1790
+ includeDuplicateListeners: true
1791
+ )
1792
+ Task {
1793
+ _ = try? await operation.value
1254
1794
  }
1255
1795
  }, options: options)
1256
1796
  RnIapLog.result("purchaseUpdatedListener.register.duplicates", "attached")
@@ -1284,12 +1824,6 @@ class HybridRnIap: HybridRnIapSpec {
1284
1824
  }
1285
1825
  }
1286
1826
 
1287
- private func ensureConnection() throws {
1288
- guard isConnectionInitialized() else {
1289
- throw OpenIapException.make(code: .initConnection, message: "Connection not initialized. Call initConnection() first.")
1290
- }
1291
- }
1292
-
1293
1827
  private func isConnectionInitialized() -> Bool {
1294
1828
  listenerLock.withLock { isInitialized }
1295
1829
  }
@@ -1297,13 +1831,29 @@ class HybridRnIap: HybridRnIapSpec {
1297
1831
  private func isCurrentConnection(_ epoch: UInt64) -> Bool {
1298
1832
  listenerLock.withLock { isInitialized && connectionEpoch == epoch }
1299
1833
  }
1834
+
1835
+ private func isCurrentEpoch(_ epoch: UInt64) -> Bool {
1836
+ listenerLock.withLock { connectionEpoch == epoch }
1837
+ }
1300
1838
 
1301
1839
  private func sendPurchaseUpdate(_ purchase: NitroPurchase, includeDuplicateListeners: Bool) {
1302
1840
  let snapshot: [(NitroPurchase) -> Void] = listenerLock.withLock {
1303
1841
  if includeDuplicateListeners {
1842
+ if pendingDuplicatePurchaseUpdates.enqueueIfNeeded(
1843
+ hasListeners: !purchaseUpdatedDuplicateListeners.isEmpty,
1844
+ event: purchase
1845
+ ) {
1846
+ return []
1847
+ }
1304
1848
  return purchaseUpdatedDuplicateListeners.map(\.listener)
1305
1849
  }
1306
1850
 
1851
+ if pendingPurchaseUpdates.enqueueIfNeeded(
1852
+ hasListeners: !purchaseUpdatedListeners.isEmpty,
1853
+ event: purchase
1854
+ ) {
1855
+ return []
1856
+ }
1307
1857
  return purchaseUpdatedListeners.map(\.listener)
1308
1858
  }
1309
1859
 
@@ -1312,25 +1862,16 @@ class HybridRnIap: HybridRnIapSpec {
1312
1862
  }
1313
1863
  }
1314
1864
 
1315
- private func sendPurchaseError(_ error: NitroPurchaseResult, productId: String? = nil) {
1865
+ private func sendPurchaseError(
1866
+ _ error: NitroPurchaseResult,
1867
+ productId: String? = nil,
1868
+ dedupe: Bool = true
1869
+ ) {
1316
1870
  let dedupIdentifier = productId
1317
1871
  ?? (error.purchaseToken?.isEmpty == false ? error.purchaseToken : nil)
1318
1872
  ?? (error.message.isEmpty ? nil : error.message)
1319
1873
  let currentKey = RnIapHelper.makeErrorDedupKey(code: error.code, productId: dedupIdentifier)
1320
1874
 
1321
- // Protect error dedup state since sendPurchaseError is called from multiple threads
1322
- let shouldSkip: Bool = listenerLock.withLock {
1323
- let now = Date().timeIntervalSince1970
1324
- let withinWindow = (now - lastPurchaseErrorTimestamp) < 0.15
1325
- if currentKey == lastPurchaseErrorKey && withinWindow {
1326
- return true
1327
- }
1328
- lastPurchaseErrorKey = currentKey
1329
- lastPurchaseErrorTimestamp = now
1330
- return false
1331
- }
1332
- if shouldSkip { return }
1333
-
1334
1875
  // Ensure we never leak SKU via purchaseToken
1335
1876
  let sanitized: NitroPurchaseResult
1336
1877
  if let pid = productId, error.purchaseToken == pid {
@@ -1349,12 +1890,104 @@ class HybridRnIap: HybridRnIapSpec {
1349
1890
  } else {
1350
1891
  sanitized = error
1351
1892
  }
1352
- let snapshot = listenerLock.withLock { Array(purchaseErrorListeners) }
1893
+
1894
+ // Protect error dedup state since sendPurchaseError is called from multiple threads
1895
+ let snapshot: [(NitroPurchaseResult) -> Void]? = listenerLock.withLock {
1896
+ if dedupe {
1897
+ let now = Date().timeIntervalSince1970
1898
+ let withinWindow = (now - lastPurchaseErrorTimestamp) < 0.15
1899
+ if currentKey == lastPurchaseErrorKey && withinWindow {
1900
+ return nil
1901
+ }
1902
+ lastPurchaseErrorKey = currentKey
1903
+ lastPurchaseErrorTimestamp = now
1904
+ }
1905
+ if pendingPurchaseErrors.enqueueIfNeeded(
1906
+ hasListeners: !purchaseErrorListeners.isEmpty,
1907
+ event: sanitized
1908
+ ) {
1909
+ return []
1910
+ }
1911
+ return Array(purchaseErrorListeners)
1912
+ }
1913
+ guard let snapshot else { return }
1353
1914
  for listener in snapshot {
1354
1915
  listener(sanitized)
1355
1916
  }
1356
1917
  }
1357
1918
 
1919
+ private func schedulePendingPurchaseUpdateFlush(
1920
+ includeDuplicateListeners: Bool,
1921
+ expectedEpoch: UInt64
1922
+ ) {
1923
+ let operation: Task<Void, Error> = enqueueLifecycleOperation {
1924
+ await self.flushPendingPurchaseUpdates(
1925
+ includeDuplicateListeners: includeDuplicateListeners,
1926
+ expectedEpoch: expectedEpoch
1927
+ )
1928
+ }
1929
+ Task { _ = try? await operation.value }
1930
+ }
1931
+
1932
+ private func flushPendingPurchaseUpdates(
1933
+ includeDuplicateListeners: Bool,
1934
+ expectedEpoch: UInt64
1935
+ ) async {
1936
+ while true {
1937
+ let delivery: (events: [NitroPurchase], listeners: [(NitroPurchase) -> Void])? = listenerLock.withLock {
1938
+ guard connectionEpoch == expectedEpoch else { return nil }
1939
+ let listeners = includeDuplicateListeners
1940
+ ? purchaseUpdatedDuplicateListeners.map(\.listener)
1941
+ : purchaseUpdatedListeners.map(\.listener)
1942
+ let buffer = includeDuplicateListeners
1943
+ ? pendingDuplicatePurchaseUpdates
1944
+ : pendingPurchaseUpdates
1945
+ guard let events = buffer.takeBatchOrFinish(
1946
+ hasListeners: !listeners.isEmpty
1947
+ ) else { return nil }
1948
+ return (events, listeners)
1949
+ }
1950
+ guard let delivery else { return }
1951
+ for event in delivery.events {
1952
+ for listener in delivery.listeners {
1953
+ guard isCurrentEpoch(expectedEpoch) else { return }
1954
+ await MainActor.run { listener(event) }
1955
+ }
1956
+ }
1957
+ }
1958
+ }
1959
+
1960
+ private func schedulePendingPurchaseErrorFlush(expectedEpoch: UInt64) {
1961
+ let operation: Task<Void, Error> = enqueueLifecycleOperation {
1962
+ await self.flushPendingPurchaseErrors(expectedEpoch: expectedEpoch)
1963
+ }
1964
+ Task { _ = try? await operation.value }
1965
+ }
1966
+
1967
+ private func flushPendingPurchaseErrors(expectedEpoch: UInt64) async {
1968
+ while true {
1969
+ let delivery: (events: [NitroPurchaseResult], listeners: [(NitroPurchaseResult) -> Void])? = listenerLock.withLock {
1970
+ guard connectionEpoch == expectedEpoch else { return nil }
1971
+ let listeners = Array(purchaseErrorListeners)
1972
+ guard let events = pendingPurchaseErrors.takeBatchOrFinish(
1973
+ hasListeners: !listeners.isEmpty
1974
+ ) else { return nil }
1975
+ return (events, listeners)
1976
+ }
1977
+ guard let delivery else { return }
1978
+ for event in delivery.events {
1979
+ for listener in delivery.listeners {
1980
+ guard isCurrentEpoch(expectedEpoch) else { return }
1981
+ await MainActor.run { listener(event) }
1982
+ }
1983
+ }
1984
+ }
1985
+ }
1986
+
1987
+ func enqueueLifecycleBarrier() -> Task<Void, Error> {
1988
+ enqueueLifecycleOperation {}
1989
+ }
1990
+
1358
1991
  private func sendPurchaseErrorDedup(_ error: NitroPurchaseResult, productId: String? = nil) {
1359
1992
  sendPurchaseError(error, productId: productId)
1360
1993
  }
@@ -1394,6 +2027,13 @@ class HybridRnIap: HybridRnIapSpec {
1394
2027
  subscriptionBillingIssueListeners.removeAll()
1395
2028
  lastPurchaseErrorKey = nil
1396
2029
  lastPurchaseErrorTimestamp = 0
2030
+ deliveredPurchaseUpdateIds.removeAll()
2031
+ pendingDuplicatePurchaseUpdateSuppressions.removeAll()
2032
+ pendingRequestPurchaseErrorSuppressions.removeAll()
2033
+ pendingOnDemandInitErrorSuppressions.removeAll()
2034
+ pendingPurchaseUpdates.clear()
2035
+ pendingDuplicatePurchaseUpdates.clear()
2036
+ pendingPurchaseErrors.clear()
1397
2037
  return subscriptions
1398
2038
  }
1399
2039
  }
@@ -1484,14 +2124,18 @@ class HybridRnIap: HybridRnIapSpec {
1484
2124
  RnIapLog.payload("canPresentExternalPurchaseNoticeIOS", nil)
1485
2125
 
1486
2126
  if #available(iOS 16.0, *) {
1487
- try self.ensureConnection()
1488
2127
  do {
1489
- let canPresent = try await OpenIapModule.shared.canPresentExternalPurchaseNoticeIOS()
2128
+ let canPresent = try await self.runConnectedOperation {
2129
+ try await OpenIapModule.shared.canPresentExternalPurchaseNoticeIOS()
2130
+ }
1490
2131
  RnIapLog.result("canPresentExternalPurchaseNoticeIOS", canPresent)
1491
2132
  return canPresent
1492
2133
  } catch let purchaseError as PurchaseError {
1493
2134
  RnIapLog.failure("canPresentExternalPurchaseNoticeIOS", error: purchaseError)
1494
2135
  throw OpenIapException.from(purchaseError)
2136
+ } catch let connectionError as OpenIapException {
2137
+ RnIapLog.failure("canPresentExternalPurchaseNoticeIOS", error: connectionError)
2138
+ throw connectionError
1495
2139
  } catch {
1496
2140
  RnIapLog.failure("canPresentExternalPurchaseNoticeIOS", error: error)
1497
2141
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -1509,9 +2153,10 @@ class HybridRnIap: HybridRnIapSpec {
1509
2153
  RnIapLog.payload("presentExternalPurchaseNoticeSheetIOS", nil)
1510
2154
 
1511
2155
  if #available(iOS 16.0, *) {
1512
- try self.ensureConnection()
1513
2156
  do {
1514
- let result = try await OpenIapModule.shared.presentExternalPurchaseNoticeSheetIOS()
2157
+ let result = try await self.runConnectedOperation {
2158
+ try await OpenIapModule.shared.presentExternalPurchaseNoticeSheetIOS()
2159
+ }
1515
2160
 
1516
2161
  // Convert OpenIAP action to Nitro action via raw value
1517
2162
  let actionString = result.result.rawValue
@@ -1533,6 +2178,9 @@ class HybridRnIap: HybridRnIapSpec {
1533
2178
  } catch let purchaseError as PurchaseError {
1534
2179
  RnIapLog.failure("presentExternalPurchaseNoticeSheetIOS", error: purchaseError)
1535
2180
  throw OpenIapException.from(purchaseError)
2181
+ } catch let connectionError as OpenIapException {
2182
+ RnIapLog.failure("presentExternalPurchaseNoticeSheetIOS", error: connectionError)
2183
+ throw connectionError
1536
2184
  } catch {
1537
2185
  RnIapLog.failure("presentExternalPurchaseNoticeSheetIOS", error: error)
1538
2186
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -1550,9 +2198,10 @@ class HybridRnIap: HybridRnIapSpec {
1550
2198
  RnIapLog.payload("presentExternalPurchaseLinkIOS", ["url": url])
1551
2199
 
1552
2200
  if #available(iOS 16.0, *) {
1553
- try self.ensureConnection()
1554
2201
  do {
1555
- let result = try await OpenIapModule.shared.presentExternalPurchaseLinkIOS(url)
2202
+ let result = try await self.runConnectedOperation {
2203
+ try await OpenIapModule.shared.presentExternalPurchaseLinkIOS(url)
2204
+ }
1556
2205
  let nitroResult = ExternalPurchaseLinkResultIOS(
1557
2206
  error: RnIapHelper.wrapString(result.error),
1558
2207
  success: result.success
@@ -1562,6 +2211,9 @@ class HybridRnIap: HybridRnIapSpec {
1562
2211
  } catch let purchaseError as PurchaseError {
1563
2212
  RnIapLog.failure("presentExternalPurchaseLinkIOS", error: purchaseError)
1564
2213
  throw OpenIapException.from(purchaseError)
2214
+ } catch let connectionError as OpenIapException {
2215
+ RnIapLog.failure("presentExternalPurchaseLinkIOS", error: connectionError)
2216
+ throw connectionError
1565
2217
  } catch {
1566
2218
  RnIapLog.failure("presentExternalPurchaseLinkIOS", error: error)
1567
2219
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -1580,12 +2232,17 @@ class HybridRnIap: HybridRnIapSpec {
1580
2232
  return Promise.async {
1581
2233
  RnIapLog.payload("isEligibleForExternalPurchaseCustomLinkIOS", nil)
1582
2234
  do {
1583
- let isEligible = try await OpenIapModule.shared.isEligibleForExternalPurchaseCustomLinkIOS()
2235
+ let isEligible = try await self.runConnectedOperation {
2236
+ try await OpenIapModule.shared.isEligibleForExternalPurchaseCustomLinkIOS()
2237
+ }
1584
2238
  RnIapLog.result("isEligibleForExternalPurchaseCustomLinkIOS", isEligible)
1585
2239
  return isEligible
1586
2240
  } catch let purchaseError as PurchaseError {
1587
2241
  RnIapLog.failure("isEligibleForExternalPurchaseCustomLinkIOS", error: purchaseError)
1588
2242
  throw OpenIapException.from(purchaseError)
2243
+ } catch let connectionError as OpenIapException {
2244
+ RnIapLog.failure("isEligibleForExternalPurchaseCustomLinkIOS", error: connectionError)
2245
+ throw connectionError
1589
2246
  } catch {
1590
2247
  RnIapLog.failure("isEligibleForExternalPurchaseCustomLinkIOS", error: error)
1591
2248
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -1601,7 +2258,9 @@ class HybridRnIap: HybridRnIapSpec {
1601
2258
  guard let openIapTokenType = OpenIAP.ExternalPurchaseCustomLinkTokenTypeIOS(rawValue: tokenType.stringValue) else {
1602
2259
  throw OpenIapException.make(code: .developerError, message: "Invalid token type: \(tokenType.stringValue). Must be 'acquisition' or 'services'")
1603
2260
  }
1604
- let result = try await OpenIapModule.shared.getExternalPurchaseCustomLinkTokenIOS(openIapTokenType)
2261
+ let result = try await self.runConnectedOperation {
2262
+ try await OpenIapModule.shared.getExternalPurchaseCustomLinkTokenIOS(openIapTokenType)
2263
+ }
1605
2264
  let nitroResult = ExternalPurchaseCustomLinkTokenResultIOS(
1606
2265
  error: RnIapHelper.wrapString(result.error),
1607
2266
  token: RnIapHelper.wrapString(result.token)
@@ -1615,6 +2274,9 @@ class HybridRnIap: HybridRnIapSpec {
1615
2274
  } catch let purchaseError as PurchaseError {
1616
2275
  RnIapLog.failure("getExternalPurchaseCustomLinkTokenIOS", error: purchaseError)
1617
2276
  throw OpenIapException.from(purchaseError)
2277
+ } catch let connectionError as OpenIapException {
2278
+ RnIapLog.failure("getExternalPurchaseCustomLinkTokenIOS", error: connectionError)
2279
+ throw connectionError
1618
2280
  } catch {
1619
2281
  RnIapLog.failure("getExternalPurchaseCustomLinkTokenIOS", error: error)
1620
2282
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
@@ -1637,7 +2299,9 @@ class HybridRnIap: HybridRnIapSpec {
1637
2299
  } else {
1638
2300
  throw OpenIapException.make(code: .developerError, message: "Invalid notice type: \(noticeType.stringValue). Must be 'browser'")
1639
2301
  }
1640
- let result = try await OpenIapModule.shared.showExternalPurchaseCustomLinkNoticeIOS(openIapNoticeType)
2302
+ let result = try await self.runConnectedOperation {
2303
+ try await OpenIapModule.shared.showExternalPurchaseCustomLinkNoticeIOS(openIapNoticeType)
2304
+ }
1641
2305
  let nitroResult = ExternalPurchaseCustomLinkNoticeResultIOS(
1642
2306
  continued: result.continued,
1643
2307
  error: RnIapHelper.wrapString(result.error)
@@ -1647,6 +2311,9 @@ class HybridRnIap: HybridRnIapSpec {
1647
2311
  } catch let purchaseError as PurchaseError {
1648
2312
  RnIapLog.failure("showExternalPurchaseCustomLinkNoticeIOS", error: purchaseError)
1649
2313
  throw OpenIapException.from(purchaseError)
2314
+ } catch let connectionError as OpenIapException {
2315
+ RnIapLog.failure("showExternalPurchaseCustomLinkNoticeIOS", error: connectionError)
2316
+ throw connectionError
1650
2317
  } catch {
1651
2318
  RnIapLog.failure("showExternalPurchaseCustomLinkNoticeIOS", error: error)
1652
2319
  throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)