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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
132
|
-
listenersAttached
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/ios/HybridRnIap.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
|
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
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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