@namiml/expo-nami-iap 3.4.0-dev.202605060437

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 (43) hide show
  1. package/README.md +121 -0
  2. package/android/build.gradle +46 -0
  3. package/android/gradle.properties +1 -0
  4. package/android/libs/.gitkeep +0 -0
  5. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackend.kt +427 -0
  6. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonProductMapper.kt +119 -0
  7. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonPurchasingListener.kt +35 -0
  8. package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazon.kt +46 -0
  9. package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazonReceiver.kt +48 -0
  10. package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingBackend.kt +446 -0
  11. package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingConnection.kt +104 -0
  12. package/android/src/main/AndroidManifest.xml +1 -0
  13. package/android/src/main/java/ml/nami/expo/iap/Errors.kt +94 -0
  14. package/android/src/main/java/ml/nami/expo/iap/ExpoNamiIapModule.kt +56 -0
  15. package/android/src/main/java/ml/nami/expo/iap/IapBackend.kt +72 -0
  16. package/android/src/main/java/ml/nami/expo/iap/ProductMapper.kt +191 -0
  17. package/android/src/test/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackendTest.kt +974 -0
  18. package/android/src/test/java/ml/nami/expo/iap/ErrorsTest.kt +184 -0
  19. package/android/src/test/java/ml/nami/expo/iap/ExpoNamiIapModuleTest.kt +86 -0
  20. package/android/src/test/java/ml/nami/expo/iap/PlaceholderTest.kt +8 -0
  21. package/android/src/test/java/ml/nami/expo/iap/ProductMapperTest.kt +335 -0
  22. package/android/src/test/java/ml/nami/expo/iap/google/PlayBillingBackendTest.kt +841 -0
  23. package/android/src/testAmazon/java/ml/nami/expo/iap/ExpoNamiIapModuleAmazonFlavorTest.kt +71 -0
  24. package/dist/index.cjs +56 -0
  25. package/dist/index.d.ts +62 -0
  26. package/dist/index.mjs +45 -0
  27. package/expo-module.config.json +9 -0
  28. package/ios/Errors.swift +178 -0
  29. package/ios/ExpoNamiIap.podspec +14 -0
  30. package/ios/ExpoNamiIapModule.swift +81 -0
  31. package/ios/ProductMapper.swift +130 -0
  32. package/ios/PromotionalOfferSigner.swift +78 -0
  33. package/ios/StoreKit2Helper.swift +252 -0
  34. package/ios/Tests/ErrorsTests.swift +189 -0
  35. package/ios/Tests/ProductMapperTests.swift +165 -0
  36. package/ios/Tests/Resources/Products.storekit +210 -0
  37. package/ios/Tests/StoreKit2HelperTests.swift +360 -0
  38. package/ios/TransactionObserver.swift +54 -0
  39. package/package.json +64 -0
  40. package/src/ExpoNamiIapModule.kepler.ts +200 -0
  41. package/src/ExpoNamiIapModule.ts +18 -0
  42. package/src/index.ts +63 -0
  43. package/src/types.ts +46 -0
