expo-iap 2.9.7 → 3.0.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +10 -2
  3. package/android/build.gradle +7 -2
  4. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +195 -650
  5. package/android/src/main/java/expo/modules/iap/PromiseUtils.kt +85 -0
  6. package/build/ExpoIap.types.d.ts +0 -6
  7. package/build/ExpoIap.types.d.ts.map +1 -1
  8. package/build/ExpoIap.types.js.map +1 -1
  9. package/build/helpers/subscription.d.ts.map +1 -1
  10. package/build/helpers/subscription.js +14 -3
  11. package/build/helpers/subscription.js.map +1 -1
  12. package/build/index.d.ts +6 -73
  13. package/build/index.d.ts.map +1 -1
  14. package/build/index.js +21 -154
  15. package/build/index.js.map +1 -1
  16. package/build/modules/android.d.ts +2 -2
  17. package/build/modules/android.d.ts.map +1 -1
  18. package/build/modules/android.js +11 -1
  19. package/build/modules/android.js.map +1 -1
  20. package/build/modules/ios.d.ts +0 -60
  21. package/build/modules/ios.d.ts.map +1 -1
  22. package/build/modules/ios.js +2 -121
  23. package/build/modules/ios.js.map +1 -1
  24. package/build/types/ExpoIapAndroid.types.d.ts +0 -8
  25. package/build/types/ExpoIapAndroid.types.d.ts.map +1 -1
  26. package/build/types/ExpoIapAndroid.types.js +0 -1
  27. package/build/types/ExpoIapAndroid.types.js.map +1 -1
  28. package/build/types/ExpoIapIOS.types.d.ts +0 -5
  29. package/build/types/ExpoIapIOS.types.d.ts.map +1 -1
  30. package/build/types/ExpoIapIOS.types.js.map +1 -1
  31. package/build/useIAP.d.ts +0 -18
  32. package/build/useIAP.d.ts.map +1 -1
  33. package/build/useIAP.js +1 -18
  34. package/build/useIAP.js.map +1 -1
  35. package/bun.lock +340 -137
  36. package/codecov.yml +17 -21
  37. package/ios/ExpoIapModule.swift +10 -3
  38. package/jest.config.js +5 -9
  39. package/package.json +5 -3
  40. package/plugin/build/withIAP.d.ts +4 -1
  41. package/plugin/build/withIAP.js +48 -28
  42. package/plugin/build/withLocalOpenIAP.d.ts +6 -2
  43. package/plugin/build/withLocalOpenIAP.js +179 -20
  44. package/plugin/src/withIAP.ts +81 -37
  45. package/plugin/src/withLocalOpenIAP.ts +232 -24
  46. package/src/ExpoIap.types.ts +0 -8
  47. package/src/helpers/subscription.ts +14 -3
  48. package/src/index.ts +22 -230
  49. package/src/modules/android.ts +16 -6
  50. package/src/modules/ios.ts +2 -168
  51. package/src/types/ExpoIapAndroid.types.ts +0 -11
  52. package/src/types/ExpoIapIOS.types.ts +0 -5
  53. package/src/useIAP.ts +3 -55
  54. package/android/src/main/java/expo/modules/iap/PlayUtils.kt +0 -178
  55. package/android/src/main/java/expo/modules/iap/Types.kt +0 -98
@@ -2,727 +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
- }
149
-
150
- AsyncFunction("endConnection") { promise: Promise ->
151
- billingClientCache?.endConnection()
152
- billingClientCache = null
153
- skus.clear()
154
- promise.resolve(true)
155
- }
156
-
157
- AsyncFunction("fetchProducts") { type: String, skuArr: Array<String>, promise: Promise ->
158
- val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
159
-
160
- val skuList =
161
- skuArr.map { sku ->
162
- QueryProductDetailsParams.Product
163
- .newBuilder()
164
- .setProductId(sku)
165
- .setProductType(type)
166
- .build()
167
- }
168
-
169
- if (skuList.isEmpty()) {
170
- promise.reject(IapErrorCode.E_EMPTY_SKU_LIST, "The SKU list is empty.", null)
171
- return@AsyncFunction
172
- }
173
-
174
- val params =
175
- QueryProductDetailsParams
176
- .newBuilder()
177
- .setProductList(skuList)
178
- .build()
179
-
180
- billingClient.queryProductDetailsAsync(params) { billingResult: BillingResult, productDetailsResult: QueryProductDetailsResult ->
181
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
182
- promise.reject(
183
- IapErrorCode.E_QUERY_PRODUCT,
184
- "Error querying product details: ${billingResult.debugMessage}",
185
- null,
186
- )
187
- return@queryProductDetailsAsync
188
- }
189
-
190
- val productDetailsList = productDetailsResult.productDetailsList ?: emptyList()
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
+ }
191
87
 
