react-native-iap 14.2.3 → 14.3.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 (29) hide show
  1. package/README.md +2 -6
  2. package/android/build.gradle +4 -5
  3. package/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +327 -547
  4. package/ios/HybridRnIap.swift +16 -0
  5. package/lib/module/index.js +43 -0
  6. package/lib/module/index.js.map +1 -1
  7. package/lib/typescript/plugin/src/withIAP.d.ts.map +1 -1
  8. package/lib/typescript/src/index.d.ts +15 -0
  9. package/lib/typescript/src/index.d.ts.map +1 -1
  10. package/lib/typescript/src/specs/RnIap.nitro.d.ts +17 -0
  11. package/lib/typescript/src/specs/RnIap.nitro.d.ts.map +1 -1
  12. package/nitrogen/generated/android/c++/JHybridRnIapSpec.cpp +35 -0
  13. package/nitrogen/generated/android/c++/JHybridRnIapSpec.hpp +2 -0
  14. package/nitrogen/generated/android/c++/JNitroDeepLinkOptionsAndroid.hpp +58 -0
  15. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/HybridRnIapSpec.kt +8 -0
  16. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/NitroDeepLinkOptionsAndroid.kt +32 -0
  17. package/nitrogen/generated/ios/NitroIap-Swift-Cxx-Umbrella.hpp +3 -0
  18. package/nitrogen/generated/ios/c++/HybridRnIapSpecSwift.hpp +19 -0
  19. package/nitrogen/generated/ios/swift/HybridRnIapSpec.swift +2 -0
  20. package/nitrogen/generated/ios/swift/HybridRnIapSpec_cxx.swift +38 -0
  21. package/nitrogen/generated/ios/swift/NitroDeepLinkOptionsAndroid.swift +84 -0
  22. package/nitrogen/generated/shared/c++/HybridRnIapSpec.cpp +2 -0
  23. package/nitrogen/generated/shared/c++/HybridRnIapSpec.hpp +5 -0
  24. package/nitrogen/generated/shared/c++/NitroDeepLinkOptionsAndroid.hpp +72 -0
  25. package/package.json +1 -1
  26. package/plugin/build/withIAP.js +21 -18
  27. package/plugin/src/withIAP.ts +31 -23
  28. package/src/index.ts +48 -0
  29. package/src/specs/RnIap.nitro.ts +22 -0
@@ -1,72 +1,120 @@
1
1
  package com.margelo.nitro.iap
2
2
 
3
- import android.app.Activity
4
- import android.content.Context
5
3
  import android.util.Log
6
- import com.android.billingclient.api.*
7
4
  import com.facebook.react.bridge.ReactApplicationContext
8
- import com.google.android.gms.common.ConnectionResult
9
- import com.google.android.gms.common.GoogleApiAvailability
10
5
  import com.margelo.nitro.NitroModules
11
6
  import com.margelo.nitro.core.Promise
12
- import org.json.JSONArray
13
- import org.json.JSONObject
7
+ import dev.hyo.openiap.OpenIapError
8
+ import dev.hyo.openiap.OpenIapModule
9
+ import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
10
+ import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
11
+ import dev.hyo.openiap.models.OpenIapProduct
12
+ import dev.hyo.openiap.models.OpenIapPurchase
13
+ import dev.hyo.openiap.models.DeepLinkOptions
14
+ import dev.hyo.openiap.models.ProductRequest
15
+ import dev.hyo.openiap.models.RequestPurchaseAndroidProps
16
+ import dev.hyo.openiap.models.OpenIapSerialization
14
17
  import kotlinx.coroutines.Dispatchers
15
- import kotlinx.coroutines.suspendCancellableCoroutine
16
18
  import kotlinx.coroutines.withContext
17
- import kotlin.coroutines.resume
18
- import kotlin.coroutines.resumeWithException
19
+ import kotlinx.coroutines.CompletableDeferred
19
20
 
20
- class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientStateListener {
21
+ class HybridRnIap : HybridRnIapSpec() {
21
22
  companion object {
22
23
  const val TAG = "RnIap"
23
- private const val MICROS_PER_UNIT = 1_000_000.0
24
24
  }
25
25
 
26
26
  // Get ReactApplicationContext lazily from NitroModules
27
27
  private val context: ReactApplicationContext by lazy {
28
28
  NitroModules.applicationContext as ReactApplicationContext
29
29
  }
30
-
31
- private var billingClient: BillingClient? = null
32
- private val skuDetailsCache = mutableMapOf<String, ProductDetails>()
33
-
30
+
31
+ // OpenIAP backend + local cache for product types
32
+ private val openIap: OpenIapModule by lazy { OpenIapModule(context) }
33
+ private val productTypeBySku = mutableMapOf<String, String>()
34
+
34
35
  // Event listeners
35
36
  private val purchaseUpdatedListeners = mutableListOf<(NitroPurchase) -> Unit>()
36
37
  private val purchaseErrorListeners = mutableListOf<(NitroPurchaseResult) -> Unit>()
37
38
  private val promotedProductListenersIOS = mutableListOf<(NitroProduct) -> Unit>()
38
-
39
+ private var listenersAttached = false
40
+ private var isInitialized = false
41
+ private var initDeferred: CompletableDeferred<Boolean>? = null
42
+ private val initLock = Any()
39
43
 
40
44
  // Connection methods
41
45
  override fun initConnection(): Promise<Boolean> {
42
46
  return Promise.async {
43
- if (billingClient?.isReady == true) {
44
- return@async true
47
+ // Fast-path: if already initialized, return immediately
48
+ if (isInitialized) return@async true
49
+
50
+ // Set current activity best-effort; don't fail init if missing
51
+ withContext(Dispatchers.Main) {
52
+ runCatching { openIap.setActivity(context.currentActivity) }
45
53
  }
46
-
47
- // Check if Google Play Services is available
48
- val googleApiAvailability = GoogleApiAvailability.getInstance()
49
- val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context)
50
- if (resultCode != ConnectionResult.SUCCESS) {
51
- val errorMsg = BillingUtils.getPlayServicesErrorMessage(resultCode)
52
- val errorJson = BillingUtils.createErrorJson(
53
- IapErrorCode.E_NOT_PREPARED,
54
- errorMsg,
55
- resultCode
56
- )
57
- throw Exception(errorJson)
54
+
55
+ // Single-flight: capture or create the shared Deferred atomically
56
+ val wasExisting = synchronized(initLock) {
57
+ if (initDeferred == null) {
58
+ initDeferred = CompletableDeferred()
59
+ false
60
+ } else true
58
61
  }
59
-
60
- withContext(Dispatchers.Main) {
61
- initBillingClient()
62
+ if (wasExisting) return@async initDeferred!!.await()
63
+
64
+ if (!listenersAttached) {
65
+ listenersAttached = true
66
+ openIap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p ->
67
+ runCatching { sendPurchaseUpdate(convertToNitroPurchase(p)) }
68
+ .onFailure { Log.e(TAG, "Failed to forward purchase update", it) }
69
+ })
70
+ openIap.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e ->
71
+ val code = OpenIapError.toCode(e)
72
+ val message = e.message ?: OpenIapError.defaultMessage(code)
73
+ runCatching {
74
+ sendPurchaseError(
75
+ NitroPurchaseResult(
76
+ responseCode = -1.0,
77
+ debugMessage = null,
78
+ code = code,
79
+ message = message,
80
+ purchaseToken = null
81
+ )
82
+ )
83
+ }.onFailure { Log.e(TAG, "Failed to forward purchase error", it) }
84
+ })
85
+ }
86
+
87
+ // We created it above; reuse the shared instance
88
+ val deferred = initDeferred!!
89
+ try {
90
+ val ok = runCatching { openIap.initConnection() }.getOrElse { err ->
91
+ val error = OpenIapError.InitConnection(err.message ?: "Failed to initialize connection")
92
+ throw Exception(toErrorJson(error))
93
+ }
94
+ if (!ok) {
95
+ val error = OpenIapError.InitConnection("Failed to initialize connection")
96
+ throw Exception(toErrorJson(error))
97
+ }
98
+ isInitialized = true
99
+ deferred.complete(true)
100
+ true
101
+ } catch (e: Exception) {
102
+ // Complete exceptionally so all concurrent awaiters receive the same failure
103
+ if (!deferred.isCompleted) deferred.completeExceptionally(e)
104
+ isInitialized = false
105
+ throw e
106
+ } finally {
107
+ initDeferred = null
62
108
  }
63
109
  }
64
110
  }
