expo-iap 4.4.0 → 4.4.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.
- package/CLAUDE.md +1 -1
- package/android/build.gradle +2 -0
- package/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt +178 -117
- package/android/src/main/java/expo/modules/iap/ExpoIapLog.kt +22 -3
- package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +94 -36
- package/build/index.d.ts +3 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +94 -11
- package/build/index.js.map +1 -1
- package/build/modules/ios.d.ts +2 -2
- package/build/modules/ios.d.ts.map +1 -1
- package/build/modules/ios.js +29 -2
- package/build/modules/ios.js.map +1 -1
- package/build/types.d.ts +58 -26
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/build/useIAP.d.ts +1 -1
- package/build/useIAP.d.ts.map +1 -1
- package/build/useIAP.js.map +1 -1
- package/build/utils/errorMapping.d.ts +27 -3
- package/build/utils/errorMapping.d.ts.map +1 -1
- package/build/utils/errorMapping.js +119 -43
- package/build/utils/errorMapping.js.map +1 -1
- package/build/vega-adapter.d.ts.map +1 -1
- package/build/vega-adapter.js +22 -2
- package/build/vega-adapter.js.map +1 -1
- package/ios/ExpoIapHelper.swift +22 -8
- package/ios/ExpoIapLog.swift +13 -1
- package/ios/ExpoIapModule.swift +26 -7
- package/ios/onside/OnsideIapModule.swift +9 -1
- package/openiap-versions.json +3 -3
- package/package.json +1 -1
- package/src/index.ts +160 -42
- package/src/modules/ios.ts +34 -3
- package/src/types.ts +58 -26
- package/src/useIAP.ts +1 -1
- package/src/utils/errorMapping.ts +177 -71
- package/src/vega-adapter.ts +45 -2
package/CLAUDE.md
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
Before committing any changes:
|
|
40
40
|
|
|
41
41
|
1. Run `bun run lint` to ensure code quality
|
|
42
|
-
2. Run `bun run
|
|
42
|
+
2. Run `bun run lint:tsc` to verify TypeScript types
|
|
43
43
|
3. Run `bun run test` to verify all tests pass (Note: Use `bun run test`, not `bun test`)
|
|
44
44
|
4. **IMPORTANT**: Run tests in the example directory as well:
|
|
45
45
|
- `cd example && bun run test`
|
package/android/build.gradle
CHANGED
|
@@ -103,6 +103,8 @@ kotlin {
|
|
|
103
103
|
|
|
104
104
|
dependencies {
|
|
105
105
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7"
|
|
106
|
+
testImplementation "junit:junit:4.13.2"
|
|
107
|
+
testImplementation "org.json:json:20180813"
|
|
106
108
|
|
|
107
109
|
// Use OpenIAP Google module only; avoid direct BillingClient dependency
|
|
108
110
|
if (findProject(":openiap-google") != null) {
|
|
@@ -7,15 +7,40 @@ import dev.hyo.openiap.OpenIapModule
|
|
|
7
7
|
import dev.hyo.openiap.ProductQueryType
|
|
8
8
|
import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid
|
|
9
9
|
import dev.hyo.openiap.SubscriptionReplacementModeAndroid
|
|
10
|
+
import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener
|
|
11
|
+
import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
|
|
12
|
+
import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
|
|
13
|
+
import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener
|
|
14
|
+
import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener
|
|
10
15
|
import expo.modules.kotlin.Promise
|
|
11
16
|
import expo.modules.kotlin.modules.Module
|
|
12
17
|
import kotlinx.coroutines.CoroutineScope
|
|
13
18
|
import kotlinx.coroutines.launch
|
|
19
|
+
import org.json.JSONObject
|
|
14
20
|
import java.util.Locale
|
|
15
21
|
import java.util.concurrent.ConcurrentLinkedQueue
|
|
16
22
|
|
|
17
23
|
object ExpoIapHelper {
|
|
18
24
|
private const val MAX_BUFFERED_EVENTS = 200
|
|
25
|
+
internal const val ERROR_ENVELOPE_PREFIX = "OPENIAP_ERROR_JSON:"
|
|
26
|
+
|
|
27
|
+
data class ListenerHandles(
|
|
28
|
+
val purchaseUpdate: OpenIapPurchaseUpdateListener,
|
|
29
|
+
val purchaseError: OpenIapPurchaseErrorListener,
|
|
30
|
+
val userChoiceBilling: OpenIapUserChoiceBillingListener,
|
|
31
|
+
val developerProvidedBilling: OpenIapDeveloperProvidedBillingListener,
|
|
32
|
+
val subscriptionBillingIssue: OpenIapSubscriptionBillingIssueListener,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
internal fun serializeOpenIapError(error: OpenIapError): Map<String, Any?> =
|
|
36
|
+
error.toJSON() +
|
|
37
|
+
when (error) {
|
|
38
|
+
is OpenIapError.ProductNotFound -> mapOf("productId" to error.productId)
|
|
39
|
+
is OpenIapError.SkuNotFound -> mapOf("productId" to error.sku)
|
|
40
|
+
else -> emptyMap()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
internal fun serializeErrorEnvelope(payload: Map<String, Any?>): String = ERROR_ENVELOPE_PREFIX + JSONObject(payload).toString()
|
|
19
44
|
|
|
20
45
|
fun emitOrQueue(
|
|
21
46
|
module: Module,
|
|
@@ -57,29 +82,31 @@ object ExpoIapHelper {
|
|
|
57
82
|
// Support nested request.google / request.android structure
|
|
58
83
|
// If the params contain a "request" key with nested platform-specific data,
|
|
59
84
|
// extract and flatten it before parsing.
|
|
60
|
-
val effective: Map<String, Any?> =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
val effective: Map<String, Any?> =
|
|
86
|
+
run {
|
|
87
|
+
val request = params["request"] as? Map<*, *>
|
|
88
|
+
if (request != null) {
|
|
89
|
+
val nested =
|
|
90
|
+
(request["google"] as? Map<*, *>)
|
|
91
|
+
?: (request["android"] as? Map<*, *>)
|
|
92
|
+
if (nested != null) {
|
|
93
|
+
val flat = mutableMapOf<String, Any?>()
|
|
94
|
+
// Carry over top-level fields like type, useAlternativeBilling
|
|
95
|
+
for ((k, v) in params) {
|
|
96
|
+
if (k != "request") flat[k] = v
|
|
97
|
+
}
|
|
98
|
+
// Overlay platform-specific fields
|
|
99
|
+
for ((k, v) in nested) {
|
|
100
|
+
if (k is String) flat[k] = v
|
|
101
|
+
}
|
|
102
|
+
flat
|
|
103
|
+
} else {
|
|
104
|
+
params
|
|
74
105
|
}
|
|
75
|
-
flat
|
|
76
106
|
} else {
|
|
77
107
|
params
|
|
78
108
|
}
|
|
79
|
-
} else {
|
|
80
|
-
params
|
|
81
109
|
}
|
|
82
|
-
}
|
|
83
110
|
|
|
84
111
|
val type = effective["type"] as? String
|
|
85
112
|
val skus: List<String> =
|
|
@@ -107,9 +134,11 @@ object ExpoIapHelper {
|
|
|
107
134
|
val replacementMode = effective["replacementMode"] as? Number
|
|
108
135
|
val developerBillingOption =
|
|
109
136
|
(effective["developerBillingOption"] as? Map<*, *>)?.let { optionMap ->
|
|
110
|
-
val json =
|
|
111
|
-
|
|
112
|
-
|
|
137
|
+
val json =
|
|
138
|
+
optionMap.entries
|
|
139
|
+
.mapNotNull { (key, value) ->
|
|
140
|
+
(key as? String)?.let { it to value }
|
|
141
|
+
}.toMap()
|
|
113
142
|
DeveloperBillingOptionParamsAndroid.fromJson(json)
|
|
114
143
|
}
|
|
115
144
|
val subscriptionProductReplacementParams =
|
|
@@ -125,7 +154,7 @@ object ExpoIapHelper {
|
|
|
125
154
|
)
|
|
126
155
|
}
|
|
127
156
|
}
|
|
128
|
-
// offerToken for one-time purchase discounts (Android
|
|
157
|
+
// offerToken for one-time purchase discounts (Android 8.0+)
|
|
129
158
|
val offerToken = effective["offerToken"] as? String
|
|
130
159
|
|
|
131
160
|
return RequestPurchaseParams(
|
|
@@ -162,7 +191,7 @@ object ExpoIapHelper {
|
|
|
162
191
|
val obfuscatedAccountId: String?,
|
|
163
192
|
val obfuscatedProfileId: String?,
|
|
164
193
|
val isOfferPersonalized: Boolean,
|
|
165
|
-
/** Offer token for one-time purchase discounts (Android
|
|
194
|
+
/** Offer token for one-time purchase discounts (Android 8.0+) */
|
|
166
195
|
val offerToken: String?,
|
|
167
196
|
val offerTokenArr: List<String>,
|
|
168
197
|
val explicitSubscriptionOffers: List<AndroidSubscriptionOfferInput>,
|
|
@@ -239,56 +268,43 @@ object ExpoIapHelper {
|
|
|
239
268
|
eventUserChoiceBilling: String,
|
|
240
269
|
eventDeveloperProvidedBilling: String,
|
|
241
270
|
eventSubscriptionBillingIssue: String,
|
|
242
|
-
) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
emitOrQueue(
|
|
246
|
-
module,
|
|
247
|
-
scope,
|
|
248
|
-
connectionReady,
|
|
249
|
-
pendingEvents,
|
|
250
|
-
eventPurchaseUpdated,
|
|
251
|
-
p.toJson(),
|
|
252
|
-
)
|
|
253
|
-
}.onFailure { error ->
|
|
254
|
-
ExpoIapLog.failure("buffer/send PURCHASE_UPDATED", error)
|
|
255
|
-
// Emit as purchase error so user knows something went wrong
|
|
256
|
-
val errorPayload =
|
|
257
|
-
mapOf(
|
|
258
|
-
"code" to OpenIapError.PurchaseFailed.CODE,
|
|
259
|
-
"message" to "Failed to process purchase update: ${error.message}",
|
|
260
|
-
)
|
|
271
|
+
): ListenerHandles {
|
|
272
|
+
val purchaseUpdateListener =
|
|
273
|
+
OpenIapPurchaseUpdateListener { p ->
|
|
261
274
|
runCatching {
|
|
262
275
|
emitOrQueue(
|
|
263
276
|
module,
|
|
264
277
|
scope,
|
|
265
278
|
connectionReady,
|
|
266
279
|
pendingEvents,
|
|
267
|
-
|
|
268
|
-
|
|
280
|
+
eventPurchaseUpdated,
|
|
281
|
+
p.toJson(),
|
|
269
282
|
)
|
|
270
|
-
}.onFailure {
|
|
283
|
+
}.onFailure { error ->
|
|
284
|
+
ExpoIapLog.failure("buffer/send PURCHASE_UPDATED", error)
|
|
285
|
+
// Emit as purchase error so user knows something went wrong
|
|
286
|
+
val errorPayload =
|
|
287
|
+
mapOf(
|
|
288
|
+
"code" to OpenIapError.PurchaseFailed.CODE,
|
|
289
|
+
"message" to "Failed to process purchase update: ${error.message}",
|
|
290
|
+
)
|
|
291
|
+
runCatching {
|
|
292
|
+
emitOrQueue(
|
|
293
|
+
module,
|
|
294
|
+
scope,
|
|
295
|
+
connectionReady,
|
|
296
|
+
pendingEvents,
|
|
297
|
+
eventPurchaseError,
|
|
298
|
+
errorPayload,
|
|
299
|
+
)
|
|
300
|
+
}.onFailure { ExpoIapLog.failure("send error event", it) }
|
|
301
|
+
}
|
|
271
302
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
module,
|
|
278
|
-
scope,
|
|
279
|
-
connectionReady,
|
|
280
|
-
pendingEvents,
|
|
281
|
-
eventPurchaseError,
|
|
282
|
-
errorJson,
|
|
283
|
-
)
|
|
284
|
-
}.onFailure { error ->
|
|
285
|
-
ExpoIapLog.failure("buffer/send PURCHASE_ERROR", error)
|
|
286
|
-
// Critical: if we can't emit the original error, at least try to emit a generic one
|
|
287
|
-
val fallbackPayload =
|
|
288
|
-
mapOf(
|
|
289
|
-
"code" to OpenIapError.UnknownError.CODE,
|
|
290
|
-
"message" to "Failed to emit purchase error: ${error.message}",
|
|
291
|
-
)
|
|
303
|
+
openIap.addPurchaseUpdateListener(purchaseUpdateListener)
|
|
304
|
+
|
|
305
|
+
val purchaseErrorListener =
|
|
306
|
+
OpenIapPurchaseErrorListener { e ->
|
|
307
|
+
val errorJson = serializeOpenIapError(e)
|
|
292
308
|
runCatching {
|
|
293
309
|
emitOrQueue(
|
|
294
310
|
module,
|
|
@@ -296,63 +312,108 @@ object ExpoIapHelper {
|
|
|
296
312
|
connectionReady,
|
|
297
313
|
pendingEvents,
|
|
298
314
|
eventPurchaseError,
|
|
299
|
-
|
|
315
|
+
errorJson,
|
|
300
316
|
)
|
|
301
|
-
}.onFailure {
|
|
317
|
+
}.onFailure { error ->
|
|
318
|
+
ExpoIapLog.failure("buffer/send PURCHASE_ERROR", error)
|
|
319
|
+
// Critical: if we can't emit the original error, at least try to emit a generic one
|
|
320
|
+
val fallbackPayload =
|
|
321
|
+
mapOf(
|
|
322
|
+
"code" to OpenIapError.UnknownError.CODE,
|
|
323
|
+
"message" to "Failed to emit purchase error: ${error.message}",
|
|
324
|
+
)
|
|
325
|
+
runCatching {
|
|
326
|
+
emitOrQueue(
|
|
327
|
+
module,
|
|
328
|
+
scope,
|
|
329
|
+
connectionReady,
|
|
330
|
+
pendingEvents,
|
|
331
|
+
eventPurchaseError,
|
|
332
|
+
fallbackPayload,
|
|
333
|
+
)
|
|
334
|
+
}.onFailure { ExpoIapLog.failure("send fallback error event", it) }
|
|
335
|
+
}
|
|
336
|
+
// Also reject any pending purchase promises to match iOS behavior
|
|
337
|
+
val errorCode = errorJson["code"] as? String ?: OpenIapError.PurchaseFailed.CODE
|
|
338
|
+
rejectPurchasePromises(
|
|
339
|
+
errorCode,
|
|
340
|
+
serializeErrorEnvelope(errorJson),
|
|
341
|
+
null,
|
|
342
|
+
)
|
|
302
343
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
344
|
+
openIap.addPurchaseErrorListener(purchaseErrorListener)
|
|
345
|
+
|
|
346
|
+
val userChoiceBillingListener =
|
|
347
|
+
OpenIapUserChoiceBillingListener { details ->
|
|
348
|
+
safeEmitEvent(
|
|
349
|
+
module,
|
|
350
|
+
scope,
|
|
351
|
+
connectionReady,
|
|
352
|
+
pendingEvents,
|
|
353
|
+
eventUserChoiceBilling,
|
|
354
|
+
details.toJson(),
|
|
355
|
+
eventPurchaseError,
|
|
356
|
+
"alternative-billing-not-available",
|
|
357
|
+
"Failed to process user choice billing",
|
|
358
|
+
"USER_CHOICE_BILLING",
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
openIap.addUserChoiceBillingListener(userChoiceBillingListener)
|
|
362
|
+
|
|
322
363
|
// Developer Provided Billing listener for External Payments (8.3.0+) and Billing Choice (9.1.0+)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
364
|
+
val developerProvidedBillingListener =
|
|
365
|
+
OpenIapDeveloperProvidedBillingListener { details ->
|
|
366
|
+
safeEmitEvent(
|
|
367
|
+
module,
|
|
368
|
+
scope,
|
|
369
|
+
connectionReady,
|
|
370
|
+
pendingEvents,
|
|
371
|
+
eventDeveloperProvidedBilling,
|
|
372
|
+
details.toJson(),
|
|
373
|
+
eventPurchaseError,
|
|
374
|
+
"developer-billing-error",
|
|
375
|
+
"Failed to process developer provided billing",
|
|
376
|
+
"DEVELOPER_PROVIDED_BILLING",
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
openIap.addDeveloperProvidedBillingListener(developerProvidedBillingListener)
|
|
380
|
+
|
|
337
381
|
// Subscription billing-issue listener (Play Billing 8.1+ isSuspended; no-op on Horizon)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
382
|
+
val subscriptionBillingIssueListener =
|
|
383
|
+
OpenIapSubscriptionBillingIssueListener { purchase ->
|
|
384
|
+
safeEmitEvent(
|
|
385
|
+
module,
|
|
386
|
+
scope,
|
|
387
|
+
connectionReady,
|
|
388
|
+
pendingEvents,
|
|
389
|
+
eventSubscriptionBillingIssue,
|
|
390
|
+
purchase.toJson(),
|
|
391
|
+
eventPurchaseError,
|
|
392
|
+
"subscription-billing-issue-error",
|
|
393
|
+
"Failed to process subscription billing issue",
|
|
394
|
+
"SUBSCRIPTION_BILLING_ISSUE",
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
openIap.addSubscriptionBillingIssueListener(subscriptionBillingIssueListener)
|
|
398
|
+
|
|
399
|
+
return ListenerHandles(
|
|
400
|
+
purchaseUpdate = purchaseUpdateListener,
|
|
401
|
+
purchaseError = purchaseErrorListener,
|
|
402
|
+
userChoiceBilling = userChoiceBillingListener,
|
|
403
|
+
developerProvidedBilling = developerProvidedBillingListener,
|
|
404
|
+
subscriptionBillingIssue = subscriptionBillingIssueListener,
|
|
405
|
+
)
|
|
352
406
|
}
|
|
353
407
|
|
|
354
|
-
fun cleanupListeners(
|
|
355
|
-
|
|
356
|
-
|
|
408
|
+
fun cleanupListeners(
|
|
409
|
+
openIap: OpenIapModule,
|
|
410
|
+
handles: ListenerHandles?,
|
|
411
|
+
) {
|
|
412
|
+
if (handles == null) return
|
|
413
|
+
openIap.removePurchaseUpdateListener(handles.purchaseUpdate)
|
|
414
|
+
openIap.removePurchaseErrorListener(handles.purchaseError)
|
|
415
|
+
openIap.removeUserChoiceBillingListener(handles.userChoiceBilling)
|
|
416
|
+
openIap.removeDeveloperProvidedBillingListener(handles.developerProvidedBilling)
|
|
417
|
+
openIap.removeSubscriptionBillingIssueListener(handles.subscriptionBillingIssue)
|
|
357
418
|
}
|
|
358
419
|
}
|
|
@@ -12,7 +12,9 @@ internal object ExpoIapLog {
|
|
|
12
12
|
"apikey",
|
|
13
13
|
"secret",
|
|
14
14
|
"jws",
|
|
15
|
-
"
|
|
15
|
+
"receipt",
|
|
16
|
+
"dataandroid",
|
|
17
|
+
"signatureandroid",
|
|
16
18
|
"userid",
|
|
17
19
|
"password",
|
|
18
20
|
"bearer"
|
|
@@ -54,7 +56,7 @@ internal object ExpoIapLog {
|
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
internal fun stringify(value: Any?): String {
|
|
58
60
|
val sanitized = sanitize(value) ?: return "null"
|
|
59
61
|
return when (sanitized) {
|
|
60
62
|
is String -> sanitized
|
|
@@ -66,9 +68,14 @@ internal object ExpoIapLog {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
private fun sanitize(value: Any?): Any? {
|
|
69
|
-
if (value == null) return null
|
|
71
|
+
if (value == null || value === JSONObject.NULL) return null
|
|
70
72
|
|
|
71
73
|
return when (value) {
|
|
74
|
+
is String -> sanitizeJsonString(value)
|
|
75
|
+
is JSONObject -> sanitizeMap(
|
|
76
|
+
value.keys().asSequence().associateWith(value::opt),
|
|
77
|
+
)
|
|
78
|
+
is JSONArray -> (0 until value.length()).mapNotNull { sanitize(value.opt(it)) }
|
|
72
79
|
is Map<*, *> -> sanitizeMap(value)
|
|
73
80
|
is List<*> -> value.mapNotNull { sanitize(it) }
|
|
74
81
|
is Array<*> -> value.mapNotNull { sanitize(it) }
|
|
@@ -76,6 +83,18 @@ internal object ExpoIapLog {
|
|
|
76
83
|
}
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
private fun sanitizeJsonString(value: String): Any {
|
|
87
|
+
val trimmed = value.trim()
|
|
88
|
+
val json = runCatching {
|
|
89
|
+
when (trimmed.firstOrNull()) {
|
|
90
|
+
'{' -> JSONObject(trimmed)
|
|
91
|
+
'[' -> JSONArray(trimmed)
|
|
92
|
+
else -> null
|
|
93
|
+
}
|
|
94
|
+
}.getOrNull() ?: return value
|
|
95
|
+
return sanitize(json) ?: value
|
|
96
|
+
}
|
|
97
|
+
|
|
79
98
|
private fun sanitizeMap(source: Map<*, *>): Map<String, Any?> {
|
|
80
99
|
fun isSensitiveKey(key: String): Boolean {
|
|
81
100
|
val normalized = key.lowercase(Locale.ROOT).filter { it.isLetterOrDigit() }
|