192
- val items =
193
- productDetailsList.map { productDetails ->
194
- skus[productDetails.productId] = productDetails
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
+ }
195
100
 
196
- val currency = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode
197
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.priceCurrencyCode
198
- ?: "Unknown"
199
- val displayPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice
200
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
201
- ?: "N/A"
101
+ val ok = openIap.initConnection()
202
102
 
203
- // Prepare reusable data
204
- val oneTimePurchaseData = productDetails.oneTimePurchaseOfferDetails?.let {
205
- mapOf(
206
- "priceCurrencyCode" to it.priceCurrencyCode,
207
- "formattedPrice" to it.formattedPrice,
208
- "priceAmountMicros" to it.priceAmountMicros.toString(),
209
- )
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
210
108
  }
211
109
 
212
- val subscriptionOfferData = productDetails.subscriptionOfferDetails?.map { subscriptionOfferDetailsItem ->
213
- mapOf(
214
- "basePlanId" to subscriptionOfferDetailsItem.basePlanId,
215
- "offerId" to subscriptionOfferDetailsItem.offerId,
216
- "offerToken" to subscriptionOfferDetailsItem.offerToken,
217
- "offerTags" to subscriptionOfferDetailsItem.offerTags,
218
- "pricingPhases" to
219
- mapOf(
220
- "pricingPhaseList" to
221
- subscriptionOfferDetailsItem.pricingPhases.pricingPhaseList.map
222
- { pricingPhaseItem ->
223
- mapOf(
224
- "formattedPrice" to pricingPhaseItem.formattedPrice,
225
- "priceCurrencyCode" to pricingPhaseItem.priceCurrencyCode,
226
- "billingPeriod" to pricingPhaseItem.billingPeriod,
227
- "billingCycleCount" to pricingPhaseItem.billingCycleCount,
228
- "priceAmountMicros" to
229
- pricingPhaseItem.priceAmountMicros.toString(),
230
- "recurrenceMode" to pricingPhaseItem.recurrenceMode,
231
- )
232
- },
233
- ),
234
- )
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) }
235
117
  }
236
118
 
237
- // Convert Android productType to our expected 'inapp' or 'subs'
238
- val productType = if (productDetails.productType == BillingClient.ProductType.SUBS) "subs" else "inapp"
239
-
240
- mapOf(
241
- "id" to productDetails.productId,
242
- "title" to productDetails.title,
243
- "description" to productDetails.description,
244
- "type" to productType,
245
- // New field names with Android suffix
246
- "nameAndroid" to productDetails.name,
247
- "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseData,
248
- "subscriptionOfferDetailsAndroid" to subscriptionOfferData,
249
- "platform" to "android",
250
- "currency" to currency,
251
- "displayPrice" to displayPrice,
252
-
253
- )
119
+ promise.resolve(true)
120
+ } catch (e: Exception) {
121
+ promise.reject(OpenIapError.E_INIT_CONNECTION, e.message, e)
254
122
  }
255
- promise.resolve(items)
123
+ }
256
124
  }
257
125
  }
258
126
 
259
- AsyncFunction("requestProducts") { type: String, skuArr: Array<String>, promise: Promise ->
260
- 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.")
261
- val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
262
-
263
- val skuList =
264
- skuArr.map { sku ->
265
- QueryProductDetailsParams.Product
266
- .newBuilder()
267
- .setProductId(sku)
268
- .setProductType(type)
269
- .build()
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)
270
135
  }
271
-
272
- if (skuList.isEmpty()) {
273
- promise.reject(IapErrorCode.E_EMPTY_SKU_LIST, "The SKU list is empty.", null)
274
- return@AsyncFunction
275
136
  }
137
+ }
276
138
 
