react-native-iap 14.3.8 → 14.4.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 (54) hide show
  1. package/NitroIap.podspec +11 -1
  2. package/README.md +2 -3
  3. package/android/build.gradle +24 -1
  4. package/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +374 -119
  5. package/android/src/main/java/com/margelo/nitro/iap/RnIapLog.kt +64 -0
  6. package/ios/HybridRnIap.swift +525 -362
  7. package/ios/RnIapHelper.swift +224 -0
  8. package/ios/RnIapLog.swift +127 -0
  9. package/lib/module/hooks/useIAP.js +2 -34
  10. package/lib/module/hooks/useIAP.js.map +1 -1
  11. package/lib/module/index.js +52 -2
  12. package/lib/module/index.js.map +1 -1
  13. package/lib/module/types.js.map +1 -1
  14. package/lib/typescript/plugin/src/withIAP.d.ts.map +1 -1
  15. package/lib/typescript/src/hooks/useIAP.d.ts +0 -12
  16. package/lib/typescript/src/hooks/useIAP.d.ts.map +1 -1
  17. package/lib/typescript/src/index.d.ts +3 -0
  18. package/lib/typescript/src/index.d.ts.map +1 -1
  19. package/lib/typescript/src/specs/RnIap.nitro.d.ts +24 -0
  20. package/lib/typescript/src/specs/RnIap.nitro.d.ts.map +1 -1
  21. package/lib/typescript/src/types.d.ts +8 -6
  22. package/lib/typescript/src/types.d.ts.map +1 -1
  23. package/nitrogen/generated/android/c++/JHybridRnIapSpec.cpp +64 -0
  24. package/nitrogen/generated/android/c++/JHybridRnIapSpec.hpp +4 -0
  25. package/nitrogen/generated/android/c++/JIapPlatform.hpp +3 -3
  26. package/nitrogen/generated/android/c++/JPurchaseAndroid.hpp +6 -2
  27. package/nitrogen/generated/android/c++/JPurchaseIOS.hpp +4 -0
  28. package/nitrogen/generated/android/c++/JPurchaseState.hpp +6 -6
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/HybridRnIapSpec.kt +16 -0
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/IapPlatform.kt +2 -2
  31. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/PurchaseAndroid.kt +4 -1
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/PurchaseIOS.kt +3 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/PurchaseState.kt +5 -5
  34. package/nitrogen/generated/ios/c++/HybridRnIapSpecSwift.hpp +32 -0
  35. package/nitrogen/generated/ios/swift/HybridRnIapSpec.swift +4 -0
  36. package/nitrogen/generated/ios/swift/HybridRnIapSpec_cxx.swift +82 -0
  37. package/nitrogen/generated/ios/swift/IapPlatform.swift +4 -4
  38. package/nitrogen/generated/ios/swift/PurchaseAndroid.swift +32 -2
  39. package/nitrogen/generated/ios/swift/PurchaseIOS.swift +13 -2
  40. package/nitrogen/generated/ios/swift/PurchaseState.swift +8 -8
  41. package/nitrogen/generated/shared/c++/HybridRnIapSpec.cpp +4 -0
  42. package/nitrogen/generated/shared/c++/HybridRnIapSpec.hpp +4 -0
  43. package/nitrogen/generated/shared/c++/IapPlatform.hpp +5 -5
  44. package/nitrogen/generated/shared/c++/PurchaseAndroid.hpp +6 -2
  45. package/nitrogen/generated/shared/c++/PurchaseIOS.hpp +5 -1
  46. package/nitrogen/generated/shared/c++/PurchaseState.hpp +11 -11
  47. package/openiap-versions.json +5 -0
  48. package/package.json +3 -2
  49. package/plugin/build/withIAP.js +35 -3
  50. package/plugin/src/withIAP.ts +44 -3
  51. package/src/hooks/useIAP.ts +3 -71
  52. package/src/index.ts +61 -2
  53. package/src/specs/RnIap.nitro.ts +28 -0
  54. package/src/types.ts +8 -6
@@ -1,27 +1,41 @@
1
1
  package com.margelo.nitro.iap
2
2
 
3
- import android.util.Log
4
3
  import com.facebook.react.bridge.ReactApplicationContext
5
4
  import com.margelo.nitro.NitroModules
6
5
  import com.margelo.nitro.core.Promise
6
+ import dev.hyo.openiap.AndroidSubscriptionOfferInput
7
+ import dev.hyo.openiap.DeepLinkOptions as OpenIapDeepLinkOptions
8
+ import dev.hyo.openiap.FetchProductsResult
9
+ import dev.hyo.openiap.FetchProductsResultProducts
10
+ import dev.hyo.openiap.FetchProductsResultSubscriptions
7
11
  import dev.hyo.openiap.OpenIapError as OpenIAPError
8
12
  import dev.hyo.openiap.OpenIapModule
13
+ import dev.hyo.openiap.ProductAndroid
14
+ import dev.hyo.openiap.ProductQueryType
15
+ import dev.hyo.openiap.ProductRequest
16
+ import dev.hyo.openiap.ProductSubscriptionAndroid
17
+ import dev.hyo.openiap.ProductSubscriptionAndroidOfferDetails
18
+ import dev.hyo.openiap.ProductCommon
19
+ import dev.hyo.openiap.ProductType
20
+ import dev.hyo.openiap.Purchase as OpenIapPurchase
21
+ import dev.hyo.openiap.PurchaseAndroid
22
+ import dev.hyo.openiap.RequestPurchaseAndroidProps
23
+ import dev.hyo.openiap.RequestPurchaseProps
24
+ import dev.hyo.openiap.RequestPurchasePropsByPlatforms
25
+ import dev.hyo.openiap.RequestPurchaseResultPurchase
26
+ import dev.hyo.openiap.RequestPurchaseResultPurchases
27
+ import dev.hyo.openiap.RequestSubscriptionAndroidProps
28
+ import dev.hyo.openiap.RequestSubscriptionPropsByPlatforms
9
29
  import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
