react-native-iap 14.7.12 → 14.7.14

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.
@@ -42,6 +42,7 @@ import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunc
42
42
  import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType
43
43
  import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener
44
44
  import dev.hyo.openiap.store.OpenIapStore
45
+ import kotlin.coroutines.cancellation.CancellationException
45
46
  import kotlinx.coroutines.Dispatchers
46
47
  import kotlinx.coroutines.withContext
47
48
  import kotlinx.coroutines.CompletableDeferred
@@ -100,19 +101,34 @@ class HybridRnIap : HybridRnIapSpec() {
100
101
  // CRITICAL: Set Activity BEFORE calling initConnection
101
102
  // Horizon SDK needs Activity to initialize OVRPlatform with proper returnComponent
102
103
  // https://github.com/meta-quest/Meta-Spatial-SDK-Samples/issues/82#issuecomment-3452577530
103
- withContext(Dispatchers.Main) {
104
- runCatching { context.currentActivity }
105
- .onSuccess { activity ->
106
- if (activity != null) {
107
- RnIapLog.debug("Activity available: ${activity.javaClass.name}")
108
- openIap.setActivity(activity)
109
- } else {
110
- RnIapLog.warn("Activity is null during initConnection")
104
+ try {
105
+ withContext(Dispatchers.Main) {
106
+ runCatching { context.currentActivity }
107
+ .onSuccess { activity ->
108
+ if (activity != null) {
109
+ RnIapLog.debug("Activity available: ${activity.javaClass.name}")
110
+ openIap.setActivity(activity)
111
+ } else {
112
+ RnIapLog.warn("Activity is null during initConnection")
113
+ }
111
114
  }
112
- }
113
- .onFailure {
114
- RnIapLog.warn("Activity not available during initConnection - OpenIAP will use Context")
115
- }
115
+ .onFailure {
116
+ RnIapLog.warn("Activity not available during initConnection - OpenIAP will use Context")
117
+ }
118
+ }
119
+ } catch (err: CancellationException) {
120
+ throw err
121
+ } catch (err: Throwable) {
122
+ val error = OpenIAPError.InitConnection
123
+ val errorMessage = err.message ?: err.javaClass.name
124
+ RnIapLog.failure("initConnection.setActivity", err)
125
+ throw OpenIapException(
126
+ toErrorJson(
127
+ error = error,
128
+ debugMessage = errorMessage,
129
+ messageOverride = "Failed to set activity: $errorMessage"
130
+ )
131
+ )
116
132
  }
117
133
 
118
134
  // Single-flight: capture or create the shared Deferred atomically
@@ -128,64 +144,88 @@ class HybridRnIap : HybridRnIapSpec() {
128
144
  return@async result
129
145
  }
130
146
 
131
- if (!listenersAttached) {
132
- listenersAttached = true
133
- RnIapLog.payload("listeners.attach", null)
134
- openIap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p ->
135
- runCatching {
136
- RnIapLog.result(
137
- "purchaseUpdatedListener",
138
- mapOf("id" to p.id, "sku" to p.productId)
139
- )
140
- sendPurchaseUpdate(convertToNitroPurchase(p))
141
- }.onFailure { RnIapLog.failure("purchaseUpdatedListener", it) }
142
- })
143
- openIap.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e ->
144
- val code = OpenIAPError.toCode(e)
145
- val message = e.message ?: OpenIAPError.defaultMessage(code)
146
- runCatching {
147
- RnIapLog.result(
148
- "purchaseErrorListener",
149
- mapOf("code" to code, "message" to message)
150
- )
151
- sendPurchaseError(
152
- NitroPurchaseResult(
153
- responseCode = -1.0,
154
- debugMessage = null,
155
- code = code,
156
- message = message,
157
- purchaseToken = null
147
+ try {
148
+ if (!listenersAttached) {
149
+ listenersAttached = true
150
+ RnIapLog.payload("listeners.attach", null)
151
+ openIap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p ->
152
+ runCatching {
153
+ RnIapLog.result(
154
+ "purchaseUpdatedListener",
155
+ mapOf("id" to p.id, "sku" to p.productId)
158
156
  )
159
- )
160
- }.onFailure { RnIapLog.failure("purchaseErrorListener", it) }
161
- })
162
- openIap.addUserChoiceBillingListener(OpenIapUserChoiceBillingListener { details ->
163
- runCatching {
164
- RnIapLog.result(
165
- "userChoiceBillingListener",
166
- mapOf("products" to details.products, "token" to details.externalTransactionToken)
167
- )
168
- val nitroDetails = UserChoiceBillingDetails(
169
- externalTransactionToken = details.externalTransactionToken,
170
- products = details.products.toTypedArray()
171
- )
172
- sendUserChoiceBilling(nitroDetails)
173
- }.onFailure { RnIapLog.failure("userChoiceBillingListener", it) }
174
- })
175
- // Developer Provided Billing listener (External Payments - 8.3.0+)
176
- openIap.addDeveloperProvidedBillingListener(OpenIapDeveloperProvidedBillingListener { details ->
177
- runCatching {
178
- RnIapLog.result(
179
- "developerProvidedBillingListener",
180
- mapOf("token" to details.externalTransactionToken)
181
- )
182
- val nitroDetails = DeveloperProvidedBillingDetailsAndroid(
183
- externalTransactionToken = details.externalTransactionToken
184
- )
185
- sendDeveloperProvidedBilling(nitroDetails)
186
- }.onFailure { RnIapLog.failure("developerProvidedBillingListener", it) }
187
- })
188
- RnIapLog.result("listeners.attach", "attached")
157
+ sendPurchaseUpdate(convertToNitroPurchase(p))
158
+ }.onFailure { RnIapLog.failure("purchaseUpdatedListener", it) }
159
+ })
160
+ openIap.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e ->
161
+ val code = OpenIAPError.toCode(e)
162
+ val message = e.message ?: OpenIAPError.defaultMessage(code)
163
+ runCatching {
164
+ RnIapLog.result(
165
+ "purchaseErrorListener",
166
+ mapOf("code" to code, "message" to message)
167
+ )
168
+ sendPurchaseError(
169
+ NitroPurchaseResult(
170
+ responseCode = -1.0,
171
+ debugMessage = null,
172
+ code = code,
173
+ message = message,
174
+ purchaseToken = null
175
+ )
176
+ )
177
+ }.onFailure { RnIapLog.failure("purchaseErrorListener", it) }
178
+ })
179
+ openIap.addUserChoiceBillingListener(OpenIapUserChoiceBillingListener { details ->
180
+ runCatching {
181
+ RnIapLog.result(
182
+ "userChoiceBillingListener",
183
+ mapOf("products" to details.products, "token" to details.externalTransactionToken)
184
+ )
185
+ val nitroDetails = UserChoiceBillingDetails(
186
+ externalTransactionToken = details.externalTransactionToken,
187
+ products = details.products.toTypedArray()
188
+ )
189
+ sendUserChoiceBilling(nitroDetails)
190
+ }.onFailure { RnIapLog.failure("userChoiceBillingListener", it) }
191
+ })
192
+ // Developer Provided Billing listener (External Payments - 8.3.0+)
193
+ openIap.addDeveloperProvidedBillingListener(OpenIapDeveloperProvidedBillingListener { details ->
194
+ runCatching {
195
+ RnIapLog.result(
196
+ "developerProvidedBillingListener",
197
+ mapOf("token" to details.externalTransactionToken)
198
+ )
199
+ val nitroDetails = DeveloperProvidedBillingDetailsAndroid(
200
+ externalTransactionToken = details.externalTransactionToken
201
+ )
202
+ sendDeveloperProvidedBilling(nitroDetails)
203
+ }.onFailure { RnIapLog.failure("developerProvidedBillingListener", it) }
204
+ })
205
+ RnIapLog.result("listeners.attach", "attached")
206
+ }
207
+ } catch (err: CancellationException) {
208
+ throw err
209
+ } catch (err: Throwable) {
210
+ listenersAttached = false
211
+ val error = OpenIAPError.InitConnection
212
+ val errorMessage = err.message ?: err.javaClass.name
213
+ RnIapLog.failure("initConnection.listeners", err)
214
+ val wrapped = OpenIapException(
215
+ toErrorJson(
216
+ error = error,
217
+ debugMessage = errorMessage,
218
+ messageOverride = "Failed to register billing listeners: $errorMessage"
219
+ )
220
+ )
221
+ synchronized(initLock) {
222
+ initDeferred?.let { deferred ->
223
+ if (!deferred.isCompleted) deferred.completeExceptionally(wrapped)
224
+ }
225
+ initDeferred = null
226
+ }
227
+ isInitialized = false
228
+ throw wrapped
189
229
  }