277
- val params =
278
- QueryProductDetailsParams
279
- .newBuilder()
280
- .setProductList(skuList)
281
- .build()
282
-
283
- billingClient.queryProductDetailsAsync(params) { billingResult: BillingResult, productDetailsResult: QueryProductDetailsResult ->
284
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
285
- promise.reject(
286
- IapErrorCode.E_QUERY_PRODUCT,
287
- "Error querying product details: ${billingResult.debugMessage}",
288
- null,
289
- )
290
- return@queryProductDetailsAsync
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)
291
147
  }
148
+ }
149
+ }
292
150
 
293
- val productDetailsList = productDetailsResult.productDetailsList ?: emptyList()
294
-
295
- val items =
296
- productDetailsList.map { productDetails ->
297
- skus[productDetails.productId] = productDetails
298
-
299
- val currency = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode
300
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.priceCurrencyCode
301
- ?: "Unknown"
302
- val displayPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice
303
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
304
- ?: "N/A"
305
-
306
- // Prepare reusable data
307
- val oneTimePurchaseData = productDetails.oneTimePurchaseOfferDetails?.let {
308
- mapOf(
309
- "priceCurrencyCode" to it.priceCurrencyCode,
310
- "formattedPrice" to it.formattedPrice,
311
- "priceAmountMicros" to it.priceAmountMicros.toString(),
312
- )
313
- }
314
-
315
- val subscriptionOfferData = productDetails.subscriptionOfferDetails?.map { subscriptionOfferDetailsItem ->
316
- mapOf(
317
- "basePlanId" to subscriptionOfferDetailsItem.basePlanId,
318
- "offerId" to subscriptionOfferDetailsItem.offerId,
319
- "offerToken" to subscriptionOfferDetailsItem.offerToken,
320
- "offerTags" to subscriptionOfferDetailsItem.offerTags,
321
- "pricingPhases" to
322
- mapOf(
323
- "pricingPhaseList" to
324
- subscriptionOfferDetailsItem.pricingPhases.pricingPhaseList.map
325
- { pricingPhaseItem ->
326
- mapOf(
327
- "formattedPrice" to pricingPhaseItem.formattedPrice,
328
- "priceCurrencyCode" to pricingPhaseItem.priceCurrencyCode,
329
- "billingPeriod" to pricingPhaseItem.billingPeriod,
330
- "billingCycleCount" to pricingPhaseItem.billingCycleCount,
331
- "priceAmountMicros" to
332
- pricingPhaseItem.priceAmountMicros.toString(),
333
- "recurrenceMode" to pricingPhaseItem.recurrenceMode,
334
- )
335
- },
336
- ),
337
- )
338
- }
339
-
340
- // Convert Android productType to our expected 'inapp' or 'subs'
341
- val productType = if (productDetails.productType == BillingClient.ProductType.SUBS) "subs" else "inapp"
342
-
343
- mapOf(
344
- "id" to productDetails.productId,
345
- "title" to productDetails.title,
346
- "description" to productDetails.description,
347
- "type" to productType,
348
- // New field names with Android suffix
349
- "nameAndroid" to productDetails.name,
350
- "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseData,
351
- "subscriptionOfferDetailsAndroid" to subscriptionOfferData,
352
- "platform" to "android",
353
- "currency" to currency,
354
- "displayPrice" to displayPrice,
355
-
356
- )
357
- }
358
- 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)
158
+ }
359
159
  }
360
160
  }
361
161
 
362
- AsyncFunction("getAvailableItemsByType") { type: String, promise: Promise ->
363
- val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
364
- val items = mutableListOf<Map<String, Any?>>()
365
- billingClient.queryPurchasesAsync(
366
- QueryPurchasesParams
367
- .newBuilder()
368
- .setProductType(
369
- if (type == "subs") BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP,
370
- ).build(),
371
- ) { billingResult: BillingResult, purchases: List<Purchase>? ->
372
- if (!isValidResult(billingResult, promise)) return@queryPurchasesAsync
373
- purchases?.forEach { purchase ->
374
- val item =
375
- mutableMapOf<String, Any?>(
376
- "id" to purchase.orderId,
377
- "productId" to purchase.products.firstOrNull() as Any?,
378
- "ids" to purchase.products,
379
- "transactionId" to purchase.orderId, // @deprecated - use id instead
380
- "transactionDate" to purchase.purchaseTime.toDouble(),
381
- "transactionReceipt" to purchase.originalJson,
382
- "orderId" to purchase.orderId,
383
- "purchaseTokenAndroid" to purchase.purchaseToken,
384
- "purchaseToken" to purchase.purchaseToken,
385
- "developerPayloadAndroid" to purchase.developerPayload,
386
- "signatureAndroid" to purchase.signature,
387
- "purchaseStateAndroid" to purchase.purchaseState,
388
- "isAcknowledgedAndroid" to purchase.isAcknowledged,
389
- "packageNameAndroid" to purchase.packageName,
390
- "obfuscatedAccountIdAndroid" to purchase.accountIdentifiers?.obfuscatedAccountId,
391
- "obfuscatedProfileIdAndroid" to purchase.accountIdentifiers?.obfuscatedProfileId,
392
- "platform" to "android",
393
- )
394
- if (type == BillingClient.ProductType.SUBS) {
395
- item["autoRenewingAndroid"] = purchase.isAutoRenewing
396
- }
397
- items.add(item)
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)
398
172
  }
399
- promise.resolve(items)
400
173
  }
