expo-iap 2.9.6 → 3.0.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 (56) hide show
  1. package/.eslintrc.js +24 -0
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +1 -1
  4. package/android/build.gradle +7 -2
  5. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +195 -668
  6. package/android/src/main/java/expo/modules/iap/PromiseUtils.kt +85 -0
  7. package/build/ExpoIap.types.d.ts +0 -6
  8. package/build/ExpoIap.types.d.ts.map +1 -1
  9. package/build/ExpoIap.types.js.map +1 -1
  10. package/build/helpers/subscription.d.ts.map +1 -1
  11. package/build/helpers/subscription.js +14 -3
  12. package/build/helpers/subscription.js.map +1 -1
  13. package/build/index.d.ts +6 -73
  14. package/build/index.d.ts.map +1 -1
  15. package/build/index.js +21 -154
  16. package/build/index.js.map +1 -1
  17. package/build/modules/android.d.ts +2 -2
  18. package/build/modules/android.d.ts.map +1 -1
  19. package/build/modules/android.js +11 -1
  20. package/build/modules/android.js.map +1 -1
  21. package/build/modules/ios.d.ts +0 -60
  22. package/build/modules/ios.d.ts.map +1 -1
  23. package/build/modules/ios.js +2 -121
  24. package/build/modules/ios.js.map +1 -1
  25. package/build/types/ExpoIapAndroid.types.d.ts +0 -8
  26. package/build/types/ExpoIapAndroid.types.d.ts.map +1 -1
  27. package/build/types/ExpoIapAndroid.types.js +0 -1
  28. package/build/types/ExpoIapAndroid.types.js.map +1 -1
  29. package/build/types/ExpoIapIOS.types.d.ts +0 -5
  30. package/build/types/ExpoIapIOS.types.d.ts.map +1 -1
  31. package/build/types/ExpoIapIOS.types.js.map +1 -1
  32. package/build/useIAP.d.ts +0 -18
  33. package/build/useIAP.d.ts.map +1 -1
  34. package/build/useIAP.js +1 -18
  35. package/build/useIAP.js.map +1 -1
  36. package/bun.lock +340 -137
  37. package/codecov.yml +17 -21
  38. package/ios/ExpoIapModule.swift +50 -23
  39. package/jest.config.js +5 -9
  40. package/package.json +5 -3
  41. package/plugin/build/withIAP.d.ts +4 -1
  42. package/plugin/build/withIAP.js +38 -24
  43. package/plugin/build/withLocalOpenIAP.d.ts +6 -2
  44. package/plugin/build/withLocalOpenIAP.js +175 -20
  45. package/plugin/src/withIAP.ts +66 -30
  46. package/plugin/src/withLocalOpenIAP.ts +228 -24
  47. package/src/ExpoIap.types.ts +0 -8
  48. package/src/helpers/subscription.ts +14 -3
  49. package/src/index.ts +22 -230
  50. package/src/modules/android.ts +16 -6
  51. package/src/modules/ios.ts +2 -168
  52. package/src/types/ExpoIapAndroid.types.ts +0 -11
  53. package/src/types/ExpoIapIOS.types.ts +0 -5
  54. package/src/useIAP.ts +3 -55
  55. package/android/src/main/java/expo/modules/iap/PlayUtils.kt +0 -178
  56. package/android/src/main/java/expo/modules/iap/Types.kt +0 -98
@@ -2,745 +2,272 @@ package expo.modules.iap
2
2
 
3
3
  import android.content.Context
4
4
  import android.util.Log
5
- import com.android.billingclient.api.AcknowledgePurchaseParams
6
- import com.android.billingclient.api.BillingClient
7
- import com.android.billingclient.api.BillingClientStateListener
8
- import com.android.billingclient.api.BillingConfig
9
- import com.android.billingclient.api.BillingFlowParams
10
- import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams
11
- import com.android.billingclient.api.BillingConfigResponseListener
12
- import com.android.billingclient.api.BillingResult
13
- import com.android.billingclient.api.ConsumeParams
14
- import com.android.billingclient.api.GetBillingConfigParams
15
- import com.android.billingclient.api.PendingPurchasesParams
16
- import com.android.billingclient.api.ProductDetails
17
- import com.android.billingclient.api.ProductDetailsResult
18
- import com.android.billingclient.api.Purchase
19
- import com.android.billingclient.api.PurchasesUpdatedListener
20
- import com.android.billingclient.api.QueryProductDetailsParams
21
- import com.android.billingclient.api.QueryProductDetailsResult
22
- import com.android.billingclient.api.QueryPurchasesParams
23
- import com.google.android.gms.common.ConnectionResult
24
- import com.google.android.gms.common.GoogleApiAvailability
5
+ import dev.hyo.openiap.OpenIapError
6
+ import dev.hyo.openiap.OpenIapModule
7
+ import dev.hyo.openiap.models.DeepLinkOptions
8
+ import dev.hyo.openiap.models.ProductRequest
9
+ import dev.hyo.openiap.models.RequestPurchaseAndroidProps
25
10
  import expo.modules.kotlin.Promise
26
11
  import expo.modules.kotlin.exception.Exceptions
27
12
  import expo.modules.kotlin.modules.Module
28
13
  import expo.modules.kotlin.modules.ModuleDefinition