65
111
 
66
112
  override fun endConnection(): Promise<Boolean> {
67
113
  return Promise.async {
68
- billingClient?.endConnection()
69
- billingClient = null
114
+ runCatching { openIap.endConnection() }
115
+ productTypeBySku.clear()
116
+ isInitialized = false
117
+ initDeferred = null
70
118
  true
71
119
  }
72
120
  }
@@ -74,71 +122,20 @@ class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientSt
74
122
  // Product methods
75
123
  override fun fetchProducts(skus: Array<String>, type: String): Promise<Array<NitroProduct>> {
76
124
  return Promise.async {
77
- Log.d(TAG, "fetchProducts called with SKUs: ${skus.joinToString()}, type: $type")
78
-
79
- // Validate SKU list
125
+ Log.d(TAG, "fetchProducts (OpenIAP) skus=${skus.joinToString()} type=$type")
126
+
80
127
  if (skus.isEmpty()) {
81
- throw Exception(BillingUtils.createErrorJson(
82
- IapErrorCode.E_EMPTY_SKU_LIST,
83
- "SKU list is empty"
84
- ))
128
+ throw Exception(toErrorJson(OpenIapError.EmptySkuList))
85
129
  }
86
-
87
- // Initialize billing client if not already done
88
- // Auto-reconnection will handle service disconnections automatically
89
- if (billingClient == null) {
90
- initConnection().await()
91
- }
92
-
93
- val productType = if (type == "subs") {
94
- BillingClient.ProductType.SUBS
95
- } else {
96
- BillingClient.ProductType.INAPP
97
- }
98
-
99
- val productList = skus.map { sku ->
100
- QueryProductDetailsParams.Product.newBuilder()
101
- .setProductId(sku)
102
- .setProductType(productType)
103
- .build()
104
- }
105
-
106
- val params = QueryProductDetailsParams.newBuilder()
107
- .setProductList(productList)
108
- .build()
109
-
110
- val result = suspendCancellableCoroutine<List<ProductDetails>> { continuation ->
111
- billingClient?.queryProductDetailsAsync(params) { billingResult, productDetailsResult ->
112
- Log.d(TAG, "queryProductDetailsAsync response: code=${billingResult.responseCode}")
113
- if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
114
- val productDetailsList = productDetailsResult.productDetailsList ?: emptyList()
115
- Log.d(TAG, "Retrieved ${productDetailsList.size} products")
116
- // Cache the product details
117
- if (productDetailsList.isNotEmpty()) {
118
- for (details in productDetailsList) {
119
- Log.d(TAG, "Product: ${details.productId}, has offers: ${details.subscriptionOfferDetails?.size ?: 0}")
120
- skuDetailsCache[details.productId] = details
121
- }
122
- continuation.resume(productDetailsList)
123
- } else {
124
- continuation.resume(emptyList())
125
- }
126
- } else {
127
- continuation.resumeWithException(
128
- Exception(getBillingErrorMessage(billingResult.responseCode))
129
- )
130
- }
131
- }
132
- }
133
-
134
- Log.d(TAG, "Converting ${result.size} products to NitroProducts")
135
-
136
- val nitroProducts = result.map { productDetails ->
137
- convertToNitroProduct(productDetails, type)
138
- }.toTypedArray()
139
-
140
- Log.d(TAG, "Returning ${nitroProducts.size} NitroProducts to JS")
141
- nitroProducts
130
+
131
+ initConnection().await()
132
+ val reqType = ProductRequest.ProductRequestType.fromString(type)
133
+ val products = openIap.fetchProducts(ProductRequest(skus.toList(), reqType))
134
+
135
+ // populate type cache
136
+ products.forEach { p -> productTypeBySku[p.id] = p.type.value }
137
+
138
+ products.map { convertToNitroProduct(it) }.toTypedArray()
142
139
  }
143
140
  }
144
141
 
@@ -146,112 +143,45 @@ class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientSt
146
143
  // Purchase methods (Unified)