401
174
  }
402
175
 
403
- // getPurchaseHistoryByType removed in Google Play Billing Library v8
404
- // 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
+ }
405
187
 
406
188
  AsyncFunction("requestPurchase") { params: Map<String, Any?>, promise: Promise ->
407
189
  val type = params["type"] as String
408
- val skuArr =
409
- (params["skuArr"] as? List<*>)?.filterIsInstance<String>()?.toTypedArray()
410
- ?: emptyArray()
411
- val purchaseToken = params["purchaseToken"] as? String
412
- val replacementMode = (params["replacementMode"] as? Double)?.toInt() ?: -1
413
- val obfuscatedAccountId = params["obfuscatedAccountId"] as? String
414
- val obfuscatedProfileId = params["obfuscatedProfileId"] as? String
415
- val offerTokenArr =
416
- (params["offerTokenArr"] as? List<*>)
417
- ?.filterIsInstance<String>()
418
- ?.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
419
198
  val isOfferPersonalized = params["isOfferPersonalized"] as? Boolean ?: false
420
199
 
421
- if (currentActivity == null) {
422
- promise.reject(IapErrorCode.E_UNKNOWN, "getCurrentActivity returned null", null)
423
- return@AsyncFunction
424
- }
425
-
426
- val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
427
- PromiseUtils.addPromiseForKey(IapConstants.PROMISE_BUY_ITEM, promise)
428
-
429
- if (type == BillingClient.ProductType.SUBS && skuArr.size != offerTokenArr.size) {
430
- val debugMessage = "The number of skus (${skuArr.size}) must match: the number of offerTokens (${offerTokenArr.size}) for Subscriptions"
431
- try {
432
- sendEvent(
433
- OpenIapEvent.PURCHASE_ERROR,
434
- mapOf(
435
- "debugMessage" to debugMessage,
436
- "code" to IapErrorCode.E_SKU_OFFER_MISMATCH,
437
- "message" to debugMessage,
438
- )
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,
439
214
  )
440
- } catch (e: Exception) {
441
- Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
442
- }
443
- promise.reject(IapErrorCode.E_SKU_OFFER_MISMATCH, debugMessage, null)
444
- return@AsyncFunction
445
- }
446
-
447
- val productParamsList =
448
- skuArr.mapIndexed { index, sku ->
449
- val selectedSku = skus[sku]
450
- if (selectedSku == null) {
451
- val debugMessage =
452
- "The sku was not found. Please fetch products first by calling getItems"
453
- try {
454
- sendEvent(
455
- OpenIapEvent.PURCHASE_ERROR,
456
- mapOf(
457
- "debugMessage" to debugMessage,
458
- "code" to IapErrorCode.E_SKU_NOT_FOUND,
459
- "message" to debugMessage,
460
- "productId" to sku,
461
- ),
462
- )
463
- } catch (e: Exception) {
464
- Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
465
- }
466
- promise.reject(IapErrorCode.E_SKU_NOT_FOUND, debugMessage, null)
467
- return@AsyncFunction
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)
468
220
  }
469
-
470
- val productDetailParams =
471
- BillingFlowParams.ProductDetailsParams
472
- .newBuilder()
473
- .setProductDetails(selectedSku)
474
-
475
- if (type == BillingClient.ProductType.SUBS) {
476
- productDetailParams.setOfferToken(offerTokenArr[index])
477
- }
478
-
479
- productDetailParams.build()
480
221
  }