@@ -0,0 +1,119 @@
1
+ package ml.nami.expo.iap.amazon
2
+
3
+ import com.amazon.device.iap.model.Product
4
+ import com.amazon.device.iap.model.ProductType
5
+
6
+ /**
7
+ * Maps an Amazon IAP [Product] into a [Map] matching the
8
+ * `NamiProductDetails` / `ExpoNamiIapProduct` TypeScript type.
9
+ *
10
+ * Output keys mirror [ml.nami.expo.iap.ProductMapper] for Google Play:
11
+ * - `product_ref_id` (String)
12
+ * - `name` (String)
13
+ * - `description` (String)
14
+ * - `product_type` (`"one_time_purchase"` | `"subscription"`)
15
+ * - `offers` (List<Map<String, Any?>>)
16
+ *
17
+ * Amazon does not expose subscription period or structured pricing at the
18
+ * product level, so `subscription_period` is omitted and the price amount
19
+ * is best-effort parsed from the display string (e.g. "$9.99").
20
+ */
21
+ internal object AmazonProductMapper {
22
+ /**
23
+ * Regex to extract the first decimal number from a price display string.
24
+ *
25
+ * Handles formats like:
26
+ * - `$9.99`
27
+ * - `EUR 12.49`
28
+ * - `9,99 EUR` (comma as decimal separator)
29
+ * - `1.234,56` (thousands separator + comma decimal)
30
+ */
31
+ private val PRICE_REGEX = Regex("""(\d[\d.,]*\d)""")
32
+
33
+ /**
34
+ * Convert an Amazon [Product] to a dictionary suitable for bridging to TypeScript.
35
+ */
36
+ fun toMap(product: Product): Map<String, Any?> =
37
+ mapOf(
38
+ "product_ref_id" to product.sku,
39
+ "name" to product.title,
40
+ "description" to product.description,
41
+ "product_type" to mapProductType(product.productType),
42
+ "offers" to buildOffers(product),
43
+ )
44
+
45
+ // ── Product type ────────────────────────────────────────────────────
46
+
47
+ private fun mapProductType(type: ProductType): String =
48
+ when (type) {
49
+ ProductType.CONSUMABLE,
50
+ ProductType.ENTITLED,
51
+ -> "one_time_purchase"
52
+ ProductType.SUBSCRIPTION -> "subscription"
53
+ }
54
+
55
+ // ── Offers ──────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Build a single "default" offer from the Amazon product's price string.
59
+ *
60
+ * Amazon does not expose promo/intro offers at the product level, so
61
+ * the offers list always contains exactly one entry.
62
+ */
63
+ private fun buildOffers(product: Product): List<Map<String, Any?>> {
64
+ val priceAmount = parsePrice(product.price)
65
+ return listOf(
66
+ mapOf(
67
+ "offer_type" to "default",
68
+ "price" to priceAmount,
69
+ "price_display" to product.price,
70
+ ),
71
+ )
72
+ }
73
+
74
+ // ── Price parsing ──────────────────────────────────────────────────
75
+
76
+ /**
77
+ * Best-effort extraction of a numeric price from Amazon's display string.
78
+ *
79
+ * Amazon IAP does not provide structured price data (no `priceAmountMicros`).
80
+ * The display string varies by locale, e.g. `"$9.99"`, `"EUR 12,49"`.
81
+ *
82
+ * Strategy:
83
+ * 1. Extract the first sequence of digits, dots, and commas.
84
+ * 2. If the string contains both dot and comma, treat the last one as the
85
+ * decimal separator and strip the other (thousands separator).
86
+ * 3. Replace comma with dot for standard Double parsing.
87
+ *
88
+ * Returns 0.0 if parsing fails.
89
+ */
90
+ internal fun parsePrice(priceString: String?): Double {
91
+ if (priceString.isNullOrBlank()) return 0.0
92
+
93
+ val match = PRICE_REGEX.find(priceString) ?: return 0.0
94
+ var numStr = match.groupValues[1]
95
+
96
+ val lastDot = numStr.lastIndexOf('.')
97
+ val lastComma = numStr.lastIndexOf(',')
98
+
99
+ numStr =
100
+ when {
101
+ // Both dot and comma present — last one is the decimal separator
102
+ lastDot >= 0 && lastComma >= 0 -> {
103
+ if (lastComma > lastDot) {
104
+ // e.g. "1.234,56" → comma is decimal
105
+ numStr.replace(".", "").replace(",", ".")
106
+ } else {
107
+ // e.g. "1,234.56" → dot is decimal
108
+ numStr.replace(",", "")
109
+ }
110
+ }
111
+ // Only comma — treat as decimal separator
112
+ lastComma >= 0 -> numStr.replace(",", ".")
113
+ // Only dot or neither — already parseable
114
+ else -> numStr
115
+ }
116
+
117
+ return numStr.toDoubleOrNull() ?: 0.0
118
+ }
119
+ }
@@ -0,0 +1,35 @@
1
+ package ml.nami.expo.iap.amazon
2
+
3
+ import com.amazon.device.iap.PurchasingListener
4
+ import com.amazon.device.iap.model.ProductDataResponse
5
+ import com.amazon.device.iap.model.PurchaseResponse
6
+ import com.amazon.device.iap.model.PurchaseUpdatesResponse
7
+ import com.amazon.device.iap.model.UserDataResponse
8
+
9
+ /**
10
+ * Amazon IAP [PurchasingListener] that delegates all callbacks to
11
+ * [AmazonIapBackend] for request-ID demultiplexing.
12
+ *
13
+ * Amazon's IAP SDK uses a single process-global listener registered via
14
+ * `PurchasingService.registerListener`. This class is a thin adapter that
15
+ * forwards each response to the backend for promise resolution.
16
+ */
17
+ internal class AmazonPurchasingListener(
18
+ private val backend: AmazonIapBackend,
19
+ ) : PurchasingListener {
20
+ override fun onProductDataResponse(response: ProductDataResponse) {
21
+ backend.handleProductDataResponse(response)
22
+ }
23
+
24
+ override fun onPurchaseResponse(response: PurchaseResponse) {
25
+ backend.handlePurchaseResponse(response)
26
+ }
27
+
28
+ override fun onPurchaseUpdatesResponse(response: PurchaseUpdatesResponse) {
29
+ backend.handlePurchaseUpdatesResponse(response)
30
+ }
31
+
32
+ override fun onUserDataResponse(response: UserDataResponse) {
33
+ // Not used in this module
34
+ }
35
+ }
@@ -0,0 +1,46 @@
1
+ package ml.nami.expo.iap.amazon
2
+
3
+ import java.lang.ref.WeakReference
4
+
5
+ /**
6
+ * Singleton configuration object for the Amazon IAP backend.
7
+ *
8
+ * Provides a mode flag (`autoRegisterListener`) that controls whether
9
+ * [AmazonIapBackend] automatically calls `PurchasingService.registerListener`
10
+ * during [AmazonIapBackend.initConnection]. When set to `false` (receiver
11
+ * mode), the host app is responsible for registering its own
12
+ * `PurchasingListener` and forwarding events via
13
+ * [ExpoNamiIapAmazonReceiver].
14
+ */
15
+ object ExpoNamiIapAmazon {
16
+ private var autoRegister: Boolean = true
17
+ internal var backendRef: WeakReference<AmazonIapBackend>? = null
18
+
19
+ /**
20
+ * Enable or disable automatic listener registration.
21
+ *
22
+ * When `enabled` is `false`, [AmazonIapBackend] will not call
23
+ * `PurchasingService.registerListener`. The host app must register its
24
+ * own listener and forward events through [ExpoNamiIapAmazonReceiver].
25
+ *
26
+ * Defaults to `true` (auto-register mode).
27
+ */
28
+ @JvmStatic
29
+ fun setAutoRegisterListener(enabled: Boolean) {
30
+ autoRegister = enabled
31
+ }
32
+
33
+ internal fun autoRegisterEnabled(): Boolean = autoRegister
34
+
35
+ internal fun setBackend(backend: AmazonIapBackend) {
36
+ backendRef = WeakReference(backend)
37
+ }
38
+
39
+ /**
40
+ * Reset state for testing. Not part of the public API.
41
+ */
42
+ internal fun resetForTesting() {
43
+ autoRegister = true
44
+ backendRef = null
45
+ }
46
+ }
@@ -0,0 +1,48 @@
1
+ package ml.nami.expo.iap.amazon
2
+
3
+ import com.amazon.device.iap.model.ProductDataResponse
4
+ import com.amazon.device.iap.model.PurchaseResponse
5
+ import com.amazon.device.iap.model.PurchaseUpdatesResponse
6
+ import com.amazon.device.iap.model.UserDataResponse
7
+
8
+ /**
9
+ * Public forwarding surface for receiver mode.
10
+ *
11
+ * When the host app owns `PurchasingService.registerListener`, it should
12
+ * forward every callback to the corresponding `@JvmStatic` method here.
13
+ * Each method delegates to the active [AmazonIapBackend] instance via the
14
+ * weak reference held by [ExpoNamiIapAmazon].
15
+ *
16
+ * Example (Java):
17
+ * ```java
18
+ * PurchasingService.registerListener(context, new PurchasingListener() {
19
+ * @Override public void onProductDataResponse(ProductDataResponse r) {
20
+ * ExpoNamiIapAmazonReceiver.onProductDataResponse(r);
21
+ * }
22
+ * // … other callbacks …
23
+ * });
24
+ * ```
25
+ */
26
+ object ExpoNamiIapAmazonReceiver {
27
+ @JvmStatic
28
+ fun onProductDataResponse(response: ProductDataResponse) {
29
+ ExpoNamiIapAmazon.backendRef?.get()?.handleProductDataResponse(response)
30
+ }
31
+
32
+ @JvmStatic
33
+ fun onPurchaseResponse(response: PurchaseResponse) {
34
+ ExpoNamiIapAmazon.backendRef?.get()?.handlePurchaseResponse(response)
35
+ }
36
+
37
+ @JvmStatic
38
+ fun onPurchaseUpdatesResponse(response: PurchaseUpdatesResponse) {
39
+ ExpoNamiIapAmazon.backendRef?.get()?.handlePurchaseUpdatesResponse(response)
40
+ }
41
+
42
+ @JvmStatic
43
+ fun onUserDataResponse(
44
+ @Suppress("UNUSED_PARAMETER") response: UserDataResponse,
45
+ ) {
46
+ // No-op for now — user data is not consumed by this module.
47
+ }
48
+ }
@@ -0,0 +1,446 @@
1
+ package ml.nami.expo.iap.google
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import com.android.billingclient.api.AcknowledgePurchaseParams
6
+ import com.android.billingclient.api.BillingClient
7
+ import com.android.billingclient.api.BillingClient.BillingResponseCode
8
+ import com.android.billingclient.api.BillingClient.ProductType
9
+ import com.android.billingclient.api.BillingFlowParams
10
+ import com.android.billingclient.api.BillingResult
11
+ import com.android.billingclient.api.ConsumeParams
12
+ import com.android.billingclient.api.ProductDetails
13
+ import com.android.billingclient.api.Purchase
14
+ import com.android.billingclient.api.PurchasesUpdatedListener
15
+ import com.android.billingclient.api.QueryProductDetailsParams
16
+ import com.android.billingclient.api.QueryPurchasesParams
17
+ import com.android.billingclient.api.acknowledgePurchase
18
+ import com.android.billingclient.api.consumePurchase
19
+ import com.android.billingclient.api.queryProductDetails
20
+ import com.android.billingclient.api.queryPurchasesAsync
21
+ import kotlinx.coroutines.CancellableContinuation
22
+ import kotlinx.coroutines.async
23
+ import kotlinx.coroutines.coroutineScope
24
+ import kotlinx.coroutines.suspendCancellableCoroutine
25
+ import ml.nami.expo.iap.Errors
26
+ import ml.nami.expo.iap.ExpoNamiIapException
27
+ import ml.nami.expo.iap.IapBackend
28
+ import ml.nami.expo.iap.ProductMapper
29
+ import java.util.concurrent.ConcurrentHashMap
30
+ import kotlin.coroutines.resume
31
+ import kotlin.coroutines.resumeWithException
32
+
33
+ /**
34
+ * Google Play Billing implementation of [IapBackend].
35
+ *
36
+ * Handles product queries, eligibility checks, purchase orchestration,
37
+ * transaction finishing, and purchase restoration using the Play Billing
38
+ * v7 KTX suspend extensions.
39
+ */
40
+ class PlayBillingBackend : IapBackend {
41
+ private var connection: PlayBillingConnection? = null
42
+ private val productCache = ConcurrentHashMap<String, ProductDetails>()
43
+ private val purchaseCache = ConcurrentHashMap<String, Purchase>()
44
+ private val pendingPurchases =
45
+ ConcurrentHashMap<String, CancellableContinuation<Map<String, Any?>>>()
46
+ private var transactionListener: ((Map<String, Any?>) -> Unit)? = null
47
+ private var errorListener: ((Map<String, Any?>) -> Unit)? = null
48
+
49
+ // ── Lifecycle ──────────────────────────────────────────────────────
50
+
51
+ override suspend fun initConnection(context: Context) {
52
+ val listener =
53
+ PurchasesUpdatedListener { result: BillingResult, purchases: List<Purchase>? ->
54
+ handlePurchasesUpdated(result, purchases)
55
+ }
56
+ connection = PlayBillingConnection(context, listener)
57
+ connection!!.ensureConnected()
58
+ }
59
+
60
+ override suspend fun endConnection() {
61
+ connection?.close()
62
+ connection = null
63
+ productCache.clear()
64
+ purchaseCache.clear()
65
+ pendingPurchases.clear()
66
+ }
67
+
68
+ // ── Products ───────────────────────────────────────────────────────
69
+
70
+ override suspend fun getProducts(skuIds: List<String>): List<Map<String, Any?>> {
71
+ val client = connection?.ensureConnected() ?: error("Not connected")
72
+
73
+ // Issue SUBS + INAPP queries in parallel
74
+ val allDetails =
75
+ coroutineScope {
76
+ val subsDeferred =
77
+ async {
78
+ queryByType(client, skuIds, ProductType.SUBS)
79
+ }
80
+ val inappDeferred =
81
+ async {
82
+ queryByType(client, skuIds, ProductType.INAPP)
83
+ }
84
+ subsDeferred.await() + inappDeferred.await()
85
+ }
86
+
87
+ // Cache all fetched products
88
+ for (pd in allDetails) {
89
+ productCache[pd.productId] = pd
90
+ }
91
+
92
+ // Preserve input order: build a lookup map by productId
93
+ val detailsByProductId = allDetails.associateBy { it.productId }
94
+ return skuIds.mapNotNull { skuId ->
95
+ detailsByProductId[skuId]?.let { ProductMapper.toMap(it) }
96
+ }
97
+ }
98
+
99
+ // ── Eligibility ────────────────────────────────────────────────────
100
+
101
+ override suspend fun checkEligibility(skuIds: List<String>): List<Map<String, Any?>> {
102
+ val client = connection?.ensureConnected() ?: error("Not connected")
103
+
104
+ // Fetch active subscription purchases
105
+ val subsParams =
106
+ QueryPurchasesParams
107
+ .newBuilder()
108
+ .setProductType(ProductType.SUBS)
109
+ .build()
110
+ val purchasesResult = client.queryPurchasesAsync(subsParams)
111
+ val activePurchases = purchasesResult.purchasesList
112
+
113
+ // Cache all returned purchases
114
+ for (purchase in activePurchases) {
115
+ purchaseCache[purchase.purchaseToken] = purchase
116
+ }
117
+
118
+ // Build a set of owned product IDs from active purchases
119
+ val ownedProductIds =
120
+ activePurchases
121
+ .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
122
+ .flatMap { it.products }
123
+ .toSet()
124
+
125
+ return skuIds.map { skuId ->
126
+ val cached = productCache[skuId]
127
+ val hasActivePurchase = skuId in ownedProductIds
128
+ val hasIntroOrTrial = cached?.let { hasIntroOrTrialOffer(it) } ?: false
129
+
130
+ mapOf(
131
+ "product_ref_id" to skuId,
132
+ "intro_eligible" to (hasIntroOrTrial && !hasActivePurchase),
133
+ "promo_eligible" to !hasActivePurchase,
134
+ )
135
+ }
136
+ }
137
+
138
+ // ── Listeners ──────────────────────────────────────────────────────
139
+
140
+ override fun onTransactionUpdate(callback: (Map<String, Any?>) -> Unit) {
141
+ transactionListener = callback
142
+ }
143
+
144
+ override fun onError(callback: (Map<String, Any?>) -> Unit) {
145
+ errorListener = callback
146
+ }
147
+
148
+ // ── Purchase ────────────────────────────────────────────────────────
149
+
150
+ override suspend fun purchase(
151
+ activity: Activity,
152
+ skuId: String,
153
+ offerId: String?,
154
+ offerSignature: String?,
155
+ appAccountToken: String?,
156
+ ): Map<String, Any?> {
157
+ val client = connection?.ensureConnected() ?: error("Not connected")
158
+
159
+ // Look up cached ProductDetails; fetch if missing
160
+ val pd =
161
+ productCache[skuId] ?: run {
162
+ getProducts(listOf(skuId))
163
+ productCache[skuId]
164
+ ?: throw ExpoNamiIapException.ItemUnavailable(
165
+ "Product not found: $skuId",
166
+ )
167
+ }
168
+
169
+ // Build ProductDetailsParams with optional offer token
170
+ val productDetailsParams = buildProductDetailsParams(pd, offerId)
171
+
172
+ // Build BillingFlowParams
173
+ val flowParamsBuilder =
174
+ BillingFlowParams
175
+ .newBuilder()
176
+ .setProductDetailsParamsList(listOf(productDetailsParams))
177
+ if (!appAccountToken.isNullOrEmpty()) {
178
+ flowParamsBuilder.setObfuscatedAccountId(appAccountToken)
179
+ }
180
+ val flowParams = flowParamsBuilder.build()
181
+
182
+ // Suspend until the PurchasesUpdatedListener resolves the continuation
183
+ return suspendCancellableCoroutine { cont ->
184
+ pendingPurchases[skuId] = cont
185
+ cont.invokeOnCancellation { pendingPurchases.remove(skuId) }
186
+
187
+ val launchResult = client.launchBillingFlow(activity, flowParams)
188
+ if (launchResult.responseCode != BillingResponseCode.OK) {
189
+ pendingPurchases.remove(skuId)
190
+ cont.resumeWithException(
191
+ Errors.toException(launchResult.responseCode, launchResult.debugMessage),
192
+ )
193
+ }
194
+ }
195
+ }
196
+
197
+ // ── Restore ─────────────────────────────────────────────────────────
198
+
199
+ override suspend fun restorePurchases(): List<Map<String, Any?>> {
200
+ val client = connection?.ensureConnected() ?: error("Not connected")
201
+
202
+ val (subs, inapp) =
203
+ coroutineScope {
204
+ val subsDeferred =
205
+ async {
206
+ client.queryPurchasesAsync(
207
+ QueryPurchasesParams
208
+ .newBuilder()
209
+ .setProductType(ProductType.SUBS)
210
+ .build(),
211
+ )
212
+ }
213
+ val inappDeferred =
214
+ async {
215
+ client.queryPurchasesAsync(
216
+ QueryPurchasesParams
217
+ .newBuilder()
218
+ .setProductType(ProductType.INAPP)
219
+ .build(),
220
+ )
221
+ }
222
+ subsDeferred.await() to inappDeferred.await()
223
+ }
224
+
225
+ val all =
226
+ (subs.purchasesList + inapp.purchasesList).filter {
227
+ it.purchaseState == Purchase.PurchaseState.PURCHASED
228
+ }
229
+
230
+ // Cache every returned purchase
231
+ for (purchase in all) {
232
+ purchaseCache[purchase.purchaseToken] = purchase
233
+ }
234
+
235
+ return all.flatMap { purchase ->
236
+ purchase.products.map { productId -> purchaseToDict(purchase, productId) }
237
+ }
238
+ }
239
+
240
+ // ── Finish ─────────────────────────────────────────────────────────
241
+
242
+ /**
243
+ * Finish a transaction identified by [transactionId] (the purchase token).
244
+ *
245
+ * - Subscriptions (`"subs"`) are acknowledged via [BillingClient.acknowledgePurchase].
246
+ * - In-app products (`"inapp"`) are consumed via [BillingClient.consumePurchase],
247
+ * which also implicitly acknowledges the purchase.
248
+ *
249
+ * If the purchase token is not found in the cache, this is a no-op (matching iOS behaviour).
250
+ */
251
+ override suspend fun finishTransaction(transactionId: String) {
252
+ val client = connection?.ensureConnected() ?: error("Not connected")
253
+
254
+ val purchase = purchaseCache[transactionId] ?: return
255
+ val productId = purchase.products.firstOrNull() ?: return
256
+ val productType = productCache[productId]?.productType
257
+
258
+ when (productType) {
259
+ ProductType.SUBS -> {
260
+ val params =
261
+ AcknowledgePurchaseParams
262
+ .newBuilder()
263
+ .setPurchaseToken(transactionId)
264
+ .build()
265
+ client.acknowledgePurchase(params)
266
+ }
267
+ ProductType.INAPP -> {
268
+ val params =
269
+ ConsumeParams
270
+ .newBuilder()
271
+ .setPurchaseToken(transactionId)
272
+ .build()
273
+ client.consumePurchase(params)
274
+ }
275
+ else -> {
276
+ // Unknown product type — acknowledge as a safe default
277
+ val params =
278
+ AcknowledgePurchaseParams
279
+ .newBuilder()
280
+ .setPurchaseToken(transactionId)
281
+ .build()
282
+ client.acknowledgePurchase(params)
283
+ }
284
+ }
285
+ }
286
+
287
+ // ── Internals ──────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Query Google Play for product details of a specific [productType].
291
+ */
292
+ private suspend fun queryByType(
293
+ client: BillingClient,
294
+ skuIds: List<String>,
295
+ productType: String,
296
+ ): List<ProductDetails> {
297
+ val productList =
298
+ skuIds.map { skuId ->
299
+ QueryProductDetailsParams.Product
300
+ .newBuilder()
301
+ .setProductId(skuId)
302
+ .setProductType(productType)
303
+ .build()
304
+ }
305
+ val params =
306
+ QueryProductDetailsParams
307
+ .newBuilder()
308
+ .setProductList(productList)
309
+ .build()
310
+ val result = client.queryProductDetails(params)
311
+ return if (result.billingResult.responseCode == BillingResponseCode.OK) {
312
+ result.productDetailsList ?: emptyList()
313
+ } else {
314
+ emptyList()
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Check whether a [ProductDetails] has any introductory or free-trial offer.
320
+ *
321
+ * An intro/trial offer is a subscription offer (with a non-null offerId)
322
+ * that contains at least one pricing phase with a price lower than the
323
+ * base plan's standard price, or a free (0-price) phase.
324
+ */
325
+ private fun hasIntroOrTrialOffer(pd: ProductDetails): Boolean {
326
+ if (pd.productType != ProductType.SUBS) return false
327
+ val subOffers = pd.subscriptionOfferDetails ?: return false
328
+
329
+ // Find the base plan's standard price
330
+ val basePlan = subOffers.firstOrNull { it.offerId == null }
331
+ val basePriceMicros =
332
+ basePlan
333
+ ?.pricingPhases
334
+ ?.pricingPhaseList
335
+ ?.firstOrNull { it.priceAmountMicros > 0 }
336
+ ?.priceAmountMicros ?: return false
337
+
338
+ // Check for offers with free-trial or intro pricing phases
339
+ return subOffers.any { offer ->
340
+ offer.offerId != null &&
341
+ offer.pricingPhases.pricingPhaseList.any { phase ->
342
+ phase.priceAmountMicros == 0L ||
343
+ phase.priceAmountMicros in 1 until basePriceMicros
344
+ }
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Build [BillingFlowParams.ProductDetailsParams] for the given [ProductDetails].
350
+ *
351
+ * For subscriptions, an offer token is required:
352
+ * - If [offerId] is specified, select the matching offer.
353
+ * - Otherwise, select the base plan (the entry with `offerId == null`).
354
+ *
355
+ * For in-app products, no offer token is set.
356
+ */
357
+ private fun buildProductDetailsParams(
358
+ pd: ProductDetails,
359
+ offerId: String?,
360
+ ): BillingFlowParams.ProductDetailsParams {
361
+ val builder =
362
+ BillingFlowParams.ProductDetailsParams
363
+ .newBuilder()
364
+ .setProductDetails(pd)
365
+
366
+ if (pd.productType == ProductType.SUBS) {
367
+ val subOffers = pd.subscriptionOfferDetails ?: emptyList()
368
+ val selectedOffer =
369
+ if (offerId != null) {
370
+ subOffers.firstOrNull { it.offerId == offerId }
371
+ } else {
372
+ // Pick the base plan (offerId == null)
373
+ subOffers.firstOrNull { it.offerId == null }
374
+ }
375
+ selectedOffer?.let { builder.setOfferToken(it.offerToken) }
376
+ }
377
+
378
+ return builder.build()
379
+ }
380
+
381
+ /**
382
+ * Handles purchases updated callback from [PurchasesUpdatedListener].
383
+ *
384
+ * On success, emits each purchase via [transactionListener] and resolves
385
+ * any pending continuation in [pendingPurchases].
386
+ *
387
+ * On failure, fires [errorListener] and rejects all pending continuations.
388
+ */
389
+ private fun handlePurchasesUpdated(
390
+ result: BillingResult,
391
+ purchases: List<Purchase>?,
392
+ ) {
393
+ if (result.responseCode == BillingResponseCode.OK && purchases != null) {
394
+ for (purchase in purchases) {
395
+ // Cache for later finishTransaction lookup
396
+ purchaseCache[purchase.purchaseToken] = purchase
397
+ for (productId in purchase.products) {
398
+ val dict = purchaseToDict(purchase, productId)
399
+ transactionListener?.invoke(dict)
400
+ pendingPurchases.remove(productId)?.resume(dict)
401
+ }
402
+ }
403
+ } else {
404
+ val ex = Errors.toException(result.responseCode, result.debugMessage)
405
+ val errDict =
406
+ mapOf(
407
+ "code" to ex.code,
408
+ "message" to ex.message,
409
+ "nativeCode" to ex.nativeCode,
410
+ )
411
+ errorListener?.invoke(errDict)
412
+ // Reject all pending continuations — we cannot reliably identify which SKU
413
+ val pending = pendingPurchases.values.toList()
414
+ pendingPurchases.clear()
415
+ pending.forEach { cont -> cont.resumeWithException(ex) }
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Convert a [Purchase] into a dictionary matching the `ExpoNamiIapTransaction`
421
+ * TypeScript type.
422
+ */
423
+ internal fun purchaseToDict(
424
+ purchase: Purchase,
425
+ productId: String,
426
+ ): Map<String, Any?> =
427
+ mapOf(
428
+ "skuId" to productId,
429
+ "transactionId" to purchase.orderId,
430
+ "originalTransactionId" to null,
431
+ "receipt" to purchase.originalJson,
432
+ "purchaseToken" to purchase.purchaseToken,
433
+ "purchaseTime" to purchase.purchaseTime,
434
+ "isAcknowledged" to purchase.isAcknowledged,
435
+ )
436
+
437
+ // ── Testing ────────────────────────────────────────────────────────
438
+
439
+ /**
440
+ * Inject a [PlayBillingConnection] for testing purposes.
441
+ * @suppress This method is for testing only.
442
+ */
443
+ internal fun setConnectionForTesting(conn: PlayBillingConnection) {
444
+ connection = conn
445
+ }
446
+ }