147
144
  override fun requestPurchase(request: NitroPurchaseRequest): Promise<Unit> {
148
145
  return Promise.async {
149
- // Android implementation
150
146
  val androidRequest = request.android ?: run {
151
- sendPurchaseError(createPurchaseErrorResult(
152
- IapErrorCode.E_USER_ERROR,
153
- "No Android request provided"
154
- ))
147
+ // Programming error: no Android params provided
148
+ sendPurchaseError(toErrorResult(OpenIapError.DeveloperError))
155
149
  return@async
156
150
  }
157
-
158
- // Validate SKU list
151
+
159
152
  if (androidRequest.skus.isEmpty()) {
160
- sendPurchaseError(createPurchaseErrorResult(
161
- IapErrorCode.E_EMPTY_SKU_LIST,
162
- "SKU list is empty"
163
- ))
153
+ sendPurchaseError(toErrorResult(OpenIapError.EmptySkuList))
164
154
  return@async
165
155
  }
166
-
156
+
167
157
  try {
168
- // Initialize billing client if not already done
169
- if (billingClient == null) {
170
- initConnection().await()
171
- }
172
-
173
- // Get current activity - this should be done on Main thread
174
- val activity = withContext(Dispatchers.Main) {
175
- context.currentActivity
176
- } ?: run {
177
- sendPurchaseError(createPurchaseErrorResult(
178
- IapErrorCode.E_ACTIVITY_UNAVAILABLE,
179
- "Current activity is null. Please ensure the app is in foreground."
180
- ))
158
+ initConnection().await()
159
+ withContext(Dispatchers.Main) { runCatching { openIap.setActivity(context.currentActivity) } }
160
+
161
+ val missing = androidRequest.skus.firstOrNull { !productTypeBySku.containsKey(it) }
162
+ if (missing != null) {
163
+ sendPurchaseError(toErrorResult(OpenIapError.SkuNotFound(missing), missing))
181
164
  return@async
182
165
  }
183
-
184
- withContext(Dispatchers.Main) {
185
- val productDetailsList = mutableListOf<BillingFlowParams.ProductDetailsParams>()
186
-
187
- // Build product details list
188
- for (sku in androidRequest.skus) {
189
- val productDetails = skuDetailsCache[sku] ?: run {
190
- sendPurchaseError(createPurchaseErrorResult(
191
- IapErrorCode.E_SKU_NOT_FOUND,
192
- "Product not found: $sku. Call requestProducts first.",
193
- sku
194
- ))
195
- return@withContext
196
- }
197
-
198
- val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
199
- .setProductDetails(productDetails)
200
-
201
- // Add offer token for subscriptions (required for SUBS on Play Billing 5+)
202
- // Prefer developer-provided token, otherwise fall back to the first available offer/base-plan.
203
- val subscriptionOffers = androidRequest.subscriptionOffers
204
- var appliedOfferToken: String? = null
205
-
206
- if (!subscriptionOffers.isNullOrEmpty()) {
207
- val offer = subscriptionOffers.find { it.sku == sku }
208
- appliedOfferToken = offer?.offerToken
209
- }
210
-
211
- if (appliedOfferToken == null && productDetails.productType == BillingClient.ProductType.SUBS) {
212
- val firstAvailable = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken
213
- appliedOfferToken = firstAvailable
214
- }
215
-
216
- appliedOfferToken?.let { productDetailsParams.setOfferToken(it) }
217
- productDetailsList.add(productDetailsParams.build())
218
- }
219
-
220
- val billingFlowParams = BillingFlowParams.newBuilder()
221
- .setProductDetailsParamsList(productDetailsList)
222
- .setIsOfferPersonalized(androidRequest.isOfferPersonalized ?: false)
223
-
224
- // Set subscription update params if replacing
225
- val purchaseToken = androidRequest.purchaseTokenAndroid
226
- val replacementMode = androidRequest.replacementModeAndroid
227
- if (!purchaseToken.isNullOrEmpty() && replacementMode != null) {
228
- val updateParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder()
229
- .setOldPurchaseToken(purchaseToken)
230
- .setSubscriptionReplacementMode(replacementMode.toInt())
231
- .build()
232
- billingFlowParams.setSubscriptionUpdateParams(updateParams)
233
- }
234
-
235
- // Set obfuscated identifiers
236
- androidRequest.obfuscatedAccountIdAndroid?.let { billingFlowParams.setObfuscatedAccountId(it) }
237
- androidRequest.obfuscatedProfileIdAndroid?.let { billingFlowParams.setObfuscatedProfileId(it) }
238
-
239
- // Launch billing flow - results will be handled by onPurchasesUpdated
240
- val billingResult = billingClient?.launchBillingFlow(activity, billingFlowParams.build())
241
- if (billingResult?.responseCode != BillingClient.BillingResponseCode.OK) {
242
- sendPurchaseError(createPurchaseErrorResult(
243
- getBillingErrorCode(billingResult?.responseCode ?: -1),
244
- getBillingErrorMessage(billingResult?.responseCode ?: -1)
245
- ))
246
- }
247
-
248
- // Purchase results will be handled by onPurchasesUpdated callback
166
+ val typeStr = androidRequest.skus.firstOrNull()?.let { productTypeBySku[it] } ?: "inapp"
167
+ val typeEnum = ProductRequest.ProductRequestType.fromString(typeStr)
168
+
169
+ val result = openIap.requestPurchase(
170
+ RequestPurchaseAndroidProps(
171
+ skus = androidRequest.skus.toList(),
172
+ obfuscatedAccountIdAndroid = androidRequest.obfuscatedAccountIdAndroid,
173
+ obfuscatedProfileIdAndroid = androidRequest.obfuscatedProfileIdAndroid,
174
+ isOfferPersonalized = androidRequest.isOfferPersonalized
175
+ ),
176
+ typeEnum
177
+ )
178
+
179
+ result.forEach { p ->
180
+ runCatching { sendPurchaseUpdate(convertToNitroPurchase(p)) }
181
+ .onFailure { Log.e(TAG, "Failed to forward PURCHASE_UPDATED", it) }
249
182
  }
250
183
  } catch (e: Exception) {
251
- sendPurchaseError(createPurchaseErrorResult(
252
- IapErrorCode.E_UNKNOWN,
253
- e.message ?: "Unknown error occurred"
254
- ))
184
+ sendPurchaseError(toErrorResult(OpenIapError.PurchaseFailed(e.message ?: "Purchase failed")))
255
185
  }
256
186
  }
257
187
  }
@@ -259,97 +189,78 @@ class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientSt
259
189
  // Purchase history methods (Unified)
260
190
  override fun getAvailablePurchases(options: NitroAvailablePurchasesOptions?): Promise<Array<NitroPurchase>> {
261
191
  return Promise.async {
262
- // Android implementation
263
192
  val androidOptions = options?.android
264
- val type = androidOptions?.type ?: "inapp"
265
-
266
- // Initialize billing client if not already done
267
- // Auto-reconnection will handle service disconnections automatically
268
- if (billingClient == null) {
269
- initConnection().await()
270
- }
271
-
272
- val productType = if (type == "subs") {
273
- BillingClient.ProductType.SUBS
193
+ initConnection().await()
194
+
195
+ val result: List<OpenIapPurchase> = if (androidOptions?.type != null) {
196
+ val typeEnum = ProductRequest.ProductRequestType.fromString(androidOptions.type ?: "inapp")
197
+ openIap.getAvailableItems(typeEnum)
274
198
  } else {
275
- BillingClient.ProductType.INAPP
199
+ openIap.getAvailablePurchases()
276
200
  }
277
-
278
- val params = QueryPurchasesParams.newBuilder()
279
- .setProductType(productType)
280
- .build()
281
-
282
- val result = suspendCancellableCoroutine<List<Purchase>> { continuation ->
283
- billingClient?.queryPurchasesAsync(params) { billingResult, purchases ->
284
- if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
285
- continuation.resume(purchases)
286
- } else {
287
- continuation.resumeWithException(
288
- Exception(getBillingErrorMessage(billingResult.responseCode))
289
- )
290
- }
291
- }
292
- }
293
-
294
- result.map { purchase ->
295
- convertToNitroPurchase(purchase)
296
- }.toTypedArray()
201
+ result.map { convertToNitroPurchase(it) }.toTypedArray()
297
202
  }
298
203
  }
299
204
 
300
205
  // Transaction management methods (Unified)
301
206
  override fun finishTransaction(params: NitroFinishTransactionParams): Promise<Variant_Boolean_NitroPurchaseResult> {
302
207
  return Promise.async {
303
- // Android implementation
304
208
  val androidParams = params.android ?: return@async Variant_Boolean_NitroPurchaseResult.First(true)
305
209
  val purchaseToken = androidParams.purchaseToken
306
210
  val isConsumable = androidParams.isConsumable ?: false
307
-
308
- // Initialize billing client if not already done
309
- // Auto-reconnection will handle service disconnections automatically
310
- if (billingClient == null) {
211
+
212
+ // Validate token early to avoid confusing native errors
213
+ if (purchaseToken.isNullOrBlank()) {
214
+ return@async Variant_Boolean_NitroPurchaseResult.Second(
215
+ NitroPurchaseResult(
216
+ responseCode = -1.0,
217
+ debugMessage = "Missing purchaseToken",
218
+ code = OpenIapError.toCode(OpenIapError.DeveloperError),
219
+ message = "Missing purchaseToken",
220
+ purchaseToken = null
221
+ )
222
+ )
223
+ }
224
+
225
+ // Ensure connection; if it fails, return an error result instead of throwing
226
+ try {
311
227
  initConnection().await()
228
+ } catch (e: Exception) {
229
+ val err = OpenIapError.InitConnection(e.message ?: "Failed to initialize connection")
230
+ return@async Variant_Boolean_NitroPurchaseResult.Second(
231
+ NitroPurchaseResult(
232
+ responseCode = -1.0,
233
+ debugMessage = e.message,
234
+ code = OpenIapError.toCode(err),
235
+ message = err.message,
236
+ purchaseToken = purchaseToken
237
+ )
238
+ )
312
239
  }
313
-
314
- if (isConsumable) {
315
- // Consume the purchase
316
- val consumeParams = ConsumeParams.newBuilder()
317
- .setPurchaseToken(purchaseToken)
318
- .build()
319
-
320
- val result = suspendCancellableCoroutine<Pair<BillingResult, String>> { continuation ->
321
- billingClient?.consumeAsync(consumeParams) { billingResult, token ->
322
- continuation.resume(Pair(billingResult, token))
323
- }
240
+
241
+ try {
242
+ if (isConsumable) {
243
+ openIap.consumePurchaseAndroid(purchaseToken)
244
+ } else {
245
+ openIap.acknowledgePurchaseAndroid(purchaseToken)
324
246
  }
325
-
326
247
  Variant_Boolean_NitroPurchaseResult.Second(
327
248
  NitroPurchaseResult(
328
- responseCode = result.first.responseCode.toDouble(),
329
- debugMessage = result.first.debugMessage,
330
- code = result.first.responseCode.toString(),
331
- message = getBillingErrorMessage(result.first.responseCode),
332
- purchaseToken = result.second
249
+ responseCode = 0.0,
250
+ debugMessage = null,
251
+ code = "0",
252
+ message = "OK",
253
+ purchaseToken = purchaseToken
333
254
  )
334
255
  )
335
- } else {
336
- // Acknowledge the purchase
337
- val acknowledgeParams = AcknowledgePurchaseParams.newBuilder()
338
- .setPurchaseToken(purchaseToken)
339
- .build()
340
-
341
- val result = suspendCancellableCoroutine<BillingResult> { continuation ->
342
- billingClient?.acknowledgePurchase(acknowledgeParams) { billingResult ->
343
- continuation.resume(billingResult)
344
- }
345
- }
346
-
256
+ } catch (e: Exception) {
257
+ val err = OpenIapError.BillingError(e.message ?: "Service error")
347
258
  Variant_Boolean_NitroPurchaseResult.Second(
348
259
  NitroPurchaseResult(
349
- responseCode = result.responseCode.toDouble(),
350
- debugMessage = result.debugMessage,
351
- code = result.responseCode.toString(),
352
- message = getBillingErrorMessage(result.responseCode),
260
+ responseCode = -1.0,
261
+ debugMessage = e.message,
262
+ code = OpenIapError.toCode(err),
263
+ message = err.message,
353
264
  purchaseToken = purchaseToken
354
265
  )
355
266
  )
@@ -391,38 +302,7 @@ class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientSt
391
302
  Log.w(TAG, "removePromotedProductListenerIOS called on Android - promoted products are iOS-only")
392
303
  }
393
304
 
394
- // BillingClientStateListener implementation
395
- override fun onBillingSetupFinished(billingResult: BillingResult) {
396
- // Handled inline in initConnection
397
- }
398
-
399
- override fun onBillingServiceDisconnected() {
400
- // Try to restart the connection on the next request
401
- // For now, just log the disconnection
402
- }
403
-
404
- // PurchasesUpdatedListener implementation
405
- override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List<Purchase>?) {
406
- Log.d(TAG, "onPurchasesUpdated: responseCode=${billingResult.responseCode}")
407
-
408
- if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
409
- // Send successful purchases via events
410
- for (purchase in purchases) {
411
- sendPurchaseUpdate(convertToNitroPurchase(purchase))
412
- }
413
- } else {
414
- // Send error via events
415
- val errorCode = getBillingErrorCode(billingResult.responseCode)
416
- val errorMessage = getBillingErrorMessage(billingResult.responseCode)
417
- sendPurchaseError(createPurchaseErrorResult(
418
- errorCode,
419
- errorMessage,
420
- null,
421
- billingResult.responseCode,
422
- billingResult.debugMessage
423
- ))
424
- }
425
- }
305
+ // Billing callbacks handled internally by OpenIAP
426
306
 
427
307
  // Helper methods
428
308
 
@@ -463,163 +343,62 @@ class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientSt
463
343
  )
464
344
  }
465
345
 
466
- /**
467
- * Convert billing response code to IAP error code
468
- */
469
- private fun getBillingErrorCode(responseCode: Int): String {
470
- return when (responseCode) {
471
- BillingClient.BillingResponseCode.USER_CANCELED -> IapErrorCode.E_USER_CANCELLED
472
- BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> IapErrorCode.E_SERVICE_ERROR
473
- BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> IapErrorCode.E_NOT_PREPARED
474
- BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> IapErrorCode.E_SKU_NOT_FOUND
475
- BillingClient.BillingResponseCode.DEVELOPER_ERROR -> IapErrorCode.E_DEVELOPER_ERROR
476
- BillingClient.BillingResponseCode.ERROR -> IapErrorCode.E_UNKNOWN
477
- BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> IapErrorCode.E_ALREADY_OWNED
478
- BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> IapErrorCode.E_ITEM_NOT_OWNED
479
- BillingClient.BillingResponseCode.NETWORK_ERROR -> IapErrorCode.E_NETWORK_ERROR
480
- else -> IapErrorCode.E_UNKNOWN
481
- }
482
- }
483
-
484
- private suspend fun initBillingClient(): Boolean {
485
- return suspendCancellableCoroutine { continuation ->
486
- // For Google Play Billing v8.0.0+, use PendingPurchasesParams
487
- val pendingPurchasesParams = PendingPurchasesParams.newBuilder()
488
- .enableOneTimeProducts()
489
- .build()
490
-
491
- billingClient = BillingClient.newBuilder(context)
492
- .setListener(this@HybridRnIap)
493
- .enablePendingPurchases(pendingPurchasesParams)
494
- .enableAutoServiceReconnection() // Automatically handle service disconnections
495
- .build()
496
-
497
- billingClient?.startConnection(object : BillingClientStateListener {
498
- override fun onBillingSetupFinished(billingResult: BillingResult) {
499
- if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
500
- continuation.resume(true)
501
- } else {
502
- val errorData = BillingUtils.getBillingErrorData(billingResult.responseCode)
503
- val errorJson = BillingUtils.createErrorJson(
504
- errorData.code,
505
- errorData.message,
506
- billingResult.responseCode,
507
- billingResult.debugMessage
508
- )
509
- continuation.resumeWithException(Exception(errorJson))
510
- }
511
- }
512
-
513
- override fun onBillingServiceDisconnected() {
514
- Log.i(TAG, "Billing service disconnected")
515
- // Will try to reconnect on next operation
516
- }
517
- })
518
- }
519
- }
520
-
521
- private fun convertToNitroProduct(productDetails: ProductDetails, type: String): NitroProduct {
522
- // Get price info from either one-time purchase or subscription
523
- val (currency, displayPrice, priceAmountMicros) = when {
524
- productDetails.oneTimePurchaseOfferDetails != null -> {
525
- val offer = productDetails.oneTimePurchaseOfferDetails!!
526
- Triple(
527
- offer.priceCurrencyCode,
528
- offer.formattedPrice,
529
- offer.priceAmountMicros
530
- )
531
- }
532
- productDetails.subscriptionOfferDetails?.isNotEmpty() == true -> {
533
- val firstOffer = productDetails.subscriptionOfferDetails!![0]
534
- val firstPhase = firstOffer.pricingPhases.pricingPhaseList[0]
535
- Triple(
536
- firstPhase.priceCurrencyCode,
537
- firstPhase.formattedPrice,
538
- firstPhase.priceAmountMicros
539
- )
540
- }
541
- else -> Triple("", "N/A", 0L)
542
- }
543
-
544
- // Convert subscription offer details to JSON string if available
545
- val subscriptionOfferDetailsJson = productDetails.subscriptionOfferDetails?.let { offers ->
546
- Log.d(TAG, "Product ${productDetails.productId} has ${offers.size} subscription offers")
547
-
548
- val jsonArray = JSONArray().apply {
549
- offers.forEach { offer ->
550
- Log.d(TAG, "Offer: basePlanId=${offer.basePlanId}, offerId=${offer.offerId}, offerToken=${offer.offerToken}")
551
-
552
- val offerJson = JSONObject().apply {
553
- put("offerToken", offer.offerToken)
554
- put("basePlanId", offer.basePlanId)
555
- offer.offerId?.let { put("offerId", it) }
556
-
557
- put("offerTags", JSONArray(offer.offerTags))
558
-
559
- val pricingPhasesArray = JSONArray().apply {
560
- offer.pricingPhases.pricingPhaseList.forEach { phase ->
561
- put(JSONObject().apply {
562
- put("formattedPrice", phase.formattedPrice)
563
- put("priceCurrencyCode", phase.priceCurrencyCode)
564
- put("priceAmountMicros", phase.priceAmountMicros)
565
- put("billingCycleCount", phase.billingCycleCount)
566
- put("billingPeriod", phase.billingPeriod)
567
- put("recurrenceMode", phase.recurrenceMode)
568
- })
569
- }
570
- }
571
- put("pricingPhases", JSONObject().apply {
572
- put("pricingPhaseList", pricingPhasesArray)
573
- })
574
- }
575
- put(offerJson)
576
- }
346
+ private fun convertToNitroProduct(product: OpenIapProduct): NitroProduct {
347
+ val subOffers = product.subscriptionOfferDetailsAndroid
348
+ val subOffersJson = subOffers?.let { OpenIapSerialization.toJson(it) }
349
+
350
+ // Derive Android-specific fields from OpenIAP models
351
+ var originalPriceAndroid: String? = null
352
+ var originalPriceAmountMicrosAndroid: Double? = null
353
+ var introductoryPriceValueAndroid: Double? = null
354
+ var introductoryPriceCyclesAndroid: Double? = null
355
+ var introductoryPricePeriodAndroid: String? = null
356
+ var subscriptionPeriodAndroid: String? = null
357
+ var freeTrialPeriodAndroid: String? = null
358
+
359
+ if (product.type == OpenIapProduct.ProductType.INAPP) {
360
+ product.oneTimePurchaseOfferDetailsAndroid?.let { otp ->
361
+ originalPriceAndroid = otp.formattedPrice
362
+ // priceAmountMicros is a string; parse to number if possible
363
+ originalPriceAmountMicrosAndroid = otp.priceAmountMicros.toDoubleOrNull()
577
364
  }
578
-
579
- val jsonString = jsonArray.toString()
580
- Log.d(TAG, "Subscription offer details JSON: $jsonString")
581
- jsonString
582
- }
365
+ } else {
366
+ // SUBS: inspect pricing phases
367
+ val phases = subOffers?.firstOrNull()?.pricingPhases?.pricingPhaseList
368
+ if (!phases.isNullOrEmpty()) {
369
+ // Base recurring phase: recurrenceMode == 2 (INFINITE), else last non-zero priced phase
370
+ val basePhase = phases.firstOrNull { it.recurrenceMode == 2 } ?: phases.last()
371
+ originalPriceAndroid = basePhase.formattedPrice
372
+ originalPriceAmountMicrosAndroid = basePhase.priceAmountMicros.toDoubleOrNull()
373
+ subscriptionPeriodAndroid = basePhase.billingPeriod
583
374
 
584
- // Derive introductory/trial/base period information from subscription offers (if any)
585
- var derivedIntroValue: Double? = null
586
- var derivedIntroCycles: Double? = null
587
- var derivedIntroPeriod: String? = null
588
- var derivedSubPeriod: String? = null
589
- var derivedFreeTrialPeriod: String? = null
590
-
591
- productDetails.subscriptionOfferDetails?.let { offers ->
592
- // Prefer the first offer; if none, leave as nulls
593
- val firstOffer = offers.firstOrNull()
594
- val phases = firstOffer?.pricingPhases?.pricingPhaseList ?: emptyList()
595
- if (phases.isNotEmpty()) {
596
- // Base recurring phase: often the last phase (infinite recurrence)
597
- val basePhase = phases.last()
598
- derivedSubPeriod = basePhase.billingPeriod
599
-
600
- // Free trial phase: priceAmountMicros == 0
601
- val trialPhase = phases.firstOrNull { it.priceAmountMicros == 0L }
602
- derivedFreeTrialPeriod = trialPhase?.billingPeriod
603
-
604
- // Introductory paid phase: price > 0 and finite cycles
605
- val introPhase = phases.firstOrNull { it.priceAmountMicros > 0L && it.billingCycleCount > 0 }
375
+ // Introductory phase: finite cycles (>0) and priced (>0)
376
+ val introPhase = phases.firstOrNull {
377
+ it.billingCycleCount > 0 && (it.priceAmountMicros.toLongOrNull() ?: 0L) > 0L
378
+ }
606
379
  if (introPhase != null) {
607
- derivedIntroValue = introPhase.priceAmountMicros / MICROS_PER_UNIT
608
- derivedIntroCycles = introPhase.billingCycleCount.toDouble()
609
- derivedIntroPeriod = introPhase.billingPeriod
380
+ introductoryPriceValueAndroid = (introPhase.priceAmountMicros.toDoubleOrNull() ?: 0.0) / 1_000_000.0
381
+ introductoryPriceCyclesAndroid = introPhase.billingCycleCount.toDouble()
382
+ introductoryPricePeriodAndroid = introPhase.billingPeriod
383
+ }
384
+
385
+ // Free trial: zero-priced phase
386
+ val trialPhase = phases.firstOrNull { (it.priceAmountMicros.toLongOrNull() ?: 0L) == 0L }
387
+ if (trialPhase != null) {
388
+ freeTrialPeriodAndroid = trialPhase.billingPeriod
610
389
  }
611
390
  }
612
391
  }
613
-
614
- val nitroProduct = NitroProduct(
615
- id = productDetails.productId,
616
- title = productDetails.title,
617
- description = productDetails.description,
618
- type = type,
619
- displayName = productDetails.name,
620
- displayPrice = displayPrice,
621
- currency = currency,
622
- price = priceAmountMicros / MICROS_PER_UNIT,
392
+
393
+ return NitroProduct(
394
+ id = product.id,
395
+ title = product.title,
396
+ description = product.description,
397
+ type = product.type.value,
398
+ displayName = product.displayName,
399
+ displayPrice = product.displayPrice,
400
+ currency = product.currency,
401
+ price = product.price,
623
402
  platform = "android",
624
403
  // iOS fields (null on Android)
625
404
  typeIOS = null,
@@ -632,45 +411,36 @@ class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientSt
632
411
  introductoryPricePaymentModeIOS = null,
633
412
  introductoryPriceNumberOfPeriodsIOS = null,
634
413
  introductoryPriceSubscriptionPeriodIOS = null,
635
- // Android fields
636
- originalPriceAndroid = productDetails.oneTimePurchaseOfferDetails?.formattedPrice,
637
- originalPriceAmountMicrosAndroid = productDetails.oneTimePurchaseOfferDetails?.priceAmountMicros?.toDouble(),
638
- introductoryPriceValueAndroid = derivedIntroValue,
639
- introductoryPriceCyclesAndroid = derivedIntroCycles,
640
- introductoryPricePeriodAndroid = derivedIntroPeriod,
641
- subscriptionPeriodAndroid = derivedSubPeriod,
642
- freeTrialPeriodAndroid = derivedFreeTrialPeriod,
643
- subscriptionOfferDetailsAndroid = subscriptionOfferDetailsJson
414
+ // Android derivations
415
+ originalPriceAndroid = originalPriceAndroid,
416
+ originalPriceAmountMicrosAndroid = originalPriceAmountMicrosAndroid,
417
+ introductoryPriceValueAndroid = introductoryPriceValueAndroid,
418
+ introductoryPriceCyclesAndroid = introductoryPriceCyclesAndroid,
419
+ introductoryPricePeriodAndroid = introductoryPricePeriodAndroid,
420
+ subscriptionPeriodAndroid = subscriptionPeriodAndroid,
421
+ freeTrialPeriodAndroid = freeTrialPeriodAndroid,
422
+ subscriptionOfferDetailsAndroid = subOffersJson
644
423
  )
645
-
646
- Log.d(TAG, "Created NitroProduct for ${productDetails.productId}: has subscriptionOfferDetailsAndroid=${nitroProduct.subscriptionOfferDetailsAndroid != null}")
647
-
648
- return nitroProduct
649
- }
650
-
651
- private fun getPurchaseState(purchaseState: Int): String {
652
- return when (purchaseState) {
653
- Purchase.PurchaseState.PURCHASED -> "purchased"
654
- Purchase.PurchaseState.PENDING -> "pending"
655
- Purchase.PurchaseState.UNSPECIFIED_STATE -> "unknown"
656
- else -> "unknown"
657
- }
658
- // Note: Android doesn't have direct equivalents for:
659
- // - "restored" (iOS only - handled through restore purchases flow)
660
- // - "deferred" (iOS only - parental controls)
661
- // - "failed" (handled through error callbacks, not purchase state)
662
424
  }
663
425
 
664
- private fun convertToNitroPurchase(purchase: Purchase): NitroPurchase {
426
+ // Purchase state is provided as enum value by OpenIAP
427
+
428
+ private fun convertToNitroPurchase(purchase: OpenIapPurchase): NitroPurchase {
429
+ // Map OpenIAP purchase state back to legacy numeric Android state for compatibility
430
+ val purchaseStateAndroidNumeric = when (purchase.purchaseState) {
431
+ OpenIapPurchase.PurchaseState.PURCHASED -> 1.0
432
+ OpenIapPurchase.PurchaseState.PENDING -> 2.0
433
+ else -> 0.0 // UNSPECIFIED/UNKNOWN/other
434
+ }
665
435
  return NitroPurchase(
666
- id = purchase.orderId ?: "",
667
- productId = purchase.products.firstOrNull() ?: "",
668
- transactionDate = purchase.purchaseTime.toDouble(),
436
+ id = purchase.id,
437
+ productId = purchase.productId,
438
+ transactionDate = purchase.transactionDate.toDouble(),
669
439
  purchaseToken = purchase.purchaseToken,
670
440
  platform = "android",
671
441
  // Common fields
672
442
  quantity = purchase.quantity.toDouble(),
673
- purchaseState = getPurchaseState(purchase.purchaseState),
443
+ purchaseState = purchase.purchaseState.value,
674
444
  isAutoRenewing = purchase.isAutoRenewing,
675
445
  // iOS fields
676
446
  quantityIOS = null,
@@ -678,43 +448,60 @@ class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientSt
678
448
  originalTransactionIdentifierIOS = null,
679
449
  appAccountToken = null,
680
450
  // Android fields
681
- purchaseTokenAndroid = purchase.purchaseToken,
682
- dataAndroid = purchase.originalJson,
683
- signatureAndroid = purchase.signature,
684
- autoRenewingAndroid = purchase.isAutoRenewing,
685
- purchaseStateAndroid = purchase.purchaseState.toDouble(),
686
- isAcknowledgedAndroid = purchase.isAcknowledged,
687
- packageNameAndroid = purchase.packageName,
688
- obfuscatedAccountIdAndroid = purchase.accountIdentifiers?.obfuscatedAccountId,
689
- obfuscatedProfileIdAndroid = purchase.accountIdentifiers?.obfuscatedProfileId
451
+ purchaseTokenAndroid = purchase.purchaseTokenAndroid,
452
+ dataAndroid = purchase.dataAndroid,
453
+ signatureAndroid = purchase.signatureAndroid,
454
+ autoRenewingAndroid = purchase.autoRenewingAndroid,
455
+ purchaseStateAndroid = purchaseStateAndroidNumeric,
456
+ isAcknowledgedAndroid = purchase.isAcknowledgedAndroid,
457
+ packageNameAndroid = purchase.packageNameAndroid,
458
+ obfuscatedAccountIdAndroid = purchase.obfuscatedAccountIdAndroid,
459
+ obfuscatedProfileIdAndroid = purchase.obfuscatedProfileIdAndroid
690
460
  )
691
461
  }
692
462
 
693
- // Helper function for billing error messages
694
- private fun getBillingErrorMessage(responseCode: Int): String {
695
- val errorData = BillingUtils.getBillingErrorData(responseCode)
696
- return errorData.message
697
- }
463
+ // Billing error messages handled by OpenIAP
698
464
 
699
465
  // iOS-specific method - not supported on Android
700
466
  override fun getStorefrontIOS(): Promise<String> {
701
467
  return Promise.async {
702
- val errorJson = BillingUtils.createErrorJson(
703
- IapErrorCode.E_UNKNOWN,
704
- "getStorefrontIOS is only available on iOS"
705
- )
706
- throw Exception(errorJson)
468
+ throw Exception(toErrorJson(OpenIapError.NotSupported))
707
469
  }
708
470
  }
709
471
 
710
472
  // iOS-specific method - not supported on Android
711
473
  override fun getAppTransactionIOS(): Promise<String?> {
712
474
  return Promise.async {
713
- val errorJson = BillingUtils.createErrorJson(
714
- IapErrorCode.E_UNKNOWN,
715
- "getAppTransactionIOS is only available on iOS"
716
- )
717
- throw Exception(errorJson)
475
+ throw Exception(toErrorJson(OpenIapError.NotSupported))
476
+ }
477
+ }
478
+
479
+ // Android-specific storefront getter
480
+ override fun getStorefrontAndroid(): Promise<String> {
481
+ return Promise.async {
482
+ try {
483
+ initConnection().await()
484
+ openIap.getStorefront()
485
+ } catch (e: Exception) {
486
+ Log.w(TAG, "getStorefrontAndroid failed", e)
487
+ ""
488
+ }
489
+ }
490
+ }
491
+
492
+ // Android-specific deep link to subscription management
493
+ override fun deepLinkToSubscriptionsAndroid(options: NitroDeepLinkOptionsAndroid): Promise<Unit> {
494
+ return Promise.async {
495
+ try {
496
+ initConnection().await()
497
+ DeepLinkOptions(
498
+ skuAndroid = options.skuAndroid,
499
+ packageNameAndroid = options.packageNameAndroid
500
+ ).let { openIap.deepLinkToSubscriptions(it) }
501
+ } catch (e: Exception) {
502
+ Log.e(TAG, "deepLinkToSubscriptionsAndroid failed", e)
503
+ throw e
504
+ }
718
505
  }
719
506
  }
720
507
 
@@ -771,10 +558,7 @@ class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientSt
771
558
  try {
772
559
  // For Android, we need the androidOptions to be provided
773
560
  val androidOptions = params.androidOptions
774
- ?: throw Exception(BillingUtils.createErrorJson(
775
- IapErrorCode.E_DEVELOPER_ERROR,
776
- "Android receipt validation requires androidOptions parameter"
777
- ))
561
+ ?: throw Exception(toErrorJson(OpenIapError.DeveloperError))
778
562
 
779
563
  // Android receipt validation would typically involve server-side validation
780
564
  // using Google Play Developer API. Here we provide a simplified implementation
@@ -811,11 +595,7 @@ class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientSt
811
595
  Variant_NitroReceiptValidationResultIOS_NitroReceiptValidationResultAndroid.Second(result)
812
596
 
813
597
  } catch (e: Exception) {
814
- val errorJson = BillingUtils.createErrorJson(
815
- IapErrorCode.E_RECEIPT_FAILED,
816
- "Receipt validation failed: ${e.message}"
817
- )
818
- throw Exception(errorJson)
598
+ throw Exception(toErrorJson(OpenIapError.InvalidReceipt("Receipt validation failed: ${e.message}")))
819
599
  }
820
600
  }
821
601
  }
@@ -823,46 +603,31 @@ class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientSt
823
603
  // iOS-specific methods - Not applicable on Android, return appropriate defaults
824
604
  override fun subscriptionStatusIOS(sku: String): Promise<Array<NitroSubscriptionStatus>?> {
825
605
  return Promise.async {
826
- throw Exception(BillingUtils.createErrorJson(
827
- IapErrorCode.E_FEATURE_NOT_SUPPORTED,
828
- "subscriptionStatusIOS is only available on iOS platform"
829
- ))
606
+ throw Exception(toErrorJson(OpenIapError.NotSupported))
830
607
  }
831
608
  }
832
609
 
833
610
  override fun currentEntitlementIOS(sku: String): Promise<NitroPurchase?> {
834
611
  return Promise.async {
835
- throw Exception(BillingUtils.createErrorJson(
836
- IapErrorCode.E_FEATURE_NOT_SUPPORTED,
837
- "currentEntitlementIOS is only available on iOS platform"
838
- ))
612
+ throw Exception(toErrorJson(OpenIapError.NotSupported))
839
613
  }
840
614
  }
841
615
 
842
616
  override fun latestTransactionIOS(sku: String): Promise<NitroPurchase?> {
843
617
  return Promise.async {
844
- throw Exception(BillingUtils.createErrorJson(
845
- IapErrorCode.E_FEATURE_NOT_SUPPORTED,
846
- "latestTransactionIOS is only available on iOS platform"
847
- ))
618
+ throw Exception(toErrorJson(OpenIapError.NotSupported))
848
619
  }
849
620
  }
850
621
 
851
622
  override fun getPendingTransactionsIOS(): Promise<Array<NitroPurchase>> {
852
623
  return Promise.async {
853
- throw Exception(BillingUtils.createErrorJson(
854
- IapErrorCode.E_FEATURE_NOT_SUPPORTED,
855
- "getPendingTransactionsIOS is only available on iOS platform"
856
- ))
624
+ throw Exception(toErrorJson(OpenIapError.NotSupported))
857
625
  }
858
626
  }
859
627
 
860
628
  override fun syncIOS(): Promise<Boolean> {
861
629
  return Promise.async {
862
- throw Exception(BillingUtils.createErrorJson(
863
- IapErrorCode.E_FEATURE_NOT_SUPPORTED,
864
- "syncIOS is only available on iOS platform"
865
- ))
630
+ throw Exception(toErrorJson(OpenIapError.NotSupported))
866
631
  }
867
632
  }
868
633
 
@@ -870,37 +635,52 @@ class HybridRnIap : HybridRnIapSpec(), PurchasesUpdatedListener, BillingClientSt
870
635
 
871
636
  override fun isEligibleForIntroOfferIOS(groupID: String): Promise<Boolean> {
872
637
  return Promise.async {
873
- throw Exception(BillingUtils.createErrorJson(
874
- IapErrorCode.E_FEATURE_NOT_SUPPORTED,
875
- "isEligibleForIntroOfferIOS is only available on iOS platform"
876
- ))
638
+ throw Exception(toErrorJson(OpenIapError.NotSupported))
877
639
  }
878
640
  }
879
641
 
880
642
  override fun getReceiptDataIOS(): Promise<String> {
881
643
  return Promise.async {
882
- throw Exception(BillingUtils.createErrorJson(
883
- IapErrorCode.E_FEATURE_NOT_SUPPORTED,
884
- "getReceiptDataIOS is only available on iOS platform"
885
- ))
644
+ throw Exception(toErrorJson(OpenIapError.NotSupported))
886
645
  }
887
646
  }
888
647
 
889
648
  override fun isTransactionVerifiedIOS(sku: String): Promise<Boolean> {
890
649
  return Promise.async {
891
- throw Exception(BillingUtils.createErrorJson(
892
- IapErrorCode.E_FEATURE_NOT_SUPPORTED,
893
- "isTransactionVerifiedIOS is only available on iOS platform"
894
- ))
650
+ throw Exception(toErrorJson(OpenIapError.NotSupported))
895
651
  }
896
652
  }
897
653
 
898
654
  override fun getTransactionJwsIOS(sku: String): Promise<String?> {
899
655
  return Promise.async {
900
- throw Exception(BillingUtils.createErrorJson(
901
- IapErrorCode.E_FEATURE_NOT_SUPPORTED,
902
- "getTransactionJwsIOS is only available on iOS platform"
903
- ))
656
+ throw Exception(toErrorJson(OpenIapError.NotSupported))
904
657
  }
905
658
  }
659
+
660
+ // ---------------------------------------------------------------------
661
+ // OpenIAP error helpers: unify error codes/messages from library
662
+ // ---------------------------------------------------------------------
663
+ private fun toErrorJson(error: OpenIapError, productId: String? = null): String {
664
+ val code = OpenIapError.toCode(error)
665
+ val message = error.message.ifEmpty { OpenIapError.defaultMessage(code) }
666
+ return BillingUtils.createErrorJson(
667
+ code = code,
668
+ message = message,
669
+ responseCode = -1,
670
+ debugMessage = error.message,
671
+ productId = productId
672
+ )
673
+ }
674
+
675
+ private fun toErrorResult(error: OpenIapError, productId: String? = null): NitroPurchaseResult {
676
+ val code = OpenIapError.toCode(error)
677
+ val message = error.message.ifEmpty { OpenIapError.defaultMessage(code) }
678
+ return NitroPurchaseResult(
679
+ responseCode = -1.0,
680
+ debugMessage = error.message,
681
+ code = code,
682
+ message = message,
683
+ purchaseToken = null
684
+ )
685
+ }
906
686
  }