481
-
482
- val builder =
483
- BillingFlowParams
484
- .newBuilder()
485
- .setProductDetailsParamsList(productParamsList)
486
- .setIsOfferPersonalized(isOfferPersonalized)
487
-
488
- if (purchaseToken != null) {
489
- val subscriptionUpdateParams =
490
- SubscriptionUpdateParams
491
- .newBuilder()
492
- .setOldPurchaseToken(purchaseToken)
493
-
494
- if (type == BillingClient.ProductType.SUBS && replacementMode != -1) {
495
- val mode =
496
- when (replacementMode) {
497
- SubscriptionUpdateParams.ReplacementMode.CHARGE_PRORATED_PRICE ->
498
- SubscriptionUpdateParams.ReplacementMode.CHARGE_PRORATED_PRICE
499
- SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION ->
500
- SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION
501
- SubscriptionUpdateParams.ReplacementMode.DEFERRED ->
502
- SubscriptionUpdateParams.ReplacementMode.DEFERRED
503
- SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION ->
504
- SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION
505
- SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE ->
506
- SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE
507
- else -> SubscriptionUpdateParams.ReplacementMode.UNKNOWN_REPLACEMENT_MODE
508
- }
509
- subscriptionUpdateParams.setSubscriptionReplacementMode(mode)
510
- }
511
- builder.setSubscriptionUpdateParams(subscriptionUpdateParams.build())
512
- }
513
-
514
- obfuscatedAccountId?.let { builder.setObfuscatedAccountId(it) }
515
- obfuscatedProfileId?.let { builder.setObfuscatedProfileId(it) }
516
-
517
- val flowParams = builder.build()
518
- val billingResult = billingClient.launchBillingFlow(currentActivity, flowParams)
519
-
520
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
521
- val errorData = PlayUtils.getBillingResponseData(billingResult.responseCode)
522
- var errorMessage = billingResult.debugMessage ?: errorData.message
523
- var subResponseCode: Int? = null
524
-
525
- // 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
+ )
526
230
  try {
527
- subResponseCode = billingResult.javaClass.getMethod("getSubResponseCode").invoke(billingResult) as? Int
528
- if (subResponseCode != null && subResponseCode != 0) {
529
- if (subResponseCode == 1) { // PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS
530
- errorMessage = "$errorMessage (Payment declined due to insufficient funds)"
531
- } else {
532
- errorMessage = "$errorMessage (Sub-response code: $subResponseCode)"
533
- }
534
- }
535
- } catch (e: Exception) {
536
- // 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)
537
234
  }
538
-
539
- // Send error event to match iOS behavior
540
- val errorMap = mutableMapOf<String, Any?>(
541
- "responseCode" to billingResult.responseCode,
542
- "debugMessage" to billingResult.debugMessage,
543
- "code" to errorData.code,
544
- "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,
545
241
  )
546
-
547
- // Add product ID if available
548
- if (skuArr.isNotEmpty()) {
549
- errorMap["productId"] = skuArr.first()
550
- }
551
-
552
- // Add sub-response code if available
553
- subResponseCode?.let {
554
- if (it != 0) {
555
- errorMap["subResponseCode"] = it
556
- }
557
- }
558
-
559
- try {
560
- sendEvent(OpenIapEvent.PURCHASE_ERROR, errorMap.toMap())
561
- } catch (e: Exception) {
562
- Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
563
- }
564
-
565
- promise.reject(errorData.code, errorMessage, null)
566
- return@AsyncFunction
567
242
  }
243
+ }
568
244
  }
569
245
 
570
- AsyncFunction("acknowledgePurchaseAndroid") {
571
- token: String,
572
- promise: Promise,
573
- ->
574
- val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
575
- val acknowledgePurchaseParams =
576
- AcknowledgePurchaseParams
577
- .newBuilder()
578
- .setPurchaseToken(token)
579
- .build()
580
-
581
- billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult: BillingResult ->
582
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
583
- PlayUtils.rejectPromiseWithBillingError(
584
- promise,
585
- billingResult.responseCode,
586
- )
587
- return@acknowledgePurchase
588
- }
589
-
590
- val map = mutableMapOf<String, Any?>()
591
- map["responseCode"] = billingResult.responseCode
592
- map["debugMessage"] = billingResult.debugMessage
593
- val errorData = PlayUtils.getBillingResponseData(billingResult.responseCode)
594
- map["code"] = errorData.code
595
- map["message"] = errorData.message
596
- 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)
597
253
  }
