expo-iap 4.4.0 → 4.5.0

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 (38) hide show
  1. package/CLAUDE.md +1 -1
  2. package/android/build.gradle +2 -0
  3. package/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt +178 -117
  4. package/android/src/main/java/expo/modules/iap/ExpoIapLog.kt +22 -3
  5. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +94 -36
  6. package/build/index.d.ts +3 -3
  7. package/build/index.d.ts.map +1 -1
  8. package/build/index.js +94 -11
  9. package/build/index.js.map +1 -1
  10. package/build/modules/ios.d.ts +2 -2
  11. package/build/modules/ios.d.ts.map +1 -1
  12. package/build/modules/ios.js +29 -2
  13. package/build/modules/ios.js.map +1 -1
  14. package/build/types.d.ts +65 -26
  15. package/build/types.d.ts.map +1 -1
  16. package/build/types.js.map +1 -1
  17. package/build/useIAP.d.ts +1 -1
  18. package/build/useIAP.d.ts.map +1 -1
  19. package/build/useIAP.js.map +1 -1
  20. package/build/utils/errorMapping.d.ts +27 -3
  21. package/build/utils/errorMapping.d.ts.map +1 -1
  22. package/build/utils/errorMapping.js +119 -43
  23. package/build/utils/errorMapping.js.map +1 -1
  24. package/build/vega-adapter.d.ts.map +1 -1
  25. package/build/vega-adapter.js +114 -11
  26. package/build/vega-adapter.js.map +1 -1
  27. package/ios/ExpoIapHelper.swift +22 -8
  28. package/ios/ExpoIapLog.swift +13 -1
  29. package/ios/ExpoIapModule.swift +26 -7
  30. package/ios/onside/OnsideIapModule.swift +9 -1
  31. package/openiap-versions.json +3 -3
  32. package/package.json +1 -1
  33. package/src/index.ts +160 -42
  34. package/src/modules/ios.ts +34 -3
  35. package/src/types.ts +65 -26
  36. package/src/useIAP.ts +1 -1
  37. package/src/utils/errorMapping.ts +177 -71
  38. package/src/vega-adapter.ts +169 -24
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 typecheck` to verify TypeScript types
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`
@@ -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?> = run {
61
- val request = params["request"] as? Map<*, *>
62
- if (request != null) {
63
- val nested = (request["google"] as? Map<*, *>)
64
- ?: (request["android"] as? Map<*, *>)
65
- if (nested != null) {
66
- val flat = mutableMapOf<String, Any?>()
67
- // Carry over top-level fields like type, useAlternativeBilling
68
- for ((k, v) in params) {
69
- if (k != "request") flat[k] = v
70
- }
71
- // Overlay platform-specific fields
72
- for ((k, v) in nested) {
73
- if (k is String) flat[k] = v
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 = optionMap.entries.mapNotNull { (key, value) ->
111
- (key as? String)?.let { it to value }
112
- }.toMap()
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 7.0+)
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 7.0+) */
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
- openIap.addPurchaseUpdateListener { p ->
244
- runCatching {
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
- eventPurchaseError,
268
- errorPayload,
280
+ eventPurchaseUpdated,
281
+ p.toJson(),
269
282
  )
270
- }.onFailure { ExpoIapLog.failure("send error event", it) }
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
- openIap.addPurchaseErrorListener { e ->
274
- val errorJson = e.toJSON()
275
- runCatching {
276
- emitOrQueue(
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
- fallbackPayload,
315
+ errorJson,
300
316
  )
301
- }.onFailure { ExpoIapLog.failure("send fallback error event", it) }
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
- // Also reject any pending purchase promises to match iOS behavior
304
- val errorCode = errorJson["code"] as? String ?: OpenIapError.PurchaseFailed.CODE
305
- val errorMessage = errorJson["message"] as? String ?: "Purchase failed"
306
- rejectPurchasePromises(errorCode, errorMessage, null)
307
- }
308
- openIap.addUserChoiceBillingListener { details ->
309
- safeEmitEvent(
310
- module,
311
- scope,
312
- connectionReady,
313
- pendingEvents,
314
- eventUserChoiceBilling,
315
- details.toJson(),
316
- eventPurchaseError,
317
- "alternative-billing-not-available",
318
- "Failed to process user choice billing",
319
- "USER_CHOICE_BILLING",
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
- openIap.addDeveloperProvidedBillingListener { details ->
324
- safeEmitEvent(
325
- module,
326
- scope,
327
- connectionReady,
328
- pendingEvents,
329
- eventDeveloperProvidedBilling,
330
- details.toJson(),
331
- eventPurchaseError,
332
- "developer-billing-error",
333
- "Failed to process developer provided billing",
334
- "DEVELOPER_PROVIDED_BILLING",
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
- openIap.addSubscriptionBillingIssueListener { purchase ->
339
- safeEmitEvent(
340
- module,
341
- scope,
342
- connectionReady,
343
- pendingEvents,
344
- eventSubscriptionBillingIssue,
345
- purchase.toJson(),
346
- eventPurchaseError,
347
- "subscription-billing-issue-error",
348
- "Failed to process subscription billing issue",
349
- "SUBSCRIPTION_BILLING_ISSUE",
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(openIap: OpenIapModule) {
355
- // Android doesn't have explicit listeners to clean up
356
- // This function is kept for API compatibility with iOS
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
- "receiptid",
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
- private fun stringify(value: Any?): String {
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() }