29
-
30
- class ExpoIapModule :
31
- Module(),
32
- PurchasesUpdatedListener {
14
+ import kotlinx.coroutines.CoroutineScope
15
+ import kotlinx.coroutines.Dispatchers
16
+ import kotlinx.coroutines.Job
17
+ import kotlinx.coroutines.launch
18
+ import kotlinx.coroutines.sync.Mutex
19
+ import kotlinx.coroutines.sync.withLock
20
+ import java.util.concurrent.ConcurrentLinkedQueue
21
+ import java.util.concurrent.atomic.AtomicBoolean
22
+
23
+ class ExpoIapModule : Module() {
33
24
  companion object {
34
25
  const val TAG = "ExpoIapModule"
26
+ private const val EVENT_PURCHASE_UPDATED = "purchase-updated"
27
+ private const val EVENT_PURCHASE_ERROR = "purchase-error"
28
+ private const val MAX_BUFFERED_EVENTS = 200
35
29
  }
36
30
 
37
- private var billingClientCache: BillingClient? = null
38
- private val skus: MutableMap<String, ProductDetails> = mutableMapOf()
31
+ private val job = Job()
32
+ private val scope = CoroutineScope(Dispatchers.Main + job)
39
33
  private val context: Context
40
34
  get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
41
35
  private val currentActivity
42
- get() =
43
- appContext.activityProvider?.currentActivity
44
- ?: throw MissingCurrentActivityException()
36
+ get() = appContext.activityProvider?.currentActivity ?: throw Exceptions.MissingActivity()
37
+
38
+ private val openIap: OpenIapModule by lazy { OpenIapModule(context) }
39
+ private var listenersAttached = false
40
+ private val pendingEvents = ConcurrentLinkedQueue<Pair<String, Map<String, Any?>>>()
41
+ private val connectionReady = AtomicBoolean(false)
42
+ private val connectionMutex = Mutex()
45
43
 
46
- override fun onPurchasesUpdated(
47
- billingResult: BillingResult,
48
- purchases: List<Purchase>?,
44
+ private fun emitOrQueue(
45
+ name: String,
46
+ payload: Map<String, Any?>,
49
47
  ) {
50
- val responseCode = billingResult.responseCode
51
- if (responseCode != BillingClient.BillingResponseCode.OK) {
52
- val error =
53
- mutableMapOf<String, Any?>(
54
- "responseCode" to responseCode,
55
- "debugMessage" to billingResult.debugMessage,
56
- )
57
- // Add sub-response code if available (v8.0.0+)
58
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
59
- try {
60
- val subResponseCode = billingResult.javaClass.getMethod("getSubResponseCode").invoke(billingResult) as? Int
61
- if (subResponseCode != null && subResponseCode != 0) {
62
- error["subResponseCode"] = subResponseCode
63
- // Check for specific sub-response codes
64
- if (subResponseCode == 1) { // PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS
65
- error["subResponseMessage"] = "Payment declined due to insufficient funds"
66
- }
67
- }
68
- } catch (e: Exception) {
69
- // Method doesn't exist in older versions, ignore
70
- }
71
- }
72
- val errorData = PlayUtils.getBillingResponseData(responseCode)
73
- error["code"] = errorData.code
74
- error["message"] = errorData.message
75
- try {
76
- sendEvent(OpenIapEvent.PURCHASE_ERROR, error.toMap())
77
- } catch (e: Exception) {
78
- Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
79
- }
80
- PromiseUtils.rejectPromisesForKey(IapConstants.PROMISE_BUY_ITEM, errorData.code, errorData.message, null)
48
+ if (connectionReady.get()) {
49
+ // Ensure event emission occurs on the main dispatcher
50
+ scope.launch { sendEvent(name, payload) }
81
51
  return
82
52
  }
83
-
84
- if (purchases != null) {
85
- val promiseItems = mutableListOf<Map<String, Any?>>()
86
- purchases.forEach { purchase ->
87
- val item =
88
- mutableMapOf<String, Any?>(
89
- "id" to purchase.orderId,
90
- "productId" to purchase.products.firstOrNull() as Any?,
91
- "ids" to purchase.products,
92
- "transactionId" to purchase.orderId, // @deprecated - use id instead
93
- "transactionDate" to purchase.purchaseTime.toDouble(),
94
- "transactionReceipt" to purchase.originalJson,
95
- "purchaseTokenAndroid" to purchase.purchaseToken,
96
- "purchaseToken" to purchase.purchaseToken,
97
- "dataAndroid" to purchase.originalJson,
98
- "signatureAndroid" to purchase.signature,
99
- "autoRenewingAndroid" to purchase.isAutoRenewing,
100
- "isAcknowledgedAndroid" to purchase.isAcknowledged,
101
- "purchaseStateAndroid" to purchase.purchaseState,
102
- "packageNameAndroid" to purchase.packageName,
103
- "developerPayloadAndroid" to purchase.developerPayload,
104
- "platform" to "android",
105
- )
106
- purchase.accountIdentifiers?.let { accountIdentifiers ->
107
- item["obfuscatedAccountIdAndroid"] = accountIdentifiers.obfuscatedAccountId
108
- item["obfuscatedProfileIdAndroid"] = accountIdentifiers.obfuscatedProfileId
109
- }
110
- promiseItems.add(item.toMap())
111
- try {
112
- sendEvent(OpenIapEvent.PURCHASE_UPDATED, item.toMap())
113
- } catch (e: Exception) {
114
- Log.e(TAG, "Failed to send PURCHASE_UPDATED event: ${e.message}")
115
- }
116
- }
117
- PromiseUtils.resolvePromisesForKey(IapConstants.PROMISE_BUY_ITEM, promiseItems)
118
- } else {
119
- val result =
120
- mutableMapOf<String, Any?>(
121
- "platform" to "android",
122
- "responseCode" to billingResult.responseCode,
123
- "debugMessage" to billingResult.debugMessage,
124
- "extraMessage" to
125
- "The purchases are null. This is a normal behavior if you have requested DEFERRED proration. If not please report an issue.",
126
- )
127
- try {
128
- sendEvent(OpenIapEvent.PURCHASE_UPDATED, result.toMap())
129
- } catch (e: Exception) {
130
- Log.e(TAG, "Failed to send PURCHASE_UPDATED event: ${e.message}")
131
- }
132
- PromiseUtils.resolvePromisesForKey(IapConstants.PROMISE_BUY_ITEM, result)
53
+ // Bound the buffer to prevent unbounded growth if init stalls
54
+ if (pendingEvents.size >= MAX_BUFFERED_EVENTS) {
55
+ pendingEvents.poll()
56
+ Log.w(TAG, "pendingEvents overflow; dropping oldest")
133
57
  }
58
+ pendingEvents.add(name to payload)
134
59
  }
135
60
 
61
+ // Mapping helpers now provided by openiap-google (toJSON helpers)
62
+
136
63
  override fun definition() =
137
64
  ModuleDefinition {
138
65
  Name("ExpoIap")
139
66
 
140
67
  Constants(
141
- "ERROR_CODES" to IapErrorCode.toMap()
68
+ "ERROR_CODES" to OpenIapError.getAllErrorCodes(),
142
69
  )
143
70
 
144
- Events(OpenIapEvent.PURCHASE_UPDATED, OpenIapEvent.PURCHASE_ERROR)
71
+ Events(EVENT_PURCHASE_UPDATED, EVENT_PURCHASE_ERROR)
145
72
 
146
73
  AsyncFunction("initConnection") { promise: Promise ->
147
- initBillingClient(promise) { promise.resolve(true) }
148
- }
74
+ scope.launch {
75
+ connectionMutex.withLock {
76
+ try {
77
+ // Activity may be unavailable in headless/background scenarios.
78
+ // Attempt to set it, but do not fail init if missing.
79
+ runCatching { openIap.setActivity(currentActivity) }
80
+ .onFailure { Log.w(TAG, "initConnection: Activity missing; proceeding headless", it) }
81
+
82
+ // If already connected, short-circuit
83
+ if (connectionReady.get()) {
84
+ promise.resolve(true)
85
+ return@withLock
86
+ }
149
87
 
150
- AsyncFunction("endConnection") { promise: Promise ->
151
- billingClientCache?.endConnection()
152
- billingClientCache = null
153
- skus.clear()
154
- promise.resolve(true)
155
- }
88
+ // Attach listeners early to avoid races during init
89
+ if (!listenersAttached) {
90
+ listenersAttached = true
91
+ openIap.addPurchaseUpdateListener { p ->
92
+ runCatching { emitOrQueue(EVENT_PURCHASE_UPDATED, p.toJSON()) }
93
+ .onFailure { Log.e(TAG, "Failed to buffer/send PURCHASE_UPDATED", it) }
94
+ }
95
+ openIap.addPurchaseErrorListener { e ->
96
+ runCatching { emitOrQueue(EVENT_PURCHASE_ERROR, e.toJSON()) }
97
+ .onFailure { Log.e(TAG, "Failed to buffer/send PURCHASE_ERROR", it) }
98
+ }
99
+ }
156
100
 
157
- AsyncFunction("fetchProducts") { type: String, skuArr: Array<String>, promise: Promise ->
158
- ensureConnection(promise) { billingClient ->
159
- val skuList =
160
- skuArr.map { sku ->
161
- QueryProductDetailsParams.Product
162
- .newBuilder()
163
- .setProductId(sku)
164
- .setProductType(type)
165
- .build()
166
- }
101
+ val ok = openIap.initConnection()
167
102
 
168
- if (skuList.isEmpty()) {
169
- promise.reject(IapErrorCode.E_EMPTY_SKU_LIST, "The SKU list is empty.", null)
170
- return@ensureConnection
171
- }
103
+ if (!ok) {
104
+ // Clear any buffered events from a failed init
105
+ pendingEvents.clear()
106
+ promise.reject(OpenIapError.E_INIT_CONNECTION, "Failed to initialize connection", null)
107
+ return@withLock
108
+ }
172
109
 
173
- val params =
174
- QueryProductDetailsParams
175
- .newBuilder()
176
- .setProductList(skuList)
177
- .build()
110
+ // Mark ready then flush any buffered events
111
+ connectionReady.set(true)
112
+ while (true) {
113
+ val ev = pendingEvents.poll() ?: break
114
+ // Already on main dispatcher here; emit directly
115
+ runCatching { sendEvent(ev.first, ev.second) }
116
+ .onFailure { Log.e(TAG, "Failed to flush buffered event: ${ev.first}", it) }
117
+ }
178
118
 
179
- billingClient.queryProductDetailsAsync(params) { billingResult: BillingResult, productDetailsResult: QueryProductDetailsResult ->
180
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
181
- promise.reject(
182
- IapErrorCode.E_QUERY_PRODUCT,
183
- "Error querying product details: ${billingResult.debugMessage}",
184
- null,
185
- )
186
- return@queryProductDetailsAsync
119
+ promise.resolve(true)
120
+ } catch (e: Exception) {
121
+ promise.reject(OpenIapError.E_INIT_CONNECTION, e.message, e)
187
122
  }
188
-
189
- val productDetailsList = productDetailsResult.productDetailsList ?: emptyList()
190
-
191
- val items =
192
- productDetailsList.map { productDetails ->
193
- skus[productDetails.productId] = productDetails
194
-
195
- val currency = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode
196
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.priceCurrencyCode
197
- ?: "Unknown"
198
- val displayPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice
199
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
200
- ?: "N/A"
201
-
202
- // Prepare reusable data
203
- val oneTimePurchaseData = productDetails.oneTimePurchaseOfferDetails?.let {
204
- mapOf(
205
- "priceCurrencyCode" to it.priceCurrencyCode,
206
- "formattedPrice" to it.formattedPrice,
207
- "priceAmountMicros" to it.priceAmountMicros.toString(),
208
- )
209
- }
210
-
211
- val subscriptionOfferData = productDetails.subscriptionOfferDetails?.map { subscriptionOfferDetailsItem ->
212
- mapOf(
213
- "basePlanId" to subscriptionOfferDetailsItem.basePlanId,
214
- "offerId" to subscriptionOfferDetailsItem.offerId,
215
- "offerToken" to subscriptionOfferDetailsItem.offerToken,
216
- "offerTags" to subscriptionOfferDetailsItem.offerTags,
217
- "pricingPhases" to
218
- mapOf(
219
- "pricingPhaseList" to
220
- subscriptionOfferDetailsItem.pricingPhases.pricingPhaseList.map
221
- { pricingPhaseItem ->
222
- mapOf(
223
- "formattedPrice" to pricingPhaseItem.formattedPrice,
224
- "priceCurrencyCode" to pricingPhaseItem.priceCurrencyCode,
225
- "billingPeriod" to pricingPhaseItem.billingPeriod,
226
- "billingCycleCount" to pricingPhaseItem.billingCycleCount,
227
- "priceAmountMicros" to
228
- pricingPhaseItem.priceAmountMicros.toString(),
229
- "recurrenceMode" to pricingPhaseItem.recurrenceMode,
230
- )
231
- },
232
- ),
233
- )
234
- }
235
-
236
- // Convert Android productType to our expected 'inapp' or 'subs'
237
- val productType = if (productDetails.productType == BillingClient.ProductType.SUBS) "subs" else "inapp"
238
-
239
- mapOf(
240
- "id" to productDetails.productId,
241
- "title" to productDetails.title,
242
- "description" to productDetails.description,
243
- "type" to productType,
244
- // New field names with Android suffix
245
- "nameAndroid" to productDetails.name,
246
- "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseData,
247
- "subscriptionOfferDetailsAndroid" to subscriptionOfferData,
248
- "platform" to "android",
249
- "currency" to currency,
250
- "displayPrice" to displayPrice,
251
- // START: Deprecated - will be removed in v2.9.0
252
- // Use nameAndroid instead of displayName
253
- "displayName" to productDetails.name,
254
- // Use nameAndroid instead of name
255
- "name" to productDetails.name,
256
- // Use oneTimePurchaseOfferDetailsAndroid instead of oneTimePurchaseOfferDetails
257
- "oneTimePurchaseOfferDetails" to oneTimePurchaseData,
258
- // Use subscriptionOfferDetailsAndroid instead of subscriptionOfferDetails
259
- "subscriptionOfferDetails" to subscriptionOfferData,
260
- // END: Deprecated - will be removed in v2.9.0
261
- )
262
- }
263
- promise.resolve(items)
264
123
  }
265
124
  }
266
125
  }
267
126
 
268
- AsyncFunction("requestProducts") { type: String, skuArr: Array<String>, promise: Promise ->
269
- Log.w("ExpoIap", "WARNING: requestProducts is deprecated. Use fetchProducts instead. The 'request' prefix should only be used for event-based operations. This method will be removed in version 3.0.0.")
270
-
271
- ensureConnection(promise) { billingClient ->
272
- val skuList =
273
- skuArr.map { sku ->
274
- QueryProductDetailsParams.Product
275
- .newBuilder()
276
- .setProductId(sku)
277
- .setProductType(type)
278
- .build()
279
- }
280
-
281
- if (skuList.isEmpty()) {
282
- promise.reject(IapErrorCode.E_EMPTY_SKU_LIST, "The SKU list is empty.", null)
283
- return@ensureConnection
127
+ AsyncFunction("endConnection") { promise: Promise ->
128
+ scope.launch {
129
+ connectionMutex.withLock {
130
+ runCatching { openIap.endConnection() }
131
+ // Reset connection state and clear any buffered events
132
+ connectionReady.set(false)
133
+ pendingEvents.clear()
134
+ promise.resolve(true)
284
135
  }
136
+ }
137
+ }
285
138
 
286
- val params =
287
- QueryProductDetailsParams
288
- .newBuilder()
289
- .setProductList(skuList)
290
- .build()
291
-
292
- billingClient.queryProductDetailsAsync(params) { billingResult: BillingResult, productDetailsResult: QueryProductDetailsResult ->
293
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
294
- promise.reject(
295
- IapErrorCode.E_QUERY_PRODUCT,
296
- "Error querying product details: ${billingResult.debugMessage}",
297
- null,
298
- )
299
- return@queryProductDetailsAsync
300
- }
301
-
302
- val productDetailsList = productDetailsResult.productDetailsList ?: emptyList()
303
-
304
- val items =
305
- productDetailsList.map { productDetails ->
306
- skus[productDetails.productId] = productDetails
307
-
308
- val currency = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode
309
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.priceCurrencyCode
310
- ?: "Unknown"
311
- val displayPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice
312
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
313
- ?: "N/A"
314
-
315
- // Prepare reusable data
316
- val oneTimePurchaseData = productDetails.oneTimePurchaseOfferDetails?.let {
317
- mapOf(
318
- "priceCurrencyCode" to it.priceCurrencyCode,
319
- "formattedPrice" to it.formattedPrice,
320
- "priceAmountMicros" to it.priceAmountMicros.toString(),
321
- )
322
- }
323
-
324
- val subscriptionOfferData = productDetails.subscriptionOfferDetails?.map { subscriptionOfferDetailsItem ->
325
- mapOf(
326
- "basePlanId" to subscriptionOfferDetailsItem.basePlanId,
327
- "offerId" to subscriptionOfferDetailsItem.offerId,
328
- "offerToken" to subscriptionOfferDetailsItem.offerToken,
329
- "offerTags" to subscriptionOfferDetailsItem.offerTags,
330
- "pricingPhases" to
331
- mapOf(
332
- "pricingPhaseList" to
333
- subscriptionOfferDetailsItem.pricingPhases.pricingPhaseList.map
334
- { pricingPhaseItem ->
335
- mapOf(
336
- "formattedPrice" to pricingPhaseItem.formattedPrice,
337
- "priceCurrencyCode" to pricingPhaseItem.priceCurrencyCode,
338
- "billingPeriod" to pricingPhaseItem.billingPeriod,
339
- "billingCycleCount" to pricingPhaseItem.billingCycleCount,
340
- "priceAmountMicros" to
341
- pricingPhaseItem.priceAmountMicros.toString(),
342
- "recurrenceMode" to pricingPhaseItem.recurrenceMode,
343
- )
344
- },
345
- ),
346
- )
347
- }
139
+ AsyncFunction("fetchProducts") { type: String, skuArr: Array<String>, promise: Promise ->
140
+ scope.launch {
141
+ try {
142
+ val reqType = ProductRequest.ProductRequestType.fromString(type)
143
+ val products = openIap.fetchProducts(ProductRequest(skuArr.toList(), reqType))
144
+ promise.resolve(products.map { it.toJSON() })
145
+ } catch (e: Exception) {
146
+ promise.reject(OpenIapError.E_QUERY_PRODUCT, e.message, null)
147
+ }
148
+ }
149
+ }
348
150
 
349
- // Convert Android productType to our expected 'inapp' or 'subs'
350
- val productType = if (productDetails.productType == BillingClient.ProductType.SUBS) "subs" else "inapp"
351
-
352
- mapOf(
353
- "id" to productDetails.productId,
354
- "title" to productDetails.title,
355
- "description" to productDetails.description,
356
- "type" to productType,
357
- // New field names with Android suffix
358
- "nameAndroid" to productDetails.name,
359
- "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseData,
360
- "subscriptionOfferDetailsAndroid" to subscriptionOfferData,
361
- "platform" to "android",
362
- "currency" to currency,
363
- "displayPrice" to displayPrice,
364
- // START: Deprecated - will be removed in v2.9.0
365
- // Use nameAndroid instead of displayName
366
- "displayName" to productDetails.name,
367
- // Use nameAndroid instead of name
368
- "name" to productDetails.name,
369
- // Use oneTimePurchaseOfferDetailsAndroid instead of oneTimePurchaseOfferDetails
370
- "oneTimePurchaseOfferDetails" to oneTimePurchaseData,
371
- // Use subscriptionOfferDetailsAndroid instead of subscriptionOfferDetails
372
- "subscriptionOfferDetails" to subscriptionOfferData,
373
- // END: Deprecated - will be removed in v2.9.0
374
- )
375
- }
376
- promise.resolve(items)
151
+ AsyncFunction("getAvailableItems") { promise: Promise ->
152
+ scope.launch {
153
+ try {
154
+ val purchases = openIap.getAvailablePurchases(null)
155
+ promise.resolve(purchases.map { it.toJSON() })
156
+ } catch (e: Exception) {
157
+ promise.reject(OpenIapError.E_SERVICE_ERROR, e.message, null)
377
158
  }
378
159
  }
379
160
  }
380
161
 
381
- AsyncFunction("getAvailableItemsByType") { type: String, promise: Promise ->
382
- ensureConnection(promise) { billingClient ->
383
- val items = mutableListOf<Map<String, Any?>>()
384
- billingClient.queryPurchasesAsync(
385
- QueryPurchasesParams
386
- .newBuilder()
387
- .setProductType(
388
- if (type == "subs") BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP,
389
- ).build(),
390
- ) { billingResult: BillingResult, purchases: List<Purchase>? ->
391
- if (!isValidResult(billingResult, promise)) return@queryPurchasesAsync
392
- purchases?.forEach { purchase ->
393
- val item =
394
- mutableMapOf<String, Any?>(
395
- "id" to purchase.orderId,
396
- "productId" to purchase.products.firstOrNull() as Any?,
397
- "ids" to purchase.products,
398
- "transactionId" to purchase.orderId, // @deprecated - use id instead
399
- "transactionDate" to purchase.purchaseTime.toDouble(),
400
- "transactionReceipt" to purchase.originalJson,
401
- "orderId" to purchase.orderId,
402
- "purchaseTokenAndroid" to purchase.purchaseToken,
403
- "purchaseToken" to purchase.purchaseToken,
404
- "developerPayloadAndroid" to purchase.developerPayload,
405
- "signatureAndroid" to purchase.signature,
406
- "purchaseStateAndroid" to purchase.purchaseState,
407
- "isAcknowledgedAndroid" to purchase.isAcknowledged,
408
- "packageNameAndroid" to purchase.packageName,
409
- "obfuscatedAccountIdAndroid" to purchase.accountIdentifiers?.obfuscatedAccountId,
410
- "obfuscatedProfileIdAndroid" to purchase.accountIdentifiers?.obfuscatedProfileId,
411
- "platform" to "android",
412
- )
413
- if (type == BillingClient.ProductType.SUBS) {
414
- item["autoRenewingAndroid"] = purchase.isAutoRenewing
415
- }
416
- items.add(item)
417
- }
418
- promise.resolve(items)
162
+ // Deep link to Manage Subscriptions screen (Android)
163
+ AsyncFunction("deepLinkToSubscriptionsAndroid") { params: Map<String, Any?>, promise: Promise ->
164
+ val sku = (params["sku"] ?: params["skuAndroid"]) as? String
165
+ val packageName = (params["packageName"] ?: params["packageNameAndroid"]) as? String
166
+ scope.launch {
167
+ try {
168
+ openIap.deepLinkToSubscriptions(DeepLinkOptions(sku, packageName))
169
+ promise.resolve(null)
170
+ } catch (e: Exception) {
171
+ promise.reject(OpenIapError.E_SERVICE_ERROR, e.message, null)
419
172
  }
420
173
  }
421
174
  }
422
175
 
423
- // getPurchaseHistoryByType removed in Google Play Billing Library v8
424
- // Use getAvailableItemsByType instead to get active purchases
176
+ // Get Google Play storefront country code (Android)
177
+ AsyncFunction("getStorefrontAndroid") { promise: Promise ->
178
+ scope.launch {
179
+ try {
180
+ val code = openIap.getStorefront()
181
+ promise.resolve(code)
182
+ } catch (e: Exception) {
183
+ promise.reject(OpenIapError.E_SERVICE_ERROR, e.message, e)
184
+ }
185
+ }
186
+ }
425
187
 
426
188
  AsyncFunction("requestPurchase") { params: Map<String, Any?>, promise: Promise ->
427
189
  val type = params["type"] as String
428
- val skuArr =
429
- (params["skuArr"] as? List<*>)?.filterIsInstance<String>()?.toTypedArray()
430
- ?: emptyArray()
431
- val purchaseToken = params["purchaseToken"] as? String
432
- val replacementMode = (params["replacementMode"] as? Double)?.toInt() ?: -1
433
- val obfuscatedAccountId = params["obfuscatedAccountId"] as? String
434
- val obfuscatedProfileId = params["obfuscatedProfileId"] as? String
435
- val offerTokenArr =
436
- (params["offerTokenArr"] as? List<*>)
437
- ?.filterIsInstance<String>()
438
- ?.toTypedArray() ?: emptyArray()
190
+ val skus: List<String> =
191
+ (params["skus"] as? List<*>)?.filterIsInstance<String>()
192
+ ?: (params["skuArr"] as? List<*>)?.filterIsInstance<String>()
193
+ ?: emptyList()
194
+ val obfuscatedAccountId =
195
+ (params["obfuscatedAccountIdAndroid"] ?: params["obfuscatedAccountId"]) as? String
196
+ val obfuscatedProfileId =
197
+ (params["obfuscatedProfileIdAndroid"] ?: params["obfuscatedProfileId"]) as? String
439
198
  val isOfferPersonalized = params["isOfferPersonalized"] as? Boolean ?: false
440
199
 
441
- if (currentActivity == null) {
442
- promise.reject(IapErrorCode.E_UNKNOWN, "getCurrentActivity returned null", null)
443
- return@AsyncFunction
444
- }
445
-
446
- ensureConnection(promise) { billingClient ->
447
- PromiseUtils.addPromiseForKey(IapConstants.PROMISE_BUY_ITEM, promise)
448
-
449
- if (type == BillingClient.ProductType.SUBS && skuArr.size != offerTokenArr.size) {
450
- val debugMessage = "The number of skus (${skuArr.size}) must match: the number of offerTokens (${offerTokenArr.size}) for Subscriptions"
451
- try {
452
- sendEvent(
453
- OpenIapEvent.PURCHASE_ERROR,
454
- mapOf(
455
- "debugMessage" to debugMessage,
456
- "code" to IapErrorCode.E_SKU_OFFER_MISMATCH,
457
- "message" to debugMessage,
458
- )
200
+ PromiseUtils.addPromiseForKey(PromiseUtils.PROMISE_BUY_ITEM, promise)
201
+ scope.launch {
202
+ try {
203
+ openIap.setActivity(currentActivity)
204
+ val reqType = ProductRequest.ProductRequestType.fromString(type)
205
+ val result =
206
+ openIap.requestPurchase(
207
+ RequestPurchaseAndroidProps(
208
+ skus = skus,
209
+ obfuscatedAccountIdAndroid = obfuscatedAccountId,
210
+ obfuscatedProfileIdAndroid = obfuscatedProfileId,
211
+ isOfferPersonalized = isOfferPersonalized,
212
+ ),
213
+ reqType,
459
214
  )
460
- } catch (e: Exception) {
461
- Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
462
- }
463
- promise.reject(IapErrorCode.E_SKU_OFFER_MISMATCH, debugMessage, null)
464
- return@ensureConnection
465
- }
466
-
467
- val productParamsList =
468
- skuArr.mapIndexed { index, sku ->
469
- val selectedSku = skus[sku]
470
- if (selectedSku == null) {
471
- val debugMessage =
472
- "The sku was not found. Please fetch products first by calling getItems"
473
- try {
474
- sendEvent(
475
- OpenIapEvent.PURCHASE_ERROR,
476
- mapOf(
477
- "debugMessage" to debugMessage,
478
- "code" to IapErrorCode.E_SKU_NOT_FOUND,
479
- "message" to debugMessage,
480
- "productId" to sku,
481
- ),
482
- )
483
- } catch (e: Exception) {
484
- Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
485
- }
486
- promise.reject(IapErrorCode.E_SKU_NOT_FOUND, debugMessage, null)
487
- return@ensureConnection
488
- }
489
-
490
- val productDetailParams =
491
- BillingFlowParams.ProductDetailsParams
492
- .newBuilder()
493
- .setProductDetails(selectedSku)
494
-
495
- if (type == BillingClient.ProductType.SUBS) {
496
- productDetailParams.setOfferToken(offerTokenArr[index])
215
+ result.forEach { p ->
216
+ try {
217
+ emitOrQueue(EVENT_PURCHASE_UPDATED, p.toJSON())
218
+ } catch (ex: Exception) {
219
+ Log.e(TAG, "Failed to send PURCHASE_UPDATED event (requestPurchase)", ex)
497
220
  }
498
-
499
- productDetailParams.build()
500
- }
501
-
502
- val builder =
503
- BillingFlowParams
504
- .newBuilder()
505
- .setProductDetailsParamsList(productParamsList)
506
- .setIsOfferPersonalized(isOfferPersonalized)
507
-
508
- if (purchaseToken != null) {
509
- val subscriptionUpdateParams =
510
- SubscriptionUpdateParams
511
- .newBuilder()
512
- .setOldPurchaseToken(purchaseToken)
513
-
514
- if (type == BillingClient.ProductType.SUBS && replacementMode != -1) {
515
- val mode =
516
- when (replacementMode) {
517
- SubscriptionUpdateParams.ReplacementMode.CHARGE_PRORATED_PRICE ->
518
- SubscriptionUpdateParams.ReplacementMode.CHARGE_PRORATED_PRICE
519
- SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION ->
520
- SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION
521
- SubscriptionUpdateParams.ReplacementMode.DEFERRED ->
522
- SubscriptionUpdateParams.ReplacementMode.DEFERRED
523
- SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION ->
524
- SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION
525
- SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE ->
526
- SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE
527
- else -> SubscriptionUpdateParams.ReplacementMode.UNKNOWN_REPLACEMENT_MODE
528
- }
529
- subscriptionUpdateParams.setSubscriptionReplacementMode(mode)
530
221
  }
531
- builder.setSubscriptionUpdateParams(subscriptionUpdateParams.build())
532
- }
533
-
534
- obfuscatedAccountId?.let { builder.setObfuscatedAccountId(it) }
535
- obfuscatedProfileId?.let { builder.setObfuscatedProfileId(it) }
536
-
537
- val flowParams = builder.build()
538
- val billingResult = billingClient.launchBillingFlow(currentActivity, flowParams)
539
-
540
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
541
- val errorData = PlayUtils.getBillingResponseData(billingResult.responseCode)
542
- var errorMessage = billingResult.debugMessage ?: errorData.message
543
- var subResponseCode: Int? = null
544
-
545
- // Check for sub-response codes (v8.0.0+)
222
+ PromiseUtils.resolvePromisesForKey(PromiseUtils.PROMISE_BUY_ITEM, result.map { it.toJSON() })
223
+ } catch (e: Exception) {
224
+ val errorMap =
225
+ mapOf(
226
+ "code" to OpenIapError.E_PURCHASE_ERROR,
227
+ "message" to (e.message ?: "Purchase failed"),
228
+ "platform" to "android",
229
+ )
546
230
  try {
547
- subResponseCode = billingResult.javaClass.getMethod("getSubResponseCode").invoke(billingResult) as? Int
548
- if (subResponseCode != null && subResponseCode != 0) {
549
- if (subResponseCode == 1) { // PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS
550
- errorMessage = "$errorMessage (Payment declined due to insufficient funds)"
551
- } else {
552
- errorMessage = "$errorMessage (Sub-response code: $subResponseCode)"
553
- }
554
- }
555
- } catch (e: Exception) {
556
- // Method doesn't exist in older versions, ignore
231
+ emitOrQueue(EVENT_PURCHASE_ERROR, errorMap)
232
+ } catch (ex: Exception) {
233
+ Log.e(TAG, "Failed to send PURCHASE_ERROR event (requestPurchase)", ex)
557
234
  }
558
-
559
- // Send error event to match iOS behavior
560
- val errorMap = mutableMapOf<String, Any?>(
561
- "responseCode" to billingResult.responseCode,
562
- "debugMessage" to billingResult.debugMessage,
563
- "code" to errorData.code,
564
- "message" to errorMessage
235
+ // Reject and clear any pending promises for this purchase flow
236
+ PromiseUtils.rejectPromisesForKey(
237
+ PromiseUtils.PROMISE_BUY_ITEM,
238
+ OpenIapError.E_PURCHASE_ERROR,
239
+ e.message,
240
+ null,
565
241
  )
566
-
567
- // Add product ID if available
568
- if (skuArr.isNotEmpty()) {
569
- errorMap["productId"] = skuArr.first()
570
- }
571
-
572
- // Add sub-response code if available
573
- subResponseCode?.let {
574
- if (it != 0) {
575
- errorMap["subResponseCode"] = it
576
- }
577
- }
578
-
579
- try {
580
- sendEvent(OpenIapEvent.PURCHASE_ERROR, errorMap.toMap())
581
- } catch (e: Exception) {
582
- Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
583
- }
584
-
585
- promise.reject(errorData.code, errorMessage, null)
586
- return@ensureConnection
587
242
  }
588
243
  }
589
244
  }
590
245
 
591
- AsyncFunction("acknowledgePurchaseAndroid") {
592
- token: String,
593
- promise: Promise,
594
- ->
595
-
596
- ensureConnection(promise) { billingClient ->
597
- val acknowledgePurchaseParams =
598
- AcknowledgePurchaseParams
599
- .newBuilder()
600
- .setPurchaseToken(token)
601
- .build()
602
-
603
- billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult: BillingResult ->
604
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
605
- PlayUtils.rejectPromiseWithBillingError(
606
- promise,
607
- billingResult.responseCode,
608
- )
609
- return@acknowledgePurchase
610
- }
611
-
612
- val map = mutableMapOf<String, Any?>()
613
- map["responseCode"] = billingResult.responseCode
614
- map["debugMessage"] = billingResult.debugMessage
615
- val errorData = PlayUtils.getBillingResponseData(billingResult.responseCode)
616
- map["code"] = errorData.code
617
- map["message"] = errorData.message
618
- promise.resolve(map)
246
+ AsyncFunction("acknowledgePurchaseAndroid") { token: String, promise: Promise ->
247
+ scope.launch {
248
+ try {
249
+ openIap.acknowledgePurchaseAndroid(token)
250
+ promise.resolve(mapOf("responseCode" to 0))
251
+ } catch (e: Exception) {
252
+ promise.reject(OpenIapError.E_SERVICE_ERROR, e.message, null)
619
253
  }
620
254
  }
621
255
  }
622
256
 
623
- AsyncFunction("consumeProductAndroid") {
624
- token: String,
625
- promise: Promise,
626
- ->
627
-
628
- val params = ConsumeParams.newBuilder().setPurchaseToken(token).build()
629
-
630
- ensureConnection(promise) { billingClient ->
631
- billingClient.consumeAsync(params) { billingResult: BillingResult, purchaseToken: String? ->
632
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
633
- PlayUtils.rejectPromiseWithBillingError(
634
- promise,
635
- billingResult.responseCode,
636
- )
637
- return@consumeAsync
638
- }
639
-
640
- val map = mutableMapOf<String, Any?>()
641
- map["responseCode"] = billingResult.responseCode
642
- map["debugMessage"] = billingResult.debugMessage
643
- val errorData = PlayUtils.getBillingResponseData(billingResult.responseCode)
644
- map["code"] = errorData.code
645
- map["message"] = errorData.message
646
- map["purchaseTokenAndroid"] = purchaseToken
647
- promise.resolve(map)
257
+ // New name: consumePurchaseAndroid
258
+ AsyncFunction("consumePurchaseAndroid") { token: String, promise: Promise ->
259
+ scope.launch {
260
+ try {
261
+ openIap.consumePurchaseAndroid(token)
262
+ promise.resolve(mapOf("responseCode" to 0, "purchaseToken" to token))
263
+ } catch (e: Exception) {
264
+ promise.reject(OpenIapError.E_SERVICE_ERROR, e.message, null)
648
265
  }
649
266
  }
650
267
  }
651
268
 
652
-
653
- AsyncFunction("getStorefront") {
654
- promise: Promise,
655
- ->
656
- ensureConnection(promise) { billingClient ->
657
- billingClient.getBillingConfigAsync(
658
- GetBillingConfigParams.newBuilder().build(),
659
- BillingConfigResponseListener { result: BillingResult, config: BillingConfig? ->
660
- if (result.responseCode == BillingClient.BillingResponseCode.OK) {
661
- promise.safeResolve(config?.countryCode.orEmpty())
662
- } else {
663
- val debugMessage = result.debugMessage.orEmpty()
664
- promise.safeReject(result.responseCode.toString(), debugMessage)
665
- }
666
- },
667
- )
668
- }
269
+ OnDestroy {
270
+ job.cancel()
669
271
  }
670
272
  }
671
-
672
- /**
673
- * Rejects promise with billing code if BillingResult is not OK
674
- */
675
- private fun isValidResult(
676
- billingResult: BillingResult,
677
- promise: Promise,
678
- ): Boolean {
679
- Log.d(TAG, "responseCode: " + billingResult.responseCode)
680
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
681
- PlayUtils.rejectPromiseWithBillingError(promise, billingResult.responseCode)
682
- return false
683
- }
684
- return true
685
- }
686
-
687
- private fun ensureConnection(
688
- promise: Promise,
689
- callback: (billingClient: BillingClient) -> Unit,
690
- ) {
691
- // With auto-reconnection enabled, we only need to check if billing client exists
692
- // The service will automatically reconnect when needed
693
- if (billingClientCache != null) {
694
- callback(billingClientCache!!)
695
- return
696
- }
697
-
698
- initBillingClient(promise, callback)
699
- }
700
-
701
- private fun initBillingClient(
702
- promise: Promise,
703
- callback: (billingClient: BillingClient) -> Unit,
704
- ) {
705
- if (GoogleApiAvailability
706
- .getInstance()
707
- .isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS
708
- ) {
709
- Log.i(TAG, "Google Play Services are not available on this device")
710
- promise.reject(
711
- IapErrorCode.E_NOT_PREPARED,
712
- "Google Play Services are not available on this device",
713
- null,
714
- )
715
- return
716
- }
717
-
718
- billingClientCache =
719
- BillingClient
720
- .newBuilder(context)
721
- .setListener(this)
722
- .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
723
- .enableAutoServiceReconnection() // Automatically handle service disconnections
724
- .build()
725
-
726
- billingClientCache?.startConnection(
727
- object : BillingClientStateListener {
728
- override fun onBillingSetupFinished(billingResult: BillingResult) {
729
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
730
- promise.reject(
731
- IapErrorCode.E_INIT_CONNECTION,
732
- "Billing setup finished with error: ${billingResult.debugMessage}",
733
- null,
734
- )
735
- return
736
- }
737
- callback(billingClientCache!!)
738
- }
739
-
740
- override fun onBillingServiceDisconnected() {
741
- Log.i(TAG, "Billing service disconnected")
742
- }
743
- },
744
- )
745
- }
746
273
  }