@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.
- package/README.md +121 -0
- package/android/build.gradle +46 -0
- package/android/gradle.properties +1 -0
- package/android/libs/.gitkeep +0 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackend.kt +427 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonProductMapper.kt +119 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonPurchasingListener.kt +35 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazon.kt +46 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazonReceiver.kt +48 -0
- package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingBackend.kt +446 -0
- package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingConnection.kt +104 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ml/nami/expo/iap/Errors.kt +94 -0
- package/android/src/main/java/ml/nami/expo/iap/ExpoNamiIapModule.kt +56 -0
- package/android/src/main/java/ml/nami/expo/iap/IapBackend.kt +72 -0
- package/android/src/main/java/ml/nami/expo/iap/ProductMapper.kt +191 -0
- package/android/src/test/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackendTest.kt +974 -0
- package/android/src/test/java/ml/nami/expo/iap/ErrorsTest.kt +184 -0
- package/android/src/test/java/ml/nami/expo/iap/ExpoNamiIapModuleTest.kt +86 -0
- package/android/src/test/java/ml/nami/expo/iap/PlaceholderTest.kt +8 -0
- package/android/src/test/java/ml/nami/expo/iap/ProductMapperTest.kt +335 -0
- package/android/src/test/java/ml/nami/expo/iap/google/PlayBillingBackendTest.kt +841 -0
- package/android/src/testAmazon/java/ml/nami/expo/iap/ExpoNamiIapModuleAmazonFlavorTest.kt +71 -0
- package/dist/index.cjs +56 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.mjs +45 -0
- package/expo-module.config.json +9 -0
- package/ios/Errors.swift +178 -0
- package/ios/ExpoNamiIap.podspec +14 -0
- package/ios/ExpoNamiIapModule.swift +81 -0
- package/ios/ProductMapper.swift +130 -0
- package/ios/PromotionalOfferSigner.swift +78 -0
- package/ios/StoreKit2Helper.swift +252 -0
- package/ios/Tests/ErrorsTests.swift +189 -0
- package/ios/Tests/ProductMapperTests.swift +165 -0
- package/ios/Tests/Resources/Products.storekit +210 -0
- package/ios/Tests/StoreKit2HelperTests.swift +360 -0
- package/ios/TransactionObserver.swift +54 -0
- package/package.json +64 -0
- package/src/ExpoNamiIapModule.kepler.ts +200 -0
- package/src/ExpoNamiIapModule.ts +18 -0
- package/src/index.ts +63 -0
- 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
|
+
}
|