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.
- package/README.md +2 -6
- package/android/build.gradle +4 -5
- package/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +327 -547
- package/ios/HybridRnIap.swift +16 -0
- package/lib/module/index.js +43 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/plugin/src/withIAP.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +15 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/specs/RnIap.nitro.d.ts +17 -0
- package/lib/typescript/src/specs/RnIap.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridRnIapSpec.cpp +35 -0
- package/nitrogen/generated/android/c++/JHybridRnIapSpec.hpp +2 -0
- package/nitrogen/generated/android/c++/JNitroDeepLinkOptionsAndroid.hpp +58 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/HybridRnIapSpec.kt +8 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/NitroDeepLinkOptionsAndroid.kt +32 -0
- package/nitrogen/generated/ios/NitroIap-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridRnIapSpecSwift.hpp +19 -0
- package/nitrogen/generated/ios/swift/HybridRnIapSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridRnIapSpec_cxx.swift +38 -0
- package/nitrogen/generated/ios/swift/NitroDeepLinkOptionsAndroid.swift +84 -0
- package/nitrogen/generated/shared/c++/HybridRnIapSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridRnIapSpec.hpp +5 -0
- package/nitrogen/generated/shared/c++/NitroDeepLinkOptionsAndroid.hpp +72 -0
- package/package.json +1 -1
- package/plugin/build/withIAP.js +21 -18
- package/plugin/src/withIAP.ts +31 -23
- package/src/index.ts +48 -0
- 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
|
|
13
|
-
import
|
|
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
|
|
18
|
-
import kotlin.coroutines.resumeWithException
|
|
19
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
19
20
|
|
|
20
|
-
class HybridRnIap : HybridRnIapSpec()
|
|
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
|
-
|
|
32
|
-
private val
|
|
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
|
|
44
|
-
|
|
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
|
-
//
|
|
48
|
-
val
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
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(
|
|
82
|
-
IapErrorCode.E_EMPTY_SKU_LIST,
|
|
83
|
-
"SKU list is empty"
|
|
84
|
-
))
|
|
128
|
+
throw Exception(toErrorJson(OpenIapError.EmptySkuList))
|
|
85
129
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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(
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
.
|
|
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 =
|
|
329
|
-
debugMessage =
|
|
330
|
-
code =
|
|
331
|
-
message =
|
|
332
|
-
purchaseToken =
|
|
249
|
+
responseCode = 0.0,
|
|
250
|
+
debugMessage = null,
|
|
251
|
+
code = "0",
|
|
252
|
+
message = "OK",
|
|
253
|
+
purchaseToken = purchaseToken
|
|
333
254
|
)
|
|
334
255
|
)
|
|
335
|
-
}
|
|
336
|
-
|
|
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 =
|
|
350
|
-
debugMessage =
|
|
351
|
-
code =
|
|
352
|
-
message =
|
|
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
|
-
//
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
615
|
-
id =
|
|
616
|
-
title =
|
|
617
|
-
description =
|
|
618
|
-
type = type,
|
|
619
|
-
displayName =
|
|
620
|
-
displayPrice = displayPrice,
|
|
621
|
-
currency = currency,
|
|
622
|
-
price =
|
|
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
|
|
636
|
-
originalPriceAndroid =
|
|
637
|
-
originalPriceAmountMicrosAndroid =
|
|
638
|
-
introductoryPriceValueAndroid =
|
|
639
|
-
introductoryPriceCyclesAndroid =
|
|
640
|
-
introductoryPricePeriodAndroid =
|
|
641
|
-
subscriptionPeriodAndroid =
|
|
642
|
-
freeTrialPeriodAndroid =
|
|
643
|
-
subscriptionOfferDetailsAndroid =
|
|
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
|
-
|
|
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.
|
|
667
|
-
productId = purchase.
|
|
668
|
-
transactionDate = purchase.
|
|
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 =
|
|
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.
|
|
682
|
-
dataAndroid = purchase.
|
|
683
|
-
signatureAndroid = purchase.
|
|
684
|
-
autoRenewingAndroid = purchase.
|
|
685
|
-
purchaseStateAndroid =
|
|
686
|
-
isAcknowledgedAndroid = purchase.
|
|
687
|
-
packageNameAndroid = purchase.
|
|
688
|
-
obfuscatedAccountIdAndroid = purchase.
|
|
689
|
-
obfuscatedProfileIdAndroid = purchase.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
}
|