react-native-iap 14.3.9 → 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 +369 -124
  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,28 +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.OpenIapRequestPurchaseProps
13
- import dev.hyo.openiap.models.OpenIapSerialization
14
- import dev.hyo.openiap.models.OpenIapProduct
15
- import dev.hyo.openiap.models.OpenIapPurchase
16
- import dev.hyo.openiap.models.ProductRequest
17
- import dev.hyo.openiap.models.RequestSubscriptionAndroidProps.SubscriptionOffer as OpenIapSubscriptionOffer
18
31
  import kotlinx.coroutines.Dispatchers
19
32
  import kotlinx.coroutines.withContext
20
33
  import kotlinx.coroutines.CompletableDeferred
34
+ import org.json.JSONArray
35
+ import org.json.JSONObject
36
+ import java.util.Locale
21
37
 
22
38
  class HybridRnIap : HybridRnIapSpec() {
23
- companion object {
24
- const val TAG = "RnIap"
25
- }
26
39
 
27
40
  // Get ReactApplicationContext lazily from NitroModules
28
41
  private val context: ReactApplicationContext by lazy {
@@ -45,8 +58,12 @@ class HybridRnIap : HybridRnIapSpec() {
45
58
  // Connection methods
46
59
  override fun initConnection(): Promise<Boolean> {
47
60
  return Promise.async {
61
+ RnIapLog.payload("initConnection", null)
48
62
  // Fast-path: if already initialized, return immediately
49
- if (isInitialized) return@async true
63
+ if (isInitialized) {
64
+ RnIapLog.result("initConnection", true)
65
+ return@async true
66
+ }
50
67
 
51
68
  // Set current activity best-effort; don't fail init if missing
52
69
  withContext(Dispatchers.Main) {
@@ -60,18 +77,32 @@ class HybridRnIap : HybridRnIapSpec() {
60
77
  false
61
78
  } else true
62
79
  }
63
- 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
+ }
64
85
 
65
86
  if (!listenersAttached) {
66
87
  listenersAttached = true
88
+ RnIapLog.payload("listeners.attach", null)
67
89
  openIap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p ->
68
- runCatching { sendPurchaseUpdate(convertToNitroPurchase(p)) }
69
- .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) }
70
97
  })
71
98
  openIap.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e ->
72
99
  val code = OpenIAPError.toCode(e)
73
100
  val message = e.message ?: OpenIAPError.defaultMessage(code)
74
101
  runCatching {
102
+ RnIapLog.result(
103
+ "purchaseErrorListener",
104
+ mapOf("code" to code, "message" to message)
105
+ )
75
106
  sendPurchaseError(
76
107
  NitroPurchaseResult(
77
108
  responseCode = -1.0,
@@ -81,15 +112,22 @@ class HybridRnIap : HybridRnIapSpec() {
81
112
  purchaseToken = null
82
113
  )
83
114
  )
84
- }.onFailure { Log.e(TAG, "Failed to forward purchase error", it) }
115
+ }.onFailure { RnIapLog.failure("purchaseErrorListener", it) }
85
116
  })
117
+ RnIapLog.result("listeners.attach", "attached")
86
118
  }
87
119
 
88
120
  // We created it above; reuse the shared instance
89
121
  val deferred = initDeferred!!