190
230
 
191
231
  // We created it above; reuse the shared instance
@@ -258,8 +298,8 @@ class HybridRnIap : HybridRnIapSpec() {
258
298
  synchronized(purchaseUpdatedListeners) { purchaseUpdatedListeners.clear() }
259
299
  synchronized(purchaseErrorListeners) { purchaseErrorListeners.clear() }
260
300
  promotedProductListenersIOS.clear()
261
- userChoiceBillingListenersAndroid.clear()
262
- developerProvidedBillingListenersAndroid.clear()
301
+ synchronized(userChoiceBillingListenersAndroid) { userChoiceBillingListenersAndroid.clear() }
302
+ synchronized(developerProvidedBillingListenersAndroid) { developerProvidedBillingListenersAndroid.clear() }
263
303
  initDeferred = null
264
304
  RnIapLog.result("endConnection", true)
265
305
  true
@@ -1592,9 +1632,8 @@ class HybridRnIap : HybridRnIapSpec() {
1592
1632
  }
1593
1633
 
1594
1634
  private fun sendUserChoiceBilling(details: UserChoiceBillingDetails) {
1595
- synchronized(userChoiceBillingListenersAndroid) {
1596
- userChoiceBillingListenersAndroid.forEach { it(details) }
1597
- }
1635
+ val snapshot = synchronized(userChoiceBillingListenersAndroid) { ArrayList(userChoiceBillingListenersAndroid) }
1636
+ snapshot.forEach { it(details) }
1598
1637
  }
1599
1638
 
1600
1639
  // Developer Provided Billing listener (External Payments - 8.3.0+)
@@ -1611,9 +1650,8 @@ class HybridRnIap : HybridRnIapSpec() {
1611
1650
  }
1612
1651
 
1613
1652
  private fun sendDeveloperProvidedBilling(details: DeveloperProvidedBillingDetailsAndroid) {
1614
- synchronized(developerProvidedBillingListenersAndroid) {
1615
- developerProvidedBillingListenersAndroid.forEach { it(details) }
1616
- }
1653
+ val snapshot = synchronized(developerProvidedBillingListenersAndroid) { ArrayList(developerProvidedBillingListenersAndroid) }
1654
+ snapshot.forEach { it(details) }
1617
1655
  }
1618
1656
 
1619
1657
  // -------------------------------------------------------------------------
@@ -0,0 +1,154 @@
1
+ package com.margelo.nitro.iap
2
+
3
+ import org.junit.Test
4
+ import org.junit.Assert.*
5
+ import java.util.concurrent.CyclicBarrier
6
+ import java.util.concurrent.atomic.AtomicInteger
7
+ import java.util.concurrent.atomic.AtomicReference
8
+ import kotlin.concurrent.thread
9
+
10
+ /**
11
+ * Thread safety tests for the synchronized + snapshot listener pattern
12
+ * used in HybridRnIap to prevent ConcurrentModificationException.
13
+ *
14
+ * Addresses Issue #3150 where purchase events were silently lost
15
+ * due to concurrent listener access.
16
+ */
17
+ class ListenerThreadSafetyTest {
18
+
19
+ @Test
20
+ fun `concurrent add and snapshot iterate does not throw`() {
21
+ val listeners = mutableListOf<(String) -> Unit>()
22
+ val callCount = AtomicInteger(0)
23
+ val errorRef = AtomicReference<Throwable?>(null)
24
+ val iterations = 500
25
+ val barrier = CyclicBarrier(2)
26
+
27
+ val adder = thread {
28
+ barrier.await()
29
+ repeat(iterations) {
30
+ synchronized(listeners) { listeners.add { callCount.incrementAndGet() } }
31
+ }
32
+ }
33
+
34
+ val sender = thread {
35
+ barrier.await()
36
+ repeat(iterations) {
37
+ val snapshot = synchronized(listeners) { ArrayList(listeners) }
38
+ snapshot.forEach {
39
+ try {
40
+ it("event")
41
+ } catch (e: Throwable) {
42
+ errorRef.compareAndSet(null, e)
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ adder.join(5000)
49
+ sender.join(5000)
50
+ assertFalse("Adder thread did not finish", adder.isAlive)
51
+ assertFalse("Sender thread did not finish", sender.isAlive)
52
+ assertNull("Should not throw ConcurrentModificationException", errorRef.get())
53
+ }
54
+
55
+ @Test
56
+ fun `concurrent add, remove, and iterate is safe`() {
57
+ val listeners = mutableListOf<(String) -> Unit>()
58
+ val errorRef = AtomicReference<Throwable?>(null)
59
+ val barrier = CyclicBarrier(3)
60
+
61
+ val adder = thread {
62
+ barrier.await()
63
+ repeat(200) {
64
+ synchronized(listeners) { listeners.add { _ -> } }
65
+ }
66
+ }
67
+
68
+ val remover = thread {
69
+ barrier.await()
70
+ repeat(200) {
71
+ synchronized(listeners) {
72
+ if (listeners.isNotEmpty()) listeners.removeAt(0)
73
+ }
74
+ }
75
+ }
76
+
77
+ val sender = thread {
78
+ barrier.await()
79
+ repeat(200) {
80
+ val snapshot = synchronized(listeners) { ArrayList(listeners) }
81
+ snapshot.forEach {
82
+ try {
83
+ it("event")
84
+ } catch (e: Throwable) {
85
+ errorRef.compareAndSet(null, e)
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ adder.join(5000)
92
+ remover.join(5000)
93
+ sender.join(5000)
94
+ assertFalse("Adder thread did not finish", adder.isAlive)
95
+ assertFalse("Remover thread did not finish", remover.isAlive)
96
+ assertFalse("Sender thread did not finish", sender.isAlive)
97
+ assertNull("Concurrent access should be safe with snapshot pattern", errorRef.get())
98
+ }
99
+
100
+ @Test
101
+ fun `snapshot delivers to all registered listeners`() {
102
+ val listeners = mutableListOf<(String) -> Unit>()
103
+ val results = mutableListOf<String>()
104
+
105
+ synchronized(listeners) {
106
+ listeners.add { results.add("listener1:$it") }
107
+ listeners.add { results.add("listener2:$it") }
108
+ }
109
+
110
+ val snapshot = synchronized(listeners) { ArrayList(listeners) }
111
+ snapshot.forEach { it("event") }
112
+
113
+ assertEquals(2, results.size)
114
+ assertTrue(results.contains("listener1:event"))
115
+ assertTrue(results.contains("listener2:event"))
116
+ }
117
+
118
+ @Test
119
+ fun `synchronized clear removes all listeners`() {
120
+ val listeners = mutableListOf<(String) -> Unit>()
121
+ synchronized(listeners) {
122
+ listeners.add { _ -> }
123
+ listeners.add { _ -> }
124
+ }
125
+
126
+ synchronized(listeners) { listeners.clear() }
127
+
128
+ val snapshot = synchronized(listeners) { ArrayList(listeners) }
129
+ assertTrue("Listeners should be empty after clear", snapshot.isEmpty())
130
+ }
131
+
132
+ @Test
133
+ fun `snapshot is isolated from subsequent modifications`() {
134
+ val listeners = mutableListOf<(String) -> Unit>()
135
+ val results = mutableListOf<String>()
136
+
137
+ synchronized(listeners) {
138
+ listeners.add { results.add("original:$it") }
139
+ }
140
+
141
+ // Take snapshot before adding more listeners
142
+ val snapshot = synchronized(listeners) { ArrayList(listeners) }
143
+
144
+ // Add another listener after snapshot
145
+ synchronized(listeners) {
146
+ listeners.add { results.add("added-after:$it") }
147
+ }
148
+
149
+ // Only the original listener should be in the snapshot
150
+ snapshot.forEach { it("event") }
151
+ assertEquals(1, results.size)
152
+ assertEquals("original:event", results[0])
153
+ }
154
+ }
@@ -23,7 +23,9 @@ class HybridRnIap: HybridRnIapSpec {
23
23
  private var deliveredPurchaseEventOrder: [String] = []
24
24
  private let purchaseEventDedupLimit = 128
25
25
  private var purchasePayloadById: [String: [String: Any]] = [:]
26
-
26
+ // Thread safety lock for listener arrays and error dedup state
27
+ private let listenerLock = NSLock()
28
+
27
29
  // MARK: - Initialization
28
30
 
29
31
  override init() {
@@ -851,8 +853,8 @@ class HybridRnIap: HybridRnIapSpec {
851
853
  }
852
854
 
853
855
  func addPromotedProductListenerIOS(listener: @escaping (NitroProduct) -> Void) throws {
854
- promotedProductListeners.append(listener)
855
-
856
+ listenerLock.withLock { promotedProductListeners.append(listener) }
857
+
856
858
  // If a promoted product is already available from OpenIAP, notify immediately
857
859
  Task {
858
860
  RnIapLog.payload("promotedProductListenerIOS.fetch", nil)
@@ -864,33 +866,27 @@ class HybridRnIap: HybridRnIapSpec {
864
866
  }
865
867
  }
866
868
  }
867
-
869
+
868
870
  func removePromotedProductListenerIOS(listener: @escaping (NitroProduct) -> Void) throws {
869
- // Note: In Swift, comparing closures is not straightforward, so we'll clear all listeners
870
- // In a real implementation, you might want to use a unique identifier for each listener
871
- promotedProductListeners.removeAll()
871
+ listenerLock.withLock { promotedProductListeners.removeAll() }
872
872
  }
873
-
873
+
874
874
  // MARK: - Event Listener Methods
875
-
875
+
876
876
  func addPurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws {
877
- purchaseUpdatedListeners.append(listener)
877
+ listenerLock.withLock { purchaseUpdatedListeners.append(listener) }
878
878
  }
879
-
879
+
880
880
  func addPurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws {
881
- purchaseErrorListeners.append(listener)
881
+ listenerLock.withLock { purchaseErrorListeners.append(listener) }
882
882
  }
883
-
883
+
884
884
  func removePurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws {
885
- // Note: This is a limitation of Swift closures - we can't easily remove by reference
886
- // For now, we'll just clear all listeners when requested
887
- purchaseUpdatedListeners.removeAll()
885
+ listenerLock.withLock { purchaseUpdatedListeners.removeAll() }
888
886
  }
889
-
887
+
890
888
  func removePurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws {
891
- // Note: This is a limitation of Swift closures - we can't easily remove by reference
892
- // For now, we'll just clear all listeners when requested
893
- purchaseErrorListeners.removeAll()
889
+ listenerLock.withLock { purchaseErrorListeners.removeAll() }
894
890
  }
895
891
 
896
892
  // MARK: - Private Helper Methods
@@ -954,20 +950,22 @@ class HybridRnIap: HybridRnIapSpec {
954
950
  RnIapLog.result("fetchProducts", payloads)
955
951
  if let payload = payloads.first {
956
952
  let nitro = RnIapHelper.convertProductDictionary(payload)
953
+ let snapshot = self.listenerLock.withLock { Array(self.promotedProductListeners) }
957
954
  await MainActor.run {
958
- for listener in self.promotedProductListeners { listener(nitro) }
955
+ for listener in snapshot { listener(nitro) }
959
956
  }
960
957
  }
961
958
  } catch {
962
959
  RnIapLog.failure("promotedProductListenerIOS", error: error)
963
960
  let id = productId
961
+ let snapshot = self.listenerLock.withLock { Array(self.promotedProductListeners) }
964
962
  await MainActor.run {
965
963
  var minimal = NitroProduct()
966
964
  minimal.id = id
967
965
  minimal.title = id
968
966
  minimal.type = "inapp"
969
967
  minimal.platform = .ios
970
- for listener in self.promotedProductListeners { listener(minimal) }
968
+ for listener in snapshot { listener(minimal) }
971
969
  }
972
970
  }
973
971
  }
@@ -992,44 +990,59 @@ class HybridRnIap: HybridRnIapSpec {
992
990
  ]
993
991
  let eventKey = keyComponents.joined(separator: "#")
994
992
 
995
- if deliveredPurchaseEventKeys.contains(eventKey) {
996
- RnIapLog.warn("Duplicate purchase update skipped for \(purchase.productId)")
997
- return
993
+ var isDuplicate = false
994
+ let snapshot: [(NitroPurchase) -> Void] = listenerLock.withLock {
995
+ if deliveredPurchaseEventKeys.contains(eventKey) {
996
+ isDuplicate = true
997
+ return []
998
+ }
999
+
1000
+ deliveredPurchaseEventKeys.insert(eventKey)
1001
+ deliveredPurchaseEventOrder.append(eventKey)
1002
+ if deliveredPurchaseEventOrder.count > purchaseEventDedupLimit, let removed = deliveredPurchaseEventOrder.first {
1003
+ deliveredPurchaseEventOrder.removeFirst()
1004
+ deliveredPurchaseEventKeys.remove(removed)
1005
+ }
1006
+
1007
+ return Array(purchaseUpdatedListeners)
998
1008
  }
999
1009
 
1000
- deliveredPurchaseEventKeys.insert(eventKey)
1001
- deliveredPurchaseEventOrder.append(eventKey)
1002
- if deliveredPurchaseEventOrder.count > purchaseEventDedupLimit, let removed = deliveredPurchaseEventOrder.first {
1003
- deliveredPurchaseEventOrder.removeFirst()
1004
- deliveredPurchaseEventKeys.remove(removed)
1010
+ if isDuplicate {
1011
+ RnIapLog.warn("Duplicate purchase update skipped for \(purchase.productId)")
1012
+ return
1005
1013
  }
1006
1014
 
1007
- for listener in purchaseUpdatedListeners {
1015
+ for listener in snapshot {
1008
1016
  listener(purchase)
1009
1017
  }
1010
1018
  }
1011
-
1019
+
1012
1020
  private func sendPurchaseError(_ error: NitroPurchaseResult, productId: String? = nil) {
1013
- let now = Date().timeIntervalSince1970
1014
1021
  let dedupIdentifier = productId
1015
1022
  ?? (error.purchaseToken?.isEmpty == false ? error.purchaseToken : nil)
1016
1023
  ?? (error.message.isEmpty ? nil : error.message)
1017
1024
  let currentKey = RnIapHelper.makeErrorDedupKey(code: error.code, productId: dedupIdentifier)
1018
- // Dedup only when the exact same error is emitted almost simultaneously.
1019
- let withinWindow = (now - lastPurchaseErrorTimestamp) < 0.15
1020
- if currentKey == lastPurchaseErrorKey && withinWindow {
1021
- return
1022
- }
1023
1025
 
1024
- lastPurchaseErrorKey = currentKey
1025
- lastPurchaseErrorTimestamp = now
1026
+ // Protect error dedup state since sendPurchaseError is called from multiple threads
1027
+ let shouldSkip: Bool = listenerLock.withLock {
1028
+ let now = Date().timeIntervalSince1970
1029
+ let withinWindow = (now - lastPurchaseErrorTimestamp) < 0.15
1030
+ if currentKey == lastPurchaseErrorKey && withinWindow {
1031
+ return true
1032
+ }
1033
+ lastPurchaseErrorKey = currentKey
1034
+ lastPurchaseErrorTimestamp = now
1035
+ return false
1036
+ }
1037
+ if shouldSkip { return }
1026
1038
 
1027
1039
  // Ensure we never leak SKU via purchaseToken
1028
1040
  var sanitized = error
1029
1041
  if let pid = productId, sanitized.purchaseToken == pid {
1030
1042
  sanitized.purchaseToken = nil
1031
1043
  }
1032
- for listener in purchaseErrorListeners {
1044
+ let snapshot = listenerLock.withLock { Array(purchaseErrorListeners) }
1045
+ for listener in snapshot {
1033
1046
  listener(sanitized)
1034
1047
  }
1035
1048
  }
@@ -1067,15 +1080,20 @@ class HybridRnIap: HybridRnIapSpec {
1067
1080
  RnIapLog.result("endConnection", result as Any)
1068
1081
  }
1069
1082
 
1070
- // Clear event listeners
1071
- purchaseUpdatedListeners.removeAll()
1072
- purchaseErrorListeners.removeAll()
1073
- promotedProductListeners.removeAll()
1074
- deliveredPurchaseEventKeys.removeAll()
1075
- deliveredPurchaseEventOrder.removeAll()
1076
- purchasePayloadById.removeAll()
1077
- lastPurchaseErrorKey = nil
1078
- lastPurchaseErrorTimestamp = 0
1083
+ // Clear event listeners, error dedup state, and delivery state (thread-safe)
1084
+ listenerLock.withLock {
1085
+ purchaseUpdatedListeners.removeAll()
1086
+ purchaseErrorListeners.removeAll()
1087
+ promotedProductListeners.removeAll()
1088
+ lastPurchaseErrorKey = nil
1089
+ lastPurchaseErrorTimestamp = 0
1090
+ deliveredPurchaseEventKeys.removeAll()
1091
+ deliveredPurchaseEventOrder.removeAll()
1092
+ }
1093
+ // Clear purchasePayloadById on MainActor to match its access pattern
1094
+ Task { @MainActor in
1095
+ self.purchasePayloadById.removeAll()
1096
+ }
1079
1097
  }
1080
1098
 
1081
1099
  func deepLinkToSubscriptionsAndroid(options: NitroDeepLinkOptionsAndroid) throws -> Promise<Void> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-iap",
3
- "version": "14.7.12",
3
+ "version": "14.7.14",
4
4
  "description": "React Native In-App Purchases module for iOS and Android using Nitro",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",