254
+ }
598
255
  }
599
256
 
600
- AsyncFunction("consumeProductAndroid") {
601
- token: String,
602
- promise: Promise,
603
- ->
604
-
605
- val params = ConsumeParams.newBuilder().setPurchaseToken(token).build()
606
-
607
- val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
608
- billingClient.consumeAsync(params) { billingResult: BillingResult, purchaseToken: String? ->
609
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
610
- PlayUtils.rejectPromiseWithBillingError(
611
- promise,
612
- billingResult.responseCode,
613
- )
614
- return@consumeAsync
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)
615
265
  }
616
-
617
- val map = mutableMapOf<String, Any?>()
618
- map["responseCode"] = billingResult.responseCode
619
- map["debugMessage"] = billingResult.debugMessage
620
- val errorData = PlayUtils.getBillingResponseData(billingResult.responseCode)
621
- map["code"] = errorData.code
622
- map["message"] = errorData.message
623
- map["purchaseTokenAndroid"] = purchaseToken
624
- promise.resolve(map)
625
266
  }
626
267
  }
627
268
 
628
-
629
- AsyncFunction("getStorefront") {
630
- promise: Promise,
631
- ->
632
- val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
633
- billingClient.getBillingConfigAsync(
634
- GetBillingConfigParams.newBuilder().build(),
635
- BillingConfigResponseListener { result: BillingResult, config: BillingConfig? ->
636
- if (result.responseCode == BillingClient.BillingResponseCode.OK) {
637
- promise.safeResolve(config?.countryCode.orEmpty())
638
- } else {
639
- val debugMessage = result.debugMessage.orEmpty()
640
- promise.safeReject(result.responseCode.toString(), debugMessage)
641
- }
642
- },
643
- )
269
+ OnDestroy {
270
+ job.cancel()
644
271
  }
645
272
  }
646
-
647
- /**
648
- * Rejects promise with billing code if BillingResult is not OK
649
- */
650
- private fun isValidResult(
651
- billingResult: BillingResult,
652
- promise: Promise,
653
- ): Boolean {
654
- Log.d(TAG, "responseCode: " + billingResult.responseCode)
655
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
656
- PlayUtils.rejectPromiseWithBillingError(promise, billingResult.responseCode)
657
- return false
658
- }
659
- return true
660
- }
661
-
662
- private fun getBillingClientOrReject(promise: Promise): BillingClient? {
663
- val client = billingClientCache
664
- if (client == null) {
665
- promise.reject(
666
- IapErrorCode.E_INIT_CONNECTION,
667
- "Connection not initialized. Call initConnection() first.",
668
- null,
669
- )
670
- return null
671
- }
672
- if (!client.isReady) {
673
- promise.reject(
674
- IapErrorCode.E_INIT_CONNECTION,
675
- "BillingClient not ready. Wait for initConnection() to complete.",
676
- null,
677
- )
678
- return null
679
- }
680
- return client
681
- }
682
-
683
- private fun initBillingClient(
684
- promise: Promise,
685
- callback: (billingClient: BillingClient) -> Unit,
686
- ) {
687
- if (GoogleApiAvailability
688
- .getInstance()
689
- .isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS
690
- ) {
691
- Log.i(TAG, "Google Play Services are not available on this device")
692
- promise.reject(
693
- IapErrorCode.E_NOT_PREPARED,
694
- "Google Play Services are not available on this device",
695
- null,
696
- )
697
- return
698
- }
699
-
700
- billingClientCache =
701
- BillingClient
702
- .newBuilder(context)
703
- .setListener(this)
704
- .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
705
- .enableAutoServiceReconnection() // Automatically handle service disconnections
706
- .build()
707
-
708
- billingClientCache?.startConnection(
709
- object : BillingClientStateListener {
710
- override fun onBillingSetupFinished(billingResult: BillingResult) {
711
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
712
- promise.reject(
713
- IapErrorCode.E_INIT_CONNECTION,
714
- "Billing setup finished with error: ${billingResult.debugMessage}",
715
- null,
716
- )
717
- return
718
- }
719
- callback(billingClientCache!!)
720
- }
721
-
722
- override fun onBillingServiceDisconnected() {
723
- Log.i(TAG, "Billing service disconnected")
724
- }
725
- },
726
- )
727
- }
728
273
  }