10
30
  import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
11
- import dev.hyo.openiap.models.DeepLinkOptions as OpenIapDeepLinkOptions
12
- import dev.hyo.openiap.models.OpenIapProduct
13
- import dev.hyo.openiap.models.OpenIapPurchase
14
- import dev.hyo.openiap.models.ProductRequest
15
- import dev.hyo.openiap.models.OpenIapRequestPurchaseProps
16
- import dev.hyo.openiap.models.OpenIapSerialization
17
31
  import kotlinx.coroutines.Dispatchers
18
32
  import kotlinx.coroutines.withContext
19
33
  import kotlinx.coroutines.CompletableDeferred
34
+ import org.json.JSONArray
35
+ import org.json.JSONObject
36
+ import java.util.Locale
20
37
 
21
38
  class HybridRnIap : HybridRnIapSpec() {
22
- companion object {
23
- const val TAG = "RnIap"
24
- }
25
39
 
26
40
  // Get ReactApplicationContext lazily from NitroModules
27
41
  private val context: ReactApplicationContext by lazy {
@@ -44,8 +58,12 @@ class HybridRnIap : HybridRnIapSpec() {
44
58
  // Connection methods
45
59
  override fun initConnection(): Promise<Boolean> {
46
60
  return Promise.async {
61
+ RnIapLog.payload("initConnection", null)
47
62
  // Fast-path: if already initialized, return immediately
48
- if (isInitialized) return@async true
63
+ if (isInitialized) {
64
+ RnIapLog.result("initConnection", true)
65
+ return@async true
66
+ }
49
67
 
50
68
  // Set current activity best-effort; don't fail init if missing
51
69
  withContext(Dispatchers.Main) {
@@ -59,18 +77,32 @@ class HybridRnIap : HybridRnIapSpec() {
59
77
  false
60
78
  } else true
61
79
  }
62
- if (wasExisting) return@async initDeferred!!.await()
80
+ if (wasExisting) {
81
+ val result = initDeferred!!.await()
82
+ RnIapLog.result("initConnection.await", result)
83
+ return@async result
84
+ }
63
85
 
64
86
  if (!listenersAttached) {
65
87
  listenersAttached = true
88
+ RnIapLog.payload("listeners.attach", null)
66
89
  openIap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p ->
67
- runCatching { sendPurchaseUpdate(convertToNitroPurchase(p)) }
68
- .onFailure { Log.e(TAG, "Failed to forward purchase update", it) }
90
+ runCatching {
91
+ RnIapLog.result(
92
+ "purchaseUpdatedListener",
93
+ mapOf("id" to p.id, "sku" to p.productId)
94
+ )
95
+ sendPurchaseUpdate(convertToNitroPurchase(p))
96
+ }.onFailure { RnIapLog.failure("purchaseUpdatedListener", it) }
69
97
  })
70
98
  openIap.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e ->
71
99
  val code = OpenIAPError.toCode(e)
72
100
  val message = e.message ?: OpenIAPError.defaultMessage(code)
73
101
  runCatching {
102
+ RnIapLog.result(
103
+ "purchaseErrorListener",
104
+ mapOf("code" to code, "message" to message)
105
+ )
74
106
  sendPurchaseError(
75
107
  NitroPurchaseResult(
76
108
  responseCode = -1.0,
@@ -80,15 +112,22 @@ class HybridRnIap : HybridRnIapSpec() {
80
112
  purchaseToken = null
81
113
  )
82
114
  )
83
- }.onFailure { Log.e(TAG, "Failed to forward purchase error", it) }
115
+ }.onFailure { RnIapLog.failure("purchaseErrorListener", it) }
84
116
  })
117
+ RnIapLog.result("listeners.attach", "attached")
85
118
  }
86
119
 
87
120
  // We created it above; reuse the shared instance
88
121
  val deferred = initDeferred!!
89
122
  try {
90
- val ok = runCatching { openIap.initConnection() }.getOrElse { err ->
123
+ val ok = try {
124
+ RnIapLog.payload("initConnection.native", null)
125
+ withContext(Dispatchers.Main) {
126
+ openIap.initConnection()
127
+ }
128
+ } catch (err: Throwable) {
91
129
  val error = OpenIAPError.InitConnection()
130
+ RnIapLog.failure("initConnection.native", err)
92
131
  throw Exception(
93
132
  toErrorJson(
94
133
  error = error,
@@ -99,6 +138,7 @@ class HybridRnIap : HybridRnIapSpec() {
99
138
  }
100
139
  if (!ok) {
101
140
  val error = OpenIAPError.InitConnection()
141
+ RnIapLog.failure("initConnection.native", Exception(error.message))
102
142
  throw Exception(
103
143
  toErrorJson(
104
144
  error = error,
@@ -108,11 +148,13 @@ class HybridRnIap : HybridRnIapSpec() {
108
148
  }
109
149
  isInitialized = true
110
150
  deferred.complete(true)
151
+ RnIapLog.result("initConnection", true)
111
152
  true
112
153
  } catch (e: Exception) {
113
154
  // Complete exceptionally so all concurrent awaiters receive the same failure
114
155
  if (!deferred.isCompleted) deferred.completeExceptionally(e)
115
156
  isInitialized = false
157
+ RnIapLog.failure("initConnection", e)
116
158
  throw e
117
159
  } finally {
118
160
  initDeferred = null
@@ -122,10 +164,12 @@ class HybridRnIap : HybridRnIapSpec() {
122
164
 
123
165
  override fun endConnection(): Promise<Boolean> {
124
166
  return Promise.async {
167
+ RnIapLog.payload("endConnection", null)
125
168
  runCatching { openIap.endConnection() }
126
169
  productTypeBySku.clear()
127
170
  isInitialized = false
128
171
  initDeferred = null
172
+ RnIapLog.result("endConnection", true)
129
173
  true
130
174
  }
131
175
  }
@@ -133,7 +177,13 @@ class HybridRnIap : HybridRnIapSpec() {
133
177
  // Product methods
134
178
  override fun fetchProducts(skus: Array<String>, type: String): Promise<Array<NitroProduct>> {
135
179
  return Promise.async {
136
- Log.d(TAG, "fetchProducts (OpenIAP) skus=${skus.joinToString()} type=$type")
180
+ RnIapLog.payload(
181
+ "fetchProducts",
182
+ mapOf(
183
+ "skus" to skus.toList(),
184
+ "type" to type
185
+ )
186
+ )
137
187
 
138
188
  if (skus.isEmpty()) {
139
189
  throw Exception(toErrorJson(OpenIAPError.EmptySkuList))
@@ -141,40 +191,46 @@ class HybridRnIap : HybridRnIapSpec() {
141
191
 
142
192
  initConnection().await()
143
193
 
144
- val normalizedType = type.lowercase()
194
+ val queryType = parseProductQueryType(type)
145
195
  val skusList = skus.toList()
146
196
 
147
- val products: List<OpenIapProduct> = when (normalizedType) {
148
- "all" -> {
149
- val collected = mutableMapOf<String, OpenIapProduct>()
150
- listOf("in-app", "subs").forEach { kind ->
151
- val requestType = ProductRequest.ProductRequestType.fromString(kind)
152
- val fetched = openIap.fetchProducts(ProductRequest(skusList, requestType))
197
+ val products: List<ProductCommon> = when (queryType) {
198
+ ProductQueryType.All -> {
199
+ val collected = linkedMapOf<String, ProductCommon>()
200
+ listOf(ProductQueryType.InApp, ProductQueryType.Subs).forEach { kind ->
201
+ RnIapLog.payload(
202
+ "fetchProducts.native",
203
+ mapOf("skus" to skusList, "type" to kind.rawValue)
204
+ )
205
+ val fetched = openIap.fetchProducts(ProductRequest(skusList, kind)).productsOrEmpty()
206
+ RnIapLog.result(
207
+ "fetchProducts.native",
208
+ fetched.map { mapOf("id" to it.id, "type" to it.type.rawValue) }
209
+ )
153
210
  fetched.forEach { collected[it.id] = it }
154
211
  }
155
212
  collected.values.toList()
156
213
  }
157
- "inapp", "in-app" -> {
158
- if (normalizedType == "inapp") {
159
- Log.w(TAG, "fetchProducts received legacy type 'inapp'; forwarding as 'in-app'")
160
- }
161
- val requestType = ProductRequest.ProductRequestType.fromString("in-app")
162
- openIap.fetchProducts(ProductRequest(skusList, requestType))
163
- }
164
- "subs" -> {
165
- val requestType = ProductRequest.ProductRequestType.fromString("subs")
166
- openIap.fetchProducts(ProductRequest(skusList, requestType))
167
- }
168
214
  else -> {
169
- Log.w(TAG, "fetchProducts received unknown type '$type'; defaulting to ProductRequest.fromString")
170
- val requestType = ProductRequest.ProductRequestType.fromString(type)
171
- openIap.fetchProducts(ProductRequest(skusList, requestType))
215
+ RnIapLog.payload(
216
+ "fetchProducts.native",
217
+ mapOf("skus" to skusList, "type" to queryType.rawValue)
218
+ )
219
+ val fetched = openIap.fetchProducts(ProductRequest(skusList, queryType)).productsOrEmpty()
220
+ RnIapLog.result(
221
+ "fetchProducts.native",
222
+ fetched.map { mapOf("id" to it.id, "type" to it.type.rawValue) }
223
+ )
224
+ fetched
172
225
  }
173
226
  }
174
227
 
175
- // populate type cache
176
- products.forEach { p -> productTypeBySku[p.id] = p.type.value }
228
+ products.forEach { p -> productTypeBySku[p.id] = p.type.rawValue }
177
229
 
230
+ RnIapLog.result(
231
+ "fetchProducts",
232
+ products.map { mapOf("id" to it.id, "type" to it.type.rawValue) }
233
+ )
178
234
  products.map { convertToNitroProduct(it) }.toTypedArray()
179
235
  }
180
236
  }
@@ -183,15 +239,24 @@ class HybridRnIap : HybridRnIapSpec() {
183
239
  // Purchase methods (Unified)
184
240
  override fun requestPurchase(request: NitroPurchaseRequest): Promise<RequestPurchaseResult?> {
185
241
  return Promise.async {
186
- val defaultResult = RequestPurchaseResult.create(emptyArray())
242
+ val defaultResult = RequestPurchaseResult.create(emptyArray<com.margelo.nitro.iap.Purchase>())
243
+
244
+ RnIapLog.payload(
245
+ "requestPurchase",
246
+ mapOf(
247
+ "androidSkus" to (request.android?.skus?.toList() ?: emptyList()),
248
+ "hasIOS" to (request.ios != null)
249
+ )
250
+ )
187
251
 
188
252
  val androidRequest = request.android ?: run {
189
- // Programming error: no Android params provided
253
+ RnIapLog.warn("requestPurchase called without android payload")
190
254
  sendPurchaseError(toErrorResult(OpenIAPError.DeveloperError))
191
255
  return@async defaultResult
192
256
  }
193
257
 
194
258
  if (androidRequest.skus.isEmpty()) {
259
+ RnIapLog.warn("requestPurchase received empty SKU list")
195
260
  sendPurchaseError(toErrorResult(OpenIAPError.EmptySkuList))
196
261
  return@async defaultResult
197
262
  }
@@ -200,31 +265,102 @@ class HybridRnIap : HybridRnIapSpec() {
200
265
  initConnection().await()
201
266
  withContext(Dispatchers.Main) { runCatching { openIap.setActivity(context.currentActivity) } }
202
267
 
203
- val missing = androidRequest.skus.firstOrNull { !productTypeBySku.containsKey(it) }
204
- if (missing != null) {
205
- sendPurchaseError(toErrorResult(OpenIAPError.SkuNotFound(missing), missing))
206
- return@async defaultResult
268
+ val missingSkus = androidRequest.skus.filterNot { productTypeBySku.containsKey(it) }
269
+ if (missingSkus.isNotEmpty()) {
270
+ missingSkus.forEach { sku ->
271
+ RnIapLog.warn("requestPurchase missing cached type for $sku; attempting fetch")
272
+ val fetched = runCatching {
273
+ openIap.fetchProducts(
274
+ ProductRequest(listOf(sku), ProductQueryType.All)
275
+ ).productsOrEmpty()
276
+ }.getOrElse { error ->
277
+ RnIapLog.failure("requestPurchase.fetchMissing", error)
278
+ emptyList()
279
+ }
280
+ fetched.firstOrNull()?.let { productTypeBySku[it.id] = it.type.rawValue }
281
+ if (!productTypeBySku.containsKey(sku)) {
282
+ sendPurchaseError(toErrorResult(OpenIAPError.SkuNotFound(sku), sku))
283
+ return@async defaultResult
284
+ }
285
+ }
207
286
  }
208
- val typeStr = androidRequest.skus.firstOrNull()?.let { productTypeBySku[it] } ?: "inapp"
209
- val typeEnum = ProductRequest.ProductRequestType.fromString(typeStr)
210
-
211
- val result = openIap.requestPurchase(
212
- OpenIapRequestPurchaseProps(
213
- skus = androidRequest.skus.toList(),
214
- obfuscatedAccountIdAndroid = androidRequest.obfuscatedAccountIdAndroid,
215
- obfuscatedProfileIdAndroid = androidRequest.obfuscatedProfileIdAndroid,
216
- isOfferPersonalized = androidRequest.isOfferPersonalized
217
- ),
218
- typeEnum
287
+
288
+ val typeHint = androidRequest.skus.firstOrNull()?.let { productTypeBySku[it] } ?: "inapp"
289
+ val queryType = parseProductQueryType(typeHint)
290
+
291
+ val subscriptionOffers = androidRequest.subscriptionOffers
292
+ ?.mapNotNull { offer ->
293
+ val sku = offer.sku
294
+ val token = offer.offerToken
295
+ if (sku.isBlank() || token.isBlank()) {
296
+ null
297
+ } else {
298
+ AndroidSubscriptionOfferInput(sku = sku, offerToken = token)
299
+ }
300
+ }
301
+ ?: emptyList()
302
+ val normalizedOffers = subscriptionOffers.takeIf { it.isNotEmpty() }
303
+
304
+ val requestProps = when (queryType) {
305
+ ProductQueryType.Subs -> {
306
+ val replacementMode = (androidRequest.replacementModeAndroid as? Number)?.toInt()
307
+ val androidProps = RequestSubscriptionAndroidProps(
308
+ isOfferPersonalized = androidRequest.isOfferPersonalized,
309
+ obfuscatedAccountIdAndroid = androidRequest.obfuscatedAccountIdAndroid,
310
+ obfuscatedProfileIdAndroid = androidRequest.obfuscatedProfileIdAndroid,
311
+ purchaseTokenAndroid = androidRequest.purchaseTokenAndroid,
312
+ replacementModeAndroid = replacementMode,
313
+ skus = androidRequest.skus.toList(),
314
+ subscriptionOffers = normalizedOffers
315
+ )
316
+ RequestPurchaseProps(
317
+ request = RequestPurchaseProps.Request.Subscription(
318
+ RequestSubscriptionPropsByPlatforms(android = androidProps)
319
+ ),
320
+ type = ProductQueryType.Subs
321
+ )
322
+ }
323
+ ProductQueryType.InApp, ProductQueryType.All -> {
324
+ val androidProps = RequestPurchaseAndroidProps(
325
+ isOfferPersonalized = androidRequest.isOfferPersonalized,
326
+ obfuscatedAccountIdAndroid = androidRequest.obfuscatedAccountIdAndroid,
327
+ obfuscatedProfileIdAndroid = androidRequest.obfuscatedProfileIdAndroid,
328
+ skus = androidRequest.skus.toList()
329
+ )
330
+ RequestPurchaseProps(
331
+ request = RequestPurchaseProps.Request.Purchase(
332
+ RequestPurchasePropsByPlatforms(android = androidProps)
333
+ ),
334
+ type = ProductQueryType.InApp
335
+ )
336
+ }
337
+ }
338
+
339
+ RnIapLog.payload(
340
+ "requestPurchase.native",
341
+ mapOf(
342
+ "skus" to androidRequest.skus.toList(),
343
+ "type" to requestProps.type.rawValue,
344
+ "offerCount" to (normalizedOffers?.size ?: 0)
345
+ )
219
346
  )
220
347
 
221
- result.forEach { p ->
222
- runCatching { sendPurchaseUpdate(convertToNitroPurchase(p)) }
223
- .onFailure { Log.e(TAG, "Failed to forward PURCHASE_UPDATED", it) }
348
+ val result = withContext(Dispatchers.Main) {
349
+ openIap.requestPurchase(requestProps)
350
+ }
351
+ val purchases = result.purchasesOrEmpty()
352
+ purchases.forEach { p ->
353
+ runCatching {
354
+ RnIapLog.result(
355
+ "requestPurchase.native",
356
+ mapOf("id" to p.id, "sku" to p.productId)
357
+ )
358
+ }.onFailure { RnIapLog.failure("requestPurchase.native", it) }
224
359
  }
225
360
 
226
361
  defaultResult
227
362
  } catch (e: Exception) {
363
+ RnIapLog.failure("requestPurchase", e)
228
364
  sendPurchaseError(
229
365
  toErrorResult(
230
366
  error = OpenIAPError.PurchaseFailed(),
@@ -243,10 +379,15 @@ class HybridRnIap : HybridRnIapSpec() {
243
379
  val androidOptions = options?.android
244
380
  initConnection().await()
245
381
 
382
+ RnIapLog.payload(
383
+ "getAvailablePurchases",
384
+ mapOf("type" to androidOptions?.type?.name)
385
+ )
386
+
246
387
  val typeName = androidOptions?.type?.name?.lowercase()
247
388
  val normalizedType = when (typeName) {
248
389
  "inapp" -> {
249
- Log.w(TAG, "getAvailablePurchases received legacy type 'inapp'; forwarding as 'in-app'")
390
+ RnIapLog.warn("getAvailablePurchases received legacy type 'inapp'; forwarding as 'in-app'")
250
391
  "in-app"
251
392
  }
252
393
  "in-app", "subs" -> typeName
@@ -254,11 +395,20 @@ class HybridRnIap : HybridRnIapSpec() {
254
395
  }
255
396
 
256
397
  val result: List<OpenIapPurchase> = if (normalizedType != null) {
257
- val typeEnum = ProductRequest.ProductRequestType.fromString(normalizedType)
398
+ val typeEnum = parseProductQueryType(normalizedType)
399
+ RnIapLog.payload(
400
+ "getAvailablePurchases.native",
401
+ mapOf("type" to typeEnum.rawValue)
402
+ )
258
403
  openIap.getAvailableItems(typeEnum)
259
404
  } else {
260
- openIap.getAvailablePurchases()
405
+ RnIapLog.payload("getAvailablePurchases.native", mapOf("type" to "all"))
406
+ openIap.getAvailablePurchases(null)
261
407
  }
408
+ RnIapLog.result(
409
+ "getAvailablePurchases",
410
+ result.map { mapOf("id" to it.id, "sku" to it.productId) }
411
+ )
262
412
  result.map { convertToNitroPurchase(it) }.toTypedArray()
263
413
  }
264
414
  }
@@ -270,8 +420,17 @@ class HybridRnIap : HybridRnIapSpec() {
270
420
  val purchaseToken = androidParams.purchaseToken
271
421
  val isConsumable = androidParams.isConsumable ?: false
272
422
 
423
+ RnIapLog.payload(
424
+ "finishTransaction",
425
+ mapOf(
426
+ "purchaseToken" to purchaseToken?.let { "<hidden>" },
427
+ "isConsumable" to isConsumable
428
+ )
429
+ )
430
+
273
431
  // Validate token early to avoid confusing native errors
274
432
  if (purchaseToken.isNullOrBlank()) {
433
+ RnIapLog.warn("finishTransaction called with missing purchaseToken")
275
434
  return@async Variant_Boolean_NitroPurchaseResult.Second(
276
435
  NitroPurchaseResult(
277
436
  responseCode = -1.0,
@@ -305,7 +464,7 @@ class HybridRnIap : HybridRnIapSpec() {
305
464
  } else {
306
465
  openIap.acknowledgePurchaseAndroid(purchaseToken)
307
466
  }
308
- Variant_Boolean_NitroPurchaseResult.Second(
467
+ val result = Variant_Boolean_NitroPurchaseResult.Second(
309
468
  NitroPurchaseResult(
310
469
  responseCode = 0.0,
311
470
  debugMessage = null,
@@ -314,8 +473,11 @@ class HybridRnIap : HybridRnIapSpec() {
314
473
  purchaseToken = purchaseToken
315
474
  )
316
475
  )
476
+ RnIapLog.result("finishTransaction", mapOf("success" to true))
477
+ result
317
478
  } catch (e: Exception) {
318
479
  val err = OpenIAPError.BillingError()
480
+ RnIapLog.failure("finishTransaction", e)
319
481
  Variant_Boolean_NitroPurchaseResult.Second(
320
482
  NitroPurchaseResult(
321
483
  responseCode = -1.0,
@@ -354,13 +516,14 @@ class HybridRnIap : HybridRnIapSpec() {
354
516
  override fun addPromotedProductListenerIOS(listener: (product: NitroProduct) -> Unit) {
355
517
  // Promoted products are iOS-only, but we implement the interface for consistency
356
518
  promotedProductListenersIOS.add(listener)
357
- Log.w(TAG, "addPromotedProductListenerIOS called on Android - promoted products are iOS-only")
519
+ RnIapLog.warn("addPromotedProductListenerIOS called on Android - promoted products are iOS-only")
358
520
  }
359
-
521
+
360
522
  override fun removePromotedProductListenerIOS(listener: (product: NitroProduct) -> Unit) {
361
523
  // Promoted products are iOS-only, but we implement the interface for consistency
362
- promotedProductListenersIOS.clear()
363
- Log.w(TAG, "removePromotedProductListenerIOS called on Android - promoted products are iOS-only")
524
+ val removed = promotedProductListenersIOS.remove(listener)
525
+ if (!removed) RnIapLog.warn("removePromotedProductListenerIOS: listener not found")
526
+ RnIapLog.warn("removePromotedProductListenerIOS called on Android - promoted products are iOS-only")
364
527
  }
365
528
 
366
529
  // Billing callbacks handled internally by OpenIAP
@@ -371,6 +534,10 @@ class HybridRnIap : HybridRnIapSpec() {
371
534
  * Send purchase update event to listeners
372
535
  */
373
536
  private fun sendPurchaseUpdate(purchase: NitroPurchase) {
537
+ RnIapLog.result(
538
+ "sendPurchaseUpdate",
539
+ mapOf("productId" to purchase.productId, "platform" to purchase.platform)
540
+ )
374
541
  for (listener in purchaseUpdatedListeners) {
375
542
  listener(purchase)
376
543
  }
@@ -380,6 +547,10 @@ class HybridRnIap : HybridRnIapSpec() {
380
547
  * Send purchase error event to listeners
381
548
  */
382
549
  private fun sendPurchaseError(error: NitroPurchaseResult) {
550
+ RnIapLog.result(
551
+ "sendPurchaseError",
552
+ mapOf("code" to error.code, "message" to error.message)
553
+ )
383
554
  for (listener in purchaseErrorListeners) {
384
555
  listener(error)
385
556
  }
@@ -403,12 +574,75 @@ class HybridRnIap : HybridRnIapSpec() {
403
574
  purchaseToken = null
404
575
  )
405
576
  }
406
-
407
- private fun convertToNitroProduct(product: OpenIapProduct): NitroProduct {
408
- val subOffers = product.subscriptionOfferDetailsAndroid
409
- val subOffersJson = subOffers?.let { OpenIapSerialization.toJson(it) }
410
577
 
411
- // Derive Android-specific fields from OpenIAP models
578
+ private fun parseProductQueryType(rawType: String): ProductQueryType {
579
+ val normalized = rawType
580
+ .trim()
581
+ .lowercase(Locale.US)
582
+ .replace("_", "")
583
+ .replace("-", "")
584
+ return when (normalized) {
585
+ "subs", "subscription", "subscriptions" -> ProductQueryType.Subs
586
+ "all" -> ProductQueryType.All
587
+ else -> ProductQueryType.InApp
588
+ }
589
+ }
590
+
591
+ private fun FetchProductsResult.productsOrEmpty(): List<ProductCommon> = when (this) {
592
+ is FetchProductsResultProducts -> this.value.orEmpty().filterIsInstance<ProductCommon>()
593
+ is FetchProductsResultSubscriptions -> this.value.orEmpty().filterIsInstance<ProductCommon>()
594
+ }
595
+
596
+ private fun dev.hyo.openiap.RequestPurchaseResult?.purchasesOrEmpty(): List<OpenIapPurchase> = when (this) {
597
+ is RequestPurchaseResultPurchases -> this.value.orEmpty().mapNotNull { it }
598
+ is RequestPurchaseResultPurchase -> this.value?.let(::listOf).orEmpty()
599
+ else -> emptyList()
600
+ }
601
+
602
+ private fun serializeSubscriptionOffers(offers: List<ProductSubscriptionAndroidOfferDetails>): String {
603
+ val array = JSONArray()
604
+ offers.forEach { offer ->
605
+ val offerJson = JSONObject()
606
+ offerJson.put("basePlanId", offer.basePlanId)
607
+ offerJson.put("offerId", offer.offerId)
608
+ offerJson.put("offerTags", JSONArray(offer.offerTags))
609
+ offerJson.put("offerToken", offer.offerToken)
610
+
611
+ val phasesArray = JSONArray()
612
+ offer.pricingPhases.pricingPhaseList.forEach { phase ->
613
+ val phaseJson = JSONObject()
614
+ phaseJson.put("billingCycleCount", phase.billingCycleCount)
615
+ phaseJson.put("billingPeriod", phase.billingPeriod)
616
+ phaseJson.put("formattedPrice", phase.formattedPrice)
617
+ phaseJson.put("priceAmountMicros", phase.priceAmountMicros)
618
+ phaseJson.put("priceCurrencyCode", phase.priceCurrencyCode)
619
+ phaseJson.put("recurrenceMode", phase.recurrenceMode)
620
+ phasesArray.put(phaseJson)
621
+ }
622
+
623
+ val pricingPhasesJson = JSONObject()
624
+ pricingPhasesJson.put("pricingPhaseList", phasesArray)
625
+ offerJson.put("pricingPhases", pricingPhasesJson)
626
+
627
+ array.put(offerJson)
628
+ }
629
+ return array.toString()
630
+ }
631
+
632
+ private fun convertToNitroProduct(product: ProductCommon): NitroProduct {
633
+ val subscriptionOffers = when (product) {
634
+ is ProductSubscriptionAndroid -> product.subscriptionOfferDetailsAndroid.orEmpty()
635
+ is ProductAndroid -> product.subscriptionOfferDetailsAndroid.orEmpty()
636
+ else -> emptyList()
637
+ }
638
+ val oneTimeOffer = when (product) {
639
+ is ProductSubscriptionAndroid -> product.oneTimePurchaseOfferDetailsAndroid
640
+ is ProductAndroid -> product.oneTimePurchaseOfferDetailsAndroid
641
+ else -> null
642
+ }
643
+
644
+ val subscriptionOffersJson = subscriptionOffers.takeIf { it.isNotEmpty() }?.let { serializeSubscriptionOffers(it) }
645
+
412
646
  var originalPriceAndroid: String? = null
413
647
  var originalPriceAmountMicrosAndroid: Double? = null
414
648
  var introductoryPriceValueAndroid: Double? = null
@@ -417,33 +651,28 @@ class HybridRnIap : HybridRnIapSpec() {
417
651
  var subscriptionPeriodAndroid: String? = null
418
652
  var freeTrialPeriodAndroid: String? = null
419
653
 
420
- if (product.type == OpenIapProduct.ProductType.InApp) {
421
- product.oneTimePurchaseOfferDetailsAndroid?.let { otp ->
654
+ if (product.type == ProductType.InApp) {
655
+ oneTimeOffer?.let { otp ->
422
656
  originalPriceAndroid = otp.formattedPrice
423
- // priceAmountMicros is a string; parse to number if possible
424
657
  originalPriceAmountMicrosAndroid = otp.priceAmountMicros.toDoubleOrNull()
425
658
  }
426
659
  } else {
427
- // SUBS: inspect pricing phases
428
- val phases = subOffers?.firstOrNull()?.pricingPhases?.pricingPhaseList
429
- if (!phases.isNullOrEmpty()) {
430
- // Base recurring phase: recurrenceMode == 2 (INFINITE), else last non-zero priced phase
660
+ val phases = subscriptionOffers.firstOrNull()?.pricingPhases?.pricingPhaseList.orEmpty()
661
+ if (phases.isNotEmpty()) {
431
662
  val basePhase = phases.firstOrNull { it.recurrenceMode == 2 } ?: phases.last()
432
663
  originalPriceAndroid = basePhase.formattedPrice
433
664
  originalPriceAmountMicrosAndroid = basePhase.priceAmountMicros.toDoubleOrNull()
434
665
  subscriptionPeriodAndroid = basePhase.billingPeriod
435
666
 
436
- // Introductory phase: finite cycles (>0) and priced (>0)
437
667
  val introPhase = phases.firstOrNull {
438
668
  it.billingCycleCount > 0 && (it.priceAmountMicros.toLongOrNull() ?: 0L) > 0L
439
669
  }
440
670
  if (introPhase != null) {
441
- introductoryPriceValueAndroid = (introPhase.priceAmountMicros.toDoubleOrNull() ?: 0.0) / 1_000_000.0
671
+ introductoryPriceValueAndroid = introPhase.priceAmountMicros.toDoubleOrNull()?.div(1_000_000.0)
442
672
  introductoryPriceCyclesAndroid = introPhase.billingCycleCount.toDouble()
443
673
  introductoryPricePeriodAndroid = introPhase.billingPeriod
444
674
  }
445
675
 
446
- // Free trial: zero-priced phase
447
676
  val trialPhase = phases.firstOrNull { (it.priceAmountMicros.toLongOrNull() ?: 0L) == 0L }
448
677
  if (trialPhase != null) {
449
678
  freeTrialPeriodAndroid = trialPhase.billingPeriod
@@ -455,13 +684,12 @@ class HybridRnIap : HybridRnIapSpec() {
455
684
  id = product.id,
456
685
  title = product.title,
457
686
  description = product.description,
458
- type = product.type.value,
687
+ type = product.type.rawValue,
459
688
  displayName = product.displayName,
460
689
  displayPrice = product.displayPrice,
461
690
  currency = product.currency,
462
691
  price = product.price,
463
692
  platform = IapPlatform.ANDROID,
464
- // iOS fields (null on Android)
465
693
  typeIOS = null,
466
694
  isFamilyShareableIOS = null,
467
695
  jsonRepresentationIOS = null,
@@ -472,7 +700,6 @@ class HybridRnIap : HybridRnIapSpec() {
472
700
  introductoryPricePaymentModeIOS = null,
473
701
  introductoryPriceNumberOfPeriodsIOS = null,
474
702
  introductoryPriceSubscriptionPeriodIOS = null,
475
- // Android derivations
476
703
  originalPriceAndroid = originalPriceAndroid,
477
704
  originalPriceAmountMicrosAndroid = originalPriceAmountMicrosAndroid,
478
705
  introductoryPriceValueAndroid = introductoryPriceValueAndroid,
@@ -480,55 +707,52 @@ class HybridRnIap : HybridRnIapSpec() {
480
707
  introductoryPricePeriodAndroid = introductoryPricePeriodAndroid,
481
708
  subscriptionPeriodAndroid = subscriptionPeriodAndroid,
482
709
  freeTrialPeriodAndroid = freeTrialPeriodAndroid,
483
- subscriptionOfferDetailsAndroid = subOffersJson
710
+ subscriptionOfferDetailsAndroid = subscriptionOffersJson
484
711
  )
485
712
  }
486
713
 
487
714
  // Purchase state is provided as enum value by OpenIAP
488
715
 
489
716
  private fun convertToNitroPurchase(purchase: OpenIapPurchase): NitroPurchase {
490
- // Map OpenIAP purchase state back to legacy numeric Android state for compatibility
717
+ val androidPurchase = purchase as? PurchaseAndroid
491
718
  val purchaseStateAndroidNumeric = when (purchase.purchaseState) {
492
- OpenIapPurchase.PurchaseState.Purchased -> 1.0
493
- OpenIapPurchase.PurchaseState.Pending -> 2.0
494
- else -> 0.0 // UNSPECIFIED/UNKNOWN/other
719
+ dev.hyo.openiap.PurchaseState.Purchased -> 1.0
720
+ dev.hyo.openiap.PurchaseState.Pending -> 2.0
721
+ else -> 0.0
495
722
  }
496
723
  return NitroPurchase(
497
724
  id = purchase.id,
498
725
  productId = purchase.productId,
499
- transactionDate = purchase.transactionDate.toDouble(),
726
+ transactionDate = purchase.transactionDate,
500
727
  purchaseToken = purchase.purchaseToken,
501
728
  platform = IapPlatform.ANDROID,
502
- // Common fields
503
729
  quantity = purchase.quantity.toDouble(),
504
730
  purchaseState = mapPurchaseState(purchase.purchaseState),
505
731
  isAutoRenewing = purchase.isAutoRenewing,
506
- // iOS fields
507
732
  quantityIOS = null,
508
733
  originalTransactionDateIOS = null,
509
734
  originalTransactionIdentifierIOS = null,
510
735
  appAccountToken = null,
511
- // Android fields
512
- purchaseTokenAndroid = purchase.purchaseTokenAndroid,
513
- dataAndroid = purchase.dataAndroid,
514
- signatureAndroid = purchase.signatureAndroid,
515
- autoRenewingAndroid = purchase.autoRenewingAndroid,
736
+ purchaseTokenAndroid = androidPurchase?.purchaseToken,
737
+ dataAndroid = androidPurchase?.dataAndroid,
738
+ signatureAndroid = androidPurchase?.signatureAndroid,
739
+ autoRenewingAndroid = androidPurchase?.autoRenewingAndroid,
516
740
  purchaseStateAndroid = purchaseStateAndroidNumeric,
517
- isAcknowledgedAndroid = purchase.isAcknowledgedAndroid,
518
- packageNameAndroid = purchase.packageNameAndroid,
519
- obfuscatedAccountIdAndroid = purchase.obfuscatedAccountIdAndroid,
520
- obfuscatedProfileIdAndroid = purchase.obfuscatedProfileIdAndroid
741
+ isAcknowledgedAndroid = androidPurchase?.isAcknowledgedAndroid,
742
+ packageNameAndroid = androidPurchase?.packageNameAndroid,
743
+ obfuscatedAccountIdAndroid = androidPurchase?.obfuscatedAccountIdAndroid,
744
+ obfuscatedProfileIdAndroid = androidPurchase?.obfuscatedProfileIdAndroid
521
745
  )
522
746
  }
523
747
 
524
- private fun mapPurchaseState(state: OpenIapPurchase.PurchaseState): PurchaseState {
525
- return when (state.name.uppercase()) {
526
- "PURCHASED" -> PurchaseState.PURCHASED
527
- "PENDING" -> PurchaseState.PENDING
528
- "DEFERRED" -> PurchaseState.DEFERRED
529
- "RESTORED" -> PurchaseState.RESTORED
530
- "FAILED", "FAILURE", "CANCELED", "CANCELLED" -> PurchaseState.FAILED
531
- else -> PurchaseState.UNKNOWN
748
+ private fun mapPurchaseState(state: dev.hyo.openiap.PurchaseState): PurchaseState {
749
+ return when (state) {
750
+ dev.hyo.openiap.PurchaseState.Purchased -> PurchaseState.PURCHASED
751
+ dev.hyo.openiap.PurchaseState.Pending -> PurchaseState.PENDING
752
+ dev.hyo.openiap.PurchaseState.Deferred -> PurchaseState.DEFERRED
753
+ dev.hyo.openiap.PurchaseState.Restored -> PurchaseState.RESTORED
754
+ dev.hyo.openiap.PurchaseState.Failed -> PurchaseState.FAILED
755
+ dev.hyo.openiap.PurchaseState.Unknown -> PurchaseState.UNKNOWN
532
756
  }
533
757
  }
534
758
 
@@ -553,9 +777,12 @@ class HybridRnIap : HybridRnIapSpec() {
553
777
  return Promise.async {
554
778
  try {
555
779
  initConnection().await()
556
- openIap.getStorefront()
780
+ RnIapLog.payload("getStorefrontAndroid", null)
781
+ val value = openIap.getStorefront()
782
+ RnIapLog.result("getStorefrontAndroid", value)
783
+ value
557
784
  } catch (e: Exception) {
558
- Log.w(TAG, "getStorefrontAndroid failed", e)
785
+ RnIapLog.failure("getStorefrontAndroid", e)
559
786
  ""
560
787
  }
561
788
  }
@@ -570,13 +797,21 @@ class HybridRnIap : HybridRnIapSpec() {
570
797
  skuAndroid = options.skuAndroid,
571
798
  packageNameAndroid = options.packageNameAndroid
572
799
  ).let { openIap.deepLinkToSubscriptions(it) }
800
+ RnIapLog.result("deepLinkToSubscriptionsAndroid", true)
573
801
  } catch (e: Exception) {
574
- Log.e(TAG, "deepLinkToSubscriptionsAndroid failed", e)
802
+ RnIapLog.failure("deepLinkToSubscriptionsAndroid", e)
575
803
  throw e
576
804
  }
577
805
  }
578
806
  }
579
807
 
808
+ // iOS-specific method - not supported on Android
809
+ override fun getPromotedProductIOS(): Promise<NitroProduct?> {
810
+ return Promise.async {
811
+ null
812
+ }
813
+ }
814
+
580
815
  // iOS-specific method - not supported on Android
581
816
  override fun requestPromotedProductIOS(): Promise<NitroProduct?> {
582
817
  return Promise.async {
@@ -624,6 +859,12 @@ class HybridRnIap : HybridRnIapSpec() {
624
859
  }
625
860
  }
626
861
 
862
+ override fun deepLinkToSubscriptionsIOS(): Promise<Boolean> {
863
+ return Promise.async {
864
+ false
865
+ }
866
+ }
867
+
627
868
  // Receipt validation
628
869
  override fun validateReceipt(params: NitroReceiptValidationParams): Promise<Variant_NitroReceiptValidationResultIOS_NitroReceiptValidationResultAndroid> {
629
870
  return Promise.async {
@@ -724,7 +965,19 @@ class HybridRnIap : HybridRnIapSpec() {
724
965
  throw Exception(toErrorJson(OpenIAPError.NotSupported))
725
966
  }
726
967
  }
727
-
968
+
969
+ override fun getReceiptIOS(): Promise<String> {
970
+ return Promise.async {
971
+ throw Exception(toErrorJson(OpenIAPError.NotSupported))
972
+ }
973
+ }
974
+
975
+ override fun requestReceiptRefreshIOS(): Promise<String> {
976
+ return Promise.async {
977
+ throw Exception(toErrorJson(OpenIAPError.NotSupported))
978
+ }
979
+ }
980
+
728
981
  override fun isTransactionVerifiedIOS(sku: String): Promise<Boolean> {
729
982
  return Promise.async {
730
983
  throw Exception(toErrorJson(OpenIAPError.NotSupported))
@@ -746,9 +999,10 @@ class HybridRnIap : HybridRnIapSpec() {
746
999
  debugMessage: String? = null,
747
1000
  messageOverride: String? = null
748
1001
  ): String {
749
- val code = OpenIAPError.toCode(error)
1002
+ val code = OpenIAPError.Companion.toCode(error)
750
1003
  val message = messageOverride?.takeIf { it.isNotBlank() }
751
- ?: error.message.ifEmpty { OpenIAPError.defaultMessage(code) }
1004
+ ?: error.message?.takeIf { it.isNotBlank() }
1005
+ ?: OpenIAPError.Companion.defaultMessage(code)
752
1006
  return BillingUtils.createErrorJson(
753
1007
  code = code,
754
1008
  message = message,
@@ -764,9 +1018,10 @@ class HybridRnIap : HybridRnIapSpec() {
764
1018
  debugMessage: String? = null,
765
1019
  messageOverride: String? = null
766
1020
  ): NitroPurchaseResult {
767
- val code = OpenIAPError.toCode(error)
1021
+ val code = OpenIAPError.Companion.toCode(error)
768
1022
  val message = messageOverride?.takeIf { it.isNotBlank() }
769
- ?: error.message.ifEmpty { OpenIAPError.defaultMessage(code) }
1023
+ ?: error.message?.takeIf { it.isNotBlank() }
1024
+ ?: OpenIAPError.Companion.defaultMessage(code)
770
1025
  return NitroPurchaseResult(
771
1026
  responseCode = -1.0,
772
1027
  debugMessage = debugMessage ?: error.message,