90
122
  try {
91
- 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) {
92
129
  val error = OpenIAPError.InitConnection()
130
+ RnIapLog.failure("initConnection.native", err)
93
131
  throw Exception(
94
132
  toErrorJson(
95
133
  error = error,
@@ -100,6 +138,7 @@ class HybridRnIap : HybridRnIapSpec() {
100
138
  }
101
139
  if (!ok) {
102
140
  val error = OpenIAPError.InitConnection()
141
+ RnIapLog.failure("initConnection.native", Exception(error.message))
103
142
  throw Exception(
104
143
  toErrorJson(
105
144
  error = error,
@@ -109,11 +148,13 @@ class HybridRnIap : HybridRnIapSpec() {
109
148
  }
110
149
  isInitialized = true
111
150
  deferred.complete(true)
151
+ RnIapLog.result("initConnection", true)
112
152
  true
113
153
  } catch (e: Exception) {
114
154
  // Complete exceptionally so all concurrent awaiters receive the same failure
115
155
  if (!deferred.isCompleted) deferred.completeExceptionally(e)
116
156
  isInitialized = false
157
+ RnIapLog.failure("initConnection", e)
117
158
  throw e
118
159
  } finally {
119
160
  initDeferred = null
@@ -123,10 +164,12 @@ class HybridRnIap : HybridRnIapSpec() {
123
164
 
124
165
  override fun endConnection(): Promise<Boolean> {
125
166
  return Promise.async {
167
+ RnIapLog.payload("endConnection", null)
126
168
  runCatching { openIap.endConnection() }
127
169
  productTypeBySku.clear()
128
170
  isInitialized = false
129
171
  initDeferred = null
172
+ RnIapLog.result("endConnection", true)
130
173
  true
131
174
  }
132
175
  }
@@ -134,7 +177,13 @@ class HybridRnIap : HybridRnIapSpec() {
134
177
  // Product methods
135
178
  override fun fetchProducts(skus: Array<String>, type: String): Promise<Array<NitroProduct>> {
136
179
  return Promise.async {
137
- 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
+ )
138
187
 
139
188
  if (skus.isEmpty()) {
140
189
  throw Exception(toErrorJson(OpenIAPError.EmptySkuList))
@@ -142,40 +191,46 @@ class HybridRnIap : HybridRnIapSpec() {
142
191
 
143
192
  initConnection().await()
144
193
 
145
- val normalizedType = type.lowercase()
194
+ val queryType = parseProductQueryType(type)
146
195
  val skusList = skus.toList()
147
196
 
148
- val products: List<OpenIapProduct> = when (normalizedType) {
149
- "all" -> {
150
- val collected = mutableMapOf<String, OpenIapProduct>()
151
- listOf("in-app", "subs").forEach { kind ->
152
- val requestType = ProductRequest.ProductRequestType.fromString(kind)
153
- 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
+ )
154
210
  fetched.forEach { collected[it.id] = it }
155
211
  }
156
212
  collected.values.toList()
157
213
  }
158
- "inapp", "in-app" -> {
159
- if (normalizedType == "inapp") {
160
- Log.w(TAG, "fetchProducts received legacy type 'inapp'; forwarding as 'in-app'")
161
- }
162
- val requestType = ProductRequest.ProductRequestType.fromString("in-app")
163
- openIap.fetchProducts(ProductRequest(skusList, requestType))
164
- }
165
- "subs" -> {
166
- val requestType = ProductRequest.ProductRequestType.fromString("subs")
167
- openIap.fetchProducts(ProductRequest(skusList, requestType))
168
- }
169
214
  else -> {
170
- Log.w(TAG, "fetchProducts received unknown type '$type'; defaulting to ProductRequest.fromString")
171
- val requestType = ProductRequest.ProductRequestType.fromString(type)
172
- 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
173
225
  }
174
226
  }
175
227
 
176
- // populate type cache
177
- products.forEach { p -> productTypeBySku[p.id] = p.type.value }
228
+ products.forEach { p -> productTypeBySku[p.id] = p.type.rawValue }
178
229
 
230
+ RnIapLog.result(
231
+ "fetchProducts",
232
+ products.map { mapOf("id" to it.id, "type" to it.type.rawValue) }
233
+ )
179
234
  products.map { convertToNitroProduct(it) }.toTypedArray()
180
235
  }
181
236
  }
@@ -184,15 +239,24 @@ class HybridRnIap : HybridRnIapSpec() {
184
239
  // Purchase methods (Unified)
185
240
  override fun requestPurchase(request: NitroPurchaseRequest): Promise<RequestPurchaseResult?> {
186
241
  return Promise.async {
187
- 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
+ )
188
251
 
189
252
  val androidRequest = request.android ?: run {
190
- // Programming error: no Android params provided
253
+ RnIapLog.warn("requestPurchase called without android payload")
191
254
  sendPurchaseError(toErrorResult(OpenIAPError.DeveloperError))
192
255
  return@async defaultResult
193
256
  }
194
257
 
195
258
  if (androidRequest.skus.isEmpty()) {
259
+ RnIapLog.warn("requestPurchase received empty SKU list")
196
260
  sendPurchaseError(toErrorResult(OpenIAPError.EmptySkuList))
197
261
  return@async defaultResult
198
262
  }
@@ -201,40 +265,102 @@ class HybridRnIap : HybridRnIapSpec() {
201
265
  initConnection().await()
202
266
  withContext(Dispatchers.Main) { runCatching { openIap.setActivity(context.currentActivity) } }
203
267
 
204
- val missing = androidRequest.skus.firstOrNull { !productTypeBySku.containsKey(it) }
205
- if (missing != null) {
206
- sendPurchaseError(toErrorResult(OpenIAPError.SkuNotFound(missing), missing))
207
- 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
+ }
208
286
  }
209
- val typeStr = androidRequest.skus.firstOrNull()?.let { productTypeBySku[it] } ?: "inapp"
210
- val typeEnum = ProductRequest.ProductRequestType.fromString(typeStr)
287
+
288
+ val typeHint = androidRequest.skus.firstOrNull()?.let { productTypeBySku[it] } ?: "inapp"
289
+ val queryType = parseProductQueryType(typeHint)
211
290
 
212
291
  val subscriptionOffers = androidRequest.subscriptionOffers
213
- ?.map { offer ->
214
- OpenIapSubscriptionOffer(
215
- sku = offer.sku,
216
- offerToken = offer.offerToken
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
217
321
  )
218
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
+ }
219
338
 
220
- val result = openIap.requestPurchase(
221
- OpenIapRequestPurchaseProps(
222
- skus = androidRequest.skus.toList(),
223
- obfuscatedAccountIdAndroid = androidRequest.obfuscatedAccountIdAndroid,
224
- obfuscatedProfileIdAndroid = androidRequest.obfuscatedProfileIdAndroid,
225
- isOfferPersonalized = androidRequest.isOfferPersonalized,
226
- subscriptionOffers = subscriptionOffers ?: emptyList()
227
- ),
228
- typeEnum
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
+ )
229
346
  )
230
347
 
231
- result.forEach { p ->
232
- runCatching { sendPurchaseUpdate(convertToNitroPurchase(p)) }
233
- .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) }
234
359
  }
235
360
 
236
361
  defaultResult
237
362
  } catch (e: Exception) {
363
+ RnIapLog.failure("requestPurchase", e)
238
364
  sendPurchaseError(
239
365
  toErrorResult(
240
366
  error = OpenIAPError.PurchaseFailed(),
@@ -253,10 +379,15 @@ class HybridRnIap : HybridRnIapSpec() {
253
379
  val androidOptions = options?.android
254
380
  initConnection().await()
255
381
 
382
+ RnIapLog.payload(
383
+ "getAvailablePurchases",
384
+ mapOf("type" to androidOptions?.type?.name)
385
+ )
386
+
256
387
  val typeName = androidOptions?.type?.name?.lowercase()
257
388
  val normalizedType = when (typeName) {
258
389
  "inapp" -> {
259
- Log.w(TAG, "getAvailablePurchases received legacy type 'inapp'; forwarding as 'in-app'")
390
+ RnIapLog.warn("getAvailablePurchases received legacy type 'inapp'; forwarding as 'in-app'")
260
391
  "in-app"
261
392
  }
262
393
  "in-app", "subs" -> typeName
@@ -264,11 +395,20 @@ class HybridRnIap : HybridRnIapSpec() {
264
395
  }
265
396
 
266
397
  val result: List<OpenIapPurchase> = if (normalizedType != null) {
267
- val typeEnum = ProductRequest.ProductRequestType.fromString(normalizedType)
398
+ val typeEnum = parseProductQueryType(normalizedType)
399
+ RnIapLog.payload(
400
+ "getAvailablePurchases.native",
401
+ mapOf("type" to typeEnum.rawValue)
402
+ )
268
403
  openIap.getAvailableItems(typeEnum)
269
404
  } else {
270
- openIap.getAvailablePurchases()
405
+ RnIapLog.payload("getAvailablePurchases.native", mapOf("type" to "all"))
406
+ openIap.getAvailablePurchases(null)
271
407
  }
408
+ RnIapLog.result(
409
+ "getAvailablePurchases",
410
+ result.map { mapOf("id" to it.id, "sku" to it.productId) }
411
+ )
272
412
  result.map { convertToNitroPurchase(it) }.toTypedArray()
273
413
  }
274
414
  }
@@ -280,8 +420,17 @@ class HybridRnIap : HybridRnIapSpec() {
280
420
  val purchaseToken = androidParams.purchaseToken
281
421
  val isConsumable = androidParams.isConsumable ?: false
282
422
 
423
+ RnIapLog.payload(
424
+ "finishTransaction",
425
+ mapOf(
426
+ "purchaseToken" to purchaseToken?.let { "<hidden>" },
427
+ "isConsumable" to isConsumable
428
+ )
429
+ )
430
+
283
431
  // Validate token early to avoid confusing native errors
284
432
  if (purchaseToken.isNullOrBlank()) {
433
+ RnIapLog.warn("finishTransaction called with missing purchaseToken")
285
434
  return@async Variant_Boolean_NitroPurchaseResult.Second(
286
435
  NitroPurchaseResult(
287
436
  responseCode = -1.0,
@@ -315,7 +464,7 @@ class HybridRnIap : HybridRnIapSpec() {
315
464
  } else {
316
465
  openIap.acknowledgePurchaseAndroid(purchaseToken)
317
466
  }
318
- Variant_Boolean_NitroPurchaseResult.Second(
467
+ val result = Variant_Boolean_NitroPurchaseResult.Second(
319
468
  NitroPurchaseResult(
320
469
  responseCode = 0.0,
321
470
  debugMessage = null,
@@ -324,8 +473,11 @@ class HybridRnIap : HybridRnIapSpec() {
324
473
  purchaseToken = purchaseToken
325
474
  )
326
475
  )
476
+ RnIapLog.result("finishTransaction", mapOf("success" to true))
477
+ result
327
478
  } catch (e: Exception) {
328
479
  val err = OpenIAPError.BillingError()
480
+ RnIapLog.failure("finishTransaction", e)
329
481
  Variant_Boolean_NitroPurchaseResult.Second(
330
482
  NitroPurchaseResult(
331
483
  responseCode = -1.0,
@@ -364,13 +516,14 @@ class HybridRnIap : HybridRnIapSpec() {
364
516
  override fun addPromotedProductListenerIOS(listener: (product: NitroProduct) -> Unit) {
365
517
  // Promoted products are iOS-only, but we implement the interface for consistency
366
518
  promotedProductListenersIOS.add(listener)
367
- Log.w(TAG, "addPromotedProductListenerIOS called on Android - promoted products are iOS-only")
519
+ RnIapLog.warn("addPromotedProductListenerIOS called on Android - promoted products are iOS-only")
368
520
  }
369
-
521
+
370
522
  override fun removePromotedProductListenerIOS(listener: (product: NitroProduct) -> Unit) {
371
523
  // Promoted products are iOS-only, but we implement the interface for consistency
372
- promotedProductListenersIOS.clear()
373
- 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")
374
527
  }
375
528
 
376
529
  // Billing callbacks handled internally by OpenIAP
@@ -381,6 +534,10 @@ class HybridRnIap : HybridRnIapSpec() {
381
534
  * Send purchase update event to listeners
382
535
  */
383
536
  private fun sendPurchaseUpdate(purchase: NitroPurchase) {
537
+ RnIapLog.result(
538
+ "sendPurchaseUpdate",
539
+ mapOf("productId" to purchase.productId, "platform" to purchase.platform)
540
+ )
384
541
  for (listener in purchaseUpdatedListeners) {
385
542
  listener(purchase)
386
543
  }
@@ -390,6 +547,10 @@ class HybridRnIap : HybridRnIapSpec() {
390
547
  * Send purchase error event to listeners
391
548
  */
392
549
  private fun sendPurchaseError(error: NitroPurchaseResult) {
550
+ RnIapLog.result(
551
+ "sendPurchaseError",
552
+ mapOf("code" to error.code, "message" to error.message)
553
+ )
393
554
  for (listener in purchaseErrorListeners) {
394
555
  listener(error)
395
556
  }
@@ -413,12 +574,75 @@ class HybridRnIap : HybridRnIapSpec() {
413
574
  purchaseToken = null
414
575
  )
415
576
  }
416
-
417
- private fun convertToNitroProduct(product: OpenIapProduct): NitroProduct {
418
- val subOffers = product.subscriptionOfferDetailsAndroid
419
- val subOffersJson = subOffers?.let { OpenIapSerialization.toJson(it) }
420
577
 
421
- // 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
+
422
646
  var originalPriceAndroid: String? = null
423
647
  var originalPriceAmountMicrosAndroid: Double? = null
424
648
  var introductoryPriceValueAndroid: Double? = null
@@ -427,33 +651,28 @@ class HybridRnIap : HybridRnIapSpec() {
427
651
  var subscriptionPeriodAndroid: String? = null
428
652
  var freeTrialPeriodAndroid: String? = null
429
653
 
430
- if (product.type == OpenIapProduct.ProductType.InApp) {
431
- product.oneTimePurchaseOfferDetailsAndroid?.let { otp ->
654
+ if (product.type == ProductType.InApp) {
655
+ oneTimeOffer?.let { otp ->
432
656
  originalPriceAndroid = otp.formattedPrice
433
- // priceAmountMicros is a string; parse to number if possible
434
657
  originalPriceAmountMicrosAndroid = otp.priceAmountMicros.toDoubleOrNull()
435
658
  }
436
659
  } else {
437
- // SUBS: inspect pricing phases
438
- val phases = subOffers?.firstOrNull()?.pricingPhases?.pricingPhaseList
439
- if (!phases.isNullOrEmpty()) {
440
- // Base recurring phase: recurrenceMode == 2 (INFINITE), else last non-zero priced phase
660
+ val phases = subscriptionOffers.firstOrNull()?.pricingPhases?.pricingPhaseList.orEmpty()
661
+ if (phases.isNotEmpty()) {
441
662
  val basePhase = phases.firstOrNull { it.recurrenceMode == 2 } ?: phases.last()
442
663
  originalPriceAndroid = basePhase.formattedPrice
443
664
  originalPriceAmountMicrosAndroid = basePhase.priceAmountMicros.toDoubleOrNull()
444
665
  subscriptionPeriodAndroid = basePhase.billingPeriod
445
666
 
446
- // Introductory phase: finite cycles (>0) and priced (>0)
447
667
  val introPhase = phases.firstOrNull {
448
668
  it.billingCycleCount > 0 && (it.priceAmountMicros.toLongOrNull() ?: 0L) > 0L
449
669
  }
450
670
  if (introPhase != null) {
451
- introductoryPriceValueAndroid = (introPhase.priceAmountMicros.toDoubleOrNull() ?: 0.0) / 1_000_000.0
671
+ introductoryPriceValueAndroid = introPhase.priceAmountMicros.toDoubleOrNull()?.div(1_000_000.0)
452
672
  introductoryPriceCyclesAndroid = introPhase.billingCycleCount.toDouble()
453
673
  introductoryPricePeriodAndroid = introPhase.billingPeriod
454
674
  }
455
675
 
456
- // Free trial: zero-priced phase
457
676
  val trialPhase = phases.firstOrNull { (it.priceAmountMicros.toLongOrNull() ?: 0L) == 0L }
458
677
  if (trialPhase != null) {
459
678
  freeTrialPeriodAndroid = trialPhase.billingPeriod
@@ -465,13 +684,12 @@ class HybridRnIap : HybridRnIapSpec() {
465
684
  id = product.id,
466
685
  title = product.title,
467
686
  description = product.description,
468
- type = product.type.value,
687
+ type = product.type.rawValue,
469
688
  displayName = product.displayName,
470
689
  displayPrice = product.displayPrice,
471
690
  currency = product.currency,
472
691
  price = product.price,
473
692
  platform = IapPlatform.ANDROID,
474
- // iOS fields (null on Android)
475
693
  typeIOS = null,
476
694
  isFamilyShareableIOS = null,
477
695
  jsonRepresentationIOS = null,
@@ -482,7 +700,6 @@ class HybridRnIap : HybridRnIapSpec() {
482
700
  introductoryPricePaymentModeIOS = null,
483
701
  introductoryPriceNumberOfPeriodsIOS = null,
484
702
  introductoryPriceSubscriptionPeriodIOS = null,
485
- // Android derivations
486
703
  originalPriceAndroid = originalPriceAndroid,
487
704
  originalPriceAmountMicrosAndroid = originalPriceAmountMicrosAndroid,
488
705
  introductoryPriceValueAndroid = introductoryPriceValueAndroid,
@@ -490,55 +707,52 @@ class HybridRnIap : HybridRnIapSpec() {
490
707
  introductoryPricePeriodAndroid = introductoryPricePeriodAndroid,
491
708
  subscriptionPeriodAndroid = subscriptionPeriodAndroid,
492
709
  freeTrialPeriodAndroid = freeTrialPeriodAndroid,
493
- subscriptionOfferDetailsAndroid = subOffersJson
710
+ subscriptionOfferDetailsAndroid = subscriptionOffersJson
494
711
  )
495
712
  }
496
713
 
497
714
  // Purchase state is provided as enum value by OpenIAP
498
715
 
499
716
  private fun convertToNitroPurchase(purchase: OpenIapPurchase): NitroPurchase {
500
- // Map OpenIAP purchase state back to legacy numeric Android state for compatibility
717
+ val androidPurchase = purchase as? PurchaseAndroid
501
718
  val purchaseStateAndroidNumeric = when (purchase.purchaseState) {
502
- OpenIapPurchase.PurchaseState.Purchased -> 1.0
503
- OpenIapPurchase.PurchaseState.Pending -> 2.0
504
- 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
505
722
  }
506
723
  return NitroPurchase(
507
724
  id = purchase.id,
508
725
  productId = purchase.productId,
509
- transactionDate = purchase.transactionDate.toDouble(),
726
+ transactionDate = purchase.transactionDate,
510
727
  purchaseToken = purchase.purchaseToken,
511
728
  platform = IapPlatform.ANDROID,
512
- // Common fields
513
729
  quantity = purchase.quantity.toDouble(),
514
730
  purchaseState = mapPurchaseState(purchase.purchaseState),
515
731
  isAutoRenewing = purchase.isAutoRenewing,
516
- // iOS fields
517
732
  quantityIOS = null,
518
733
  originalTransactionDateIOS = null,
519
734
  originalTransactionIdentifierIOS = null,
520
735
  appAccountToken = null,
521
- // Android fields
522
- purchaseTokenAndroid = purchase.purchaseTokenAndroid,
523
- dataAndroid = purchase.dataAndroid,
524
- signatureAndroid = purchase.signatureAndroid,
525
- autoRenewingAndroid = purchase.autoRenewingAndroid,
736
+ purchaseTokenAndroid = androidPurchase?.purchaseToken,
737
+ dataAndroid = androidPurchase?.dataAndroid,
738
+ signatureAndroid = androidPurchase?.signatureAndroid,
739
+ autoRenewingAndroid = androidPurchase?.autoRenewingAndroid,
526
740
  purchaseStateAndroid = purchaseStateAndroidNumeric,
527
- isAcknowledgedAndroid = purchase.isAcknowledgedAndroid,
528
- packageNameAndroid = purchase.packageNameAndroid,
529
- obfuscatedAccountIdAndroid = purchase.obfuscatedAccountIdAndroid,
530
- obfuscatedProfileIdAndroid = purchase.obfuscatedProfileIdAndroid
741
+ isAcknowledgedAndroid = androidPurchase?.isAcknowledgedAndroid,
742
+ packageNameAndroid = androidPurchase?.packageNameAndroid,
743
+ obfuscatedAccountIdAndroid = androidPurchase?.obfuscatedAccountIdAndroid,
744
+ obfuscatedProfileIdAndroid = androidPurchase?.obfuscatedProfileIdAndroid
531
745
  )
532
746
  }
533
747
 
534
- private fun mapPurchaseState(state: OpenIapPurchase.PurchaseState): PurchaseState {
535
- return when (state.name.uppercase()) {
536
- "PURCHASED" -> PurchaseState.PURCHASED
537
- "PENDING" -> PurchaseState.PENDING
538
- "DEFERRED" -> PurchaseState.DEFERRED
539
- "RESTORED" -> PurchaseState.RESTORED
540
- "FAILED", "FAILURE", "CANCELED", "CANCELLED" -> PurchaseState.FAILED
541
- 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
542
756
  }
543
757
  }
544
758
 
@@ -563,9 +777,12 @@ class HybridRnIap : HybridRnIapSpec() {
563
777
  return Promise.async {
564
778
  try {
565
779
  initConnection().await()
566
- openIap.getStorefront()
780
+ RnIapLog.payload("getStorefrontAndroid", null)
781
+ val value = openIap.getStorefront()
782
+ RnIapLog.result("getStorefrontAndroid", value)
783
+ value
567
784
  } catch (e: Exception) {
568
- Log.w(TAG, "getStorefrontAndroid failed", e)
785
+ RnIapLog.failure("getStorefrontAndroid", e)
569
786
  ""
570
787
  }
571
788
  }
@@ -580,13 +797,21 @@ class HybridRnIap : HybridRnIapSpec() {
580
797
  skuAndroid = options.skuAndroid,
581
798
  packageNameAndroid = options.packageNameAndroid
582
799
  ).let { openIap.deepLinkToSubscriptions(it) }
800
+ RnIapLog.result("deepLinkToSubscriptionsAndroid", true)
583
801
  } catch (e: Exception) {
584
- Log.e(TAG, "deepLinkToSubscriptionsAndroid failed", e)
802
+ RnIapLog.failure("deepLinkToSubscriptionsAndroid", e)
585
803
  throw e
586
804
  }
587
805
  }
588
806
  }
589
807
 
808
+ // iOS-specific method - not supported on Android
809
+ override fun getPromotedProductIOS(): Promise<NitroProduct?> {
810
+ return Promise.async {
811
+ null
812
+ }
813
+ }
814
+
590
815
  // iOS-specific method - not supported on Android
591
816
  override fun requestPromotedProductIOS(): Promise<NitroProduct?> {
592
817
  return Promise.async {
@@ -634,6 +859,12 @@ class HybridRnIap : HybridRnIapSpec() {
634
859
  }
635
860
  }
636
861
 
862
+ override fun deepLinkToSubscriptionsIOS(): Promise<Boolean> {
863
+ return Promise.async {
864
+ false
865
+ }
866
+ }
867
+
637
868
  // Receipt validation
638
869
  override fun validateReceipt(params: NitroReceiptValidationParams): Promise<Variant_NitroReceiptValidationResultIOS_NitroReceiptValidationResultAndroid> {
639
870
  return Promise.async {
@@ -734,7 +965,19 @@ class HybridRnIap : HybridRnIapSpec() {
734
965
  throw Exception(toErrorJson(OpenIAPError.NotSupported))
735
966
  }
736
967
  }
737
-
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
+
738
981
  override fun isTransactionVerifiedIOS(sku: String): Promise<Boolean> {
739
982
  return Promise.async {
740
983
  throw Exception(toErrorJson(OpenIAPError.NotSupported))
@@ -756,9 +999,10 @@ class HybridRnIap : HybridRnIapSpec() {
756
999
  debugMessage: String? = null,
757
1000
  messageOverride: String? = null
758
1001
  ): String {
759
- val code = OpenIAPError.toCode(error)
1002
+ val code = OpenIAPError.Companion.toCode(error)
760
1003
  val message = messageOverride?.takeIf { it.isNotBlank() }
761
- ?: error.message.ifEmpty { OpenIAPError.defaultMessage(code) }
1004
+ ?: error.message?.takeIf { it.isNotBlank() }
1005
+ ?: OpenIAPError.Companion.defaultMessage(code)
762
1006
  return BillingUtils.createErrorJson(
763
1007
  code = code,
764
1008
  message = message,
@@ -774,9 +1018,10 @@ class HybridRnIap : HybridRnIapSpec() {
774
1018
  debugMessage: String? = null,
775
1019
  messageOverride: String? = null
776
1020
  ): NitroPurchaseResult {
777
- val code = OpenIAPError.toCode(error)
1021
+ val code = OpenIAPError.Companion.toCode(error)
778
1022
  val message = messageOverride?.takeIf { it.isNotBlank() }
779
- ?: error.message.ifEmpty { OpenIAPError.defaultMessage(code) }
1023
+ ?: error.message?.takeIf { it.isNotBlank() }
1024
+ ?: OpenIAPError.Companion.defaultMessage(code)
780
1025
  return NitroPurchaseResult(
781
1026
  responseCode = -1.0,
782
1027
  debugMessage = debugMessage ?: error.message,