@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
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# @namiml/expo-nami-iap
|
|
2
|
+
|
|
3
|
+
Thin Expo native module providing cross-platform IAP primitives for the Nami SDK. It wraps platform-specific purchase APIs behind a single TypeScript interface that `@namiml/expo-sdk` consumes via its `ExpoPurchaseAdapter`.
|
|
4
|
+
|
|
5
|
+
This module is **not intended for standalone use** -- it is an internal dependency of `@namiml/expo-sdk`.
|
|
6
|
+
|
|
7
|
+
## Supported Platforms
|
|
8
|
+
|
|
9
|
+
| Platform | Backend |
|
|
10
|
+
|---|---|
|
|
11
|
+
| iOS | StoreKit 2 |
|
|
12
|
+
| Android (Google Play) | Google Play Billing Library 7 |
|
|
13
|
+
| Android (Amazon Fire OS) | Amazon IAP SDK 3.x |
|
|
14
|
+
| Amazon Vega / Kepler | `@amazon-devices/keplerscript-appstore-iap-lib` |
|
|
15
|
+
|
|
16
|
+
## Public TypeScript API
|
|
17
|
+
|
|
18
|
+
### Functions
|
|
19
|
+
|
|
20
|
+
| Function | Signature | Description |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `configure` | `(options: ConfigureOptions) => void` | Set module-level options (e.g., Amazon listener mode) |
|
|
23
|
+
| `initConnection` | `() => Promise<void>` | Initialize the native billing client |
|
|
24
|
+
| `endConnection` | `() => Promise<void>` | Tear down the native billing client |
|
|
25
|
+
| `getProducts` | `(skuIds: string[]) => Promise<ExpoNamiIapProduct[]>` | Fetch product details by SKU |
|
|
26
|
+
| `checkEligibility` | `(skuIds: string[]) => Promise<EligibilityResult[]>` | Check intro/promo offer eligibility |
|
|
27
|
+
| `purchase` | `(options: PurchaseOptions) => Promise<ExpoNamiIapTransaction>` | Initiate a purchase |
|
|
28
|
+
| `restorePurchases` | `() => Promise<ExpoNamiIapTransaction[]>` | Restore previously completed purchases |
|
|
29
|
+
| `finishTransaction` | `(transactionId: string) => Promise<void>` | Acknowledge/finish a transaction |
|
|
30
|
+
| `addTransactionListener` | `(cb) => { remove: () => void }` | Subscribe to real-time transaction updates |
|
|
31
|
+
| `addErrorListener` | `(cb) => { remove: () => void }` | Subscribe to error events |
|
|
32
|
+
|
|
33
|
+
### Types
|
|
34
|
+
|
|
35
|
+
| Type | Kind | Description |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `ExpoNamiIapProduct` | interface | Product details (extends `NamiProductDetails` from sdk-core) |
|
|
38
|
+
| `ExpoNamiIapTransaction` | interface | Transaction result with IDs, receipt, and token |
|
|
39
|
+
| `ExpoNamiIapError` | interface | Error with normalized `code` and `message` |
|
|
40
|
+
| `ExpoNamiIapErrorCode` | union | `'user_cancelled' \| 'network' \| 'item_unavailable' \| 'already_owned' \| 'not_allowed' \| 'unknown'` |
|
|
41
|
+
| `EligibilityResult` | interface | Per-SKU intro/promo eligibility flags |
|
|
42
|
+
| `ConfigureOptions` | interface | Module configuration (Amazon listener mode) |
|
|
43
|
+
| `PurchaseOptions` | interface | Purchase request parameters (SKU, offer, signature) |
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
This module is an internal dependency resolved by Expo autolinking. In `@namiml/expo-sdk`, it is declared as a dependency and automatically linked at prebuild time. No manual installation step is required for consumers of `@namiml/expo-sdk`.
|
|
48
|
+
|
|
49
|
+
## Android Gradle Flavor Selection
|
|
50
|
+
|
|
51
|
+
The Android build uses product flavors to select between Google Play and Amazon backends:
|
|
52
|
+
|
|
53
|
+
```groovy
|
|
54
|
+
flavorDimensions += "store"
|
|
55
|
+
productFlavors {
|
|
56
|
+
google { dimension "store"; isDefault true }
|
|
57
|
+
amazon { dimension "store" }
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Set the active flavor in your app's `build.gradle` or via `--variant` when running `expo prebuild` / `expo run:android`.
|
|
62
|
+
|
|
63
|
+
## Amazon Single-Listener Constraint
|
|
64
|
+
|
|
65
|
+
Amazon IAP SDK enforces a single `PurchasingListener` per process. By default, this module registers its own listener automatically. If your app needs to own the listener (e.g., you have other Amazon IAP integrations), use receiver mode:
|
|
66
|
+
|
|
67
|
+
```kotlin
|
|
68
|
+
// In your Application.onCreate() — before any Nami calls
|
|
69
|
+
ExpoNamiIapAmazon.setAutoRegisterListener(false)
|
|
70
|
+
|
|
71
|
+
// Register your own listener and forward events:
|
|
72
|
+
PurchasingService.registerListener(context, object : PurchasingListener {
|
|
73
|
+
override fun onProductDataResponse(r: ProductDataResponse) {
|
|
74
|
+
ExpoNamiIapAmazonReceiver.onProductDataResponse(r)
|
|
75
|
+
// ... your own handling
|
|
76
|
+
}
|
|
77
|
+
override fun onPurchaseResponse(r: PurchaseResponse) {
|
|
78
|
+
ExpoNamiIapAmazonReceiver.onPurchaseResponse(r)
|
|
79
|
+
}
|
|
80
|
+
override fun onPurchaseUpdatesResponse(r: PurchaseUpdatesResponse) {
|
|
81
|
+
ExpoNamiIapAmazonReceiver.onPurchaseUpdatesResponse(r)
|
|
82
|
+
}
|
|
83
|
+
override fun onUserDataResponse(r: UserDataResponse) {
|
|
84
|
+
ExpoNamiIapAmazonReceiver.onUserDataResponse(r)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Kepler / Vega Behavior
|
|
90
|
+
|
|
91
|
+
On Vega (Fire TV with KeplerScript), Metro resolves `ExpoNamiIapModule.kepler.ts` instead of the native module bridge. This implementation is entirely promise-based using `@amazon-devices/keplerscript-appstore-iap-lib`:
|
|
92
|
+
|
|
93
|
+
- `initConnection` / `endConnection` are no-ops
|
|
94
|
+
- `configure` is a no-op
|
|
95
|
+
- `checkEligibility` returns best-effort results based on existing purchase receipts
|
|
96
|
+
- Prices are returned in micro-units divided by 1,000,000
|
|
97
|
+
|
|
98
|
+
## Normalized Error Codes
|
|
99
|
+
|
|
100
|
+
| Code | Meaning |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `user_cancelled` | User dismissed the purchase dialog |
|
|
103
|
+
| `network` | Network connectivity issue |
|
|
104
|
+
| `item_unavailable` | Product not found or not available in region |
|
|
105
|
+
| `already_owned` | User already owns the item (non-consumable) |
|
|
106
|
+
| `not_allowed` | Purchases not allowed on this device/account |
|
|
107
|
+
| `unknown` | Unrecognized native error |
|
|
108
|
+
|
|
109
|
+
## Testing / Local Dev
|
|
110
|
+
|
|
111
|
+
From the monorepo root:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
make build-expo # Build the Expo SDK (includes this module)
|
|
115
|
+
make test-expo # Run Jest tests (includes module tests)
|
|
116
|
+
make lint-expo # Lint TypeScript and Kotlin
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Versioning
|
|
120
|
+
|
|
121
|
+
This module is versioned in lock-step with the monorepo `VERSION` file. See the [monorepo README](../../../../README.md) for release workflow details.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'kotlin-android'
|
|
3
|
+
apply plugin: 'expo-module-gradle-plugin'
|
|
4
|
+
|
|
5
|
+
group = 'ml.nami.expo.iap'
|
|
6
|
+
version = '3.4.0-dev.202605060442'
|
|
7
|
+
|
|
8
|
+
android {
|
|
9
|
+
namespace 'ml.nami.expo.iap'
|
|
10
|
+
compileSdk 34
|
|
11
|
+
|
|
12
|
+
defaultConfig {
|
|
13
|
+
minSdk 24
|
|
14
|
+
targetSdk 34
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
flavorDimensions += "store"
|
|
18
|
+
productFlavors {
|
|
19
|
+
google { dimension "store"; isDefault true }
|
|
20
|
+
amazon { dimension "store" }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
compileOptions {
|
|
24
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
25
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
kotlinOptions {
|
|
29
|
+
jvmTarget = '17'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
dependencies {
|
|
34
|
+
implementation project(':expo-modules-core')
|
|
35
|
+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
|
|
36
|
+
|
|
37
|
+
googleImplementation 'com.android.billingclient:billing-ktx:7.0.0'
|
|
38
|
+
// Amazon IAP SDK — the jar is not yet vendored into android/libs/.
|
|
39
|
+
// The actual SDK will be added (or switched to a Maven coordinate) in Task 18.
|
|
40
|
+
amazonCompileOnly files('libs/amazon-iap-3.0.3.jar')
|
|
41
|
+
|
|
42
|
+
testImplementation 'junit:junit:4.13.2'
|
|
43
|
+
testImplementation 'org.robolectric:robolectric:4.11.1'
|
|
44
|
+
testImplementation 'io.mockk:mockk:1.13.9'
|
|
45
|
+
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Android module properties for @namiml/expo-nami-iap
|
|
File without changes
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
package ml.nami.expo.iap.amazon
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import com.amazon.device.iap.PurchasingService
|
|
7
|
+
import com.amazon.device.iap.model.FulfillmentResult
|
|
8
|
+
import com.amazon.device.iap.model.ProductDataResponse
|
|
9
|
+
import com.amazon.device.iap.model.PurchaseResponse
|
|
10
|
+
import com.amazon.device.iap.model.PurchaseUpdatesResponse
|
|
11
|
+
import com.amazon.device.iap.model.Receipt
|
|
12
|
+
import kotlinx.coroutines.CancellableContinuation
|
|
13
|
+
import kotlinx.coroutines.CoroutineScope
|
|
14
|
+
import kotlinx.coroutines.Dispatchers
|
|
15
|
+
import kotlinx.coroutines.SupervisorJob
|
|
16
|
+
import kotlinx.coroutines.delay
|
|
17
|
+
import kotlinx.coroutines.launch
|
|
18
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
19
|
+
import ml.nami.expo.iap.ExpoNamiIapException
|
|
20
|
+
import ml.nami.expo.iap.IapBackend
|
|
21
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
22
|
+
import kotlin.coroutines.resume
|
|
23
|
+
import kotlin.coroutines.resumeWithException
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Amazon Fire OS implementation of [IapBackend].
|
|
27
|
+
*
|
|
28
|
+
* Uses Amazon's IAP SDK v3 (`PurchasingService`) for product queries,
|
|
29
|
+
* purchases, restore, eligibility checks, and transaction finishing.
|
|
30
|
+
* Responses arrive asynchronously via [AmazonPurchasingListener] and are
|
|
31
|
+
* correlated back to the originating suspend call using request-ID
|
|
32
|
+
* demultiplexing through pending-request maps.
|
|
33
|
+
*
|
|
34
|
+
* **Note:** The Amazon IAP jar (`libs/amazon-iap-3.0.3.jar`) is not yet
|
|
35
|
+
* vendored into the module. This code will not compile until the jar is
|
|
36
|
+
* added or replaced with a Maven coordinate. ktlint validation passes.
|
|
37
|
+
*/
|
|
38
|
+
class AmazonIapBackend(
|
|
39
|
+
internal val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
|
|
40
|
+
) : IapBackend {
|
|
41
|
+
private val pendingProductReqs =
|
|
42
|
+
ConcurrentHashMap<String, CancellableContinuation<List<Map<String, Any?>>>>()
|
|
43
|
+
private val pendingPurchaseReqs =
|
|
44
|
+
ConcurrentHashMap<String, CancellableContinuation<Map<String, Any?>>>()
|
|
45
|
+
private val pendingPurchaseUpdatesReqs =
|
|
46
|
+
ConcurrentHashMap<String, CancellableContinuation<List<Receipt>>>()
|
|
47
|
+
|
|
48
|
+
private var listener: AmazonPurchasingListener? = null
|
|
49
|
+
private var context: Context? = null
|
|
50
|
+
private var listenerRegistered = false
|
|
51
|
+
|
|
52
|
+
private var transactionListener: ((Map<String, Any?>) -> Unit)? = null
|
|
53
|
+
private var errorListener: ((Map<String, Any?>) -> Unit)? = null
|
|
54
|
+
|
|
55
|
+
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
override suspend fun initConnection(context: Context) {
|
|
58
|
+
this.context = context
|
|
59
|
+
ExpoNamiIapAmazon.setBackend(this)
|
|
60
|
+
ensureListenerRegistered(context)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
override fun configure(options: Map<String, Any?>) {
|
|
64
|
+
val amazon = options["amazon"] as? Map<*, *> ?: return
|
|
65
|
+
val auto = amazon["autoRegisterListener"] as? Boolean ?: true
|
|
66
|
+
ExpoNamiIapAmazon.setAutoRegisterListener(auto)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override suspend fun endConnection() {
|
|
70
|
+
pendingProductReqs.clear()
|
|
71
|
+
pendingPurchaseReqs.clear()
|
|
72
|
+
pendingPurchaseUpdatesReqs.clear()
|
|
73
|
+
listener = null
|
|
74
|
+
context = null
|
|
75
|
+
listenerRegistered = false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Products ───────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
override suspend fun getProducts(skuIds: List<String>): List<Map<String, Any?>> {
|
|
81
|
+
ensureListenerRegistered(
|
|
82
|
+
context ?: error(
|
|
83
|
+
"Context not available. Call initConnection() or setContextForTesting() first.",
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return suspendCancellableCoroutine { cont ->
|
|
88
|
+
val reqId = PurchasingService.getProductData(skuIds.toSet())
|
|
89
|
+
val key = reqId.toString()
|
|
90
|
+
pendingProductReqs[key] = cont
|
|
91
|
+
cont.invokeOnCancellation { pendingProductReqs.remove(key) }
|
|
92
|
+
|
|
93
|
+
if (!ExpoNamiIapAmazon.autoRegisterEnabled()) {
|
|
94
|
+
scope.launch {
|
|
95
|
+
delay(TIMEOUT_MS)
|
|
96
|
+
pendingProductReqs.remove(key)?.resumeWithException(
|
|
97
|
+
ExpoNamiIapException.Unknown(
|
|
98
|
+
"Amazon request timed out (receiver mode" +
|
|
99
|
+
" — ensure host app calls" +
|
|
100
|
+
" ExpoNamiIapAmazonReceiver.onProductDataResponse)",
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Eligibility ────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check eligibility for each SKU by querying current purchase receipts.
|
|
112
|
+
*
|
|
113
|
+
* For each [skuIds] entry, if an active (non-cancelled) receipt exists,
|
|
114
|
+
* both `intro_eligible` and `promo_eligible` are false; otherwise both
|
|
115
|
+
* are true. This is a best-effort approximation — Amazon does not
|
|
116
|
+
* expose intro/promo offer metadata.
|
|
117
|
+
*/
|
|
118
|
+
override suspend fun checkEligibility(skuIds: List<String>): List<Map<String, Any?>> {
|
|
119
|
+
val receipts = fetchPurchaseUpdates()
|
|
120
|
+
|
|
121
|
+
// Build a set of active (non-cancelled) SKU IDs
|
|
122
|
+
val activeSkus =
|
|
123
|
+
receipts
|
|
124
|
+
.filter { it.cancelDate == null }
|
|
125
|
+
.map { it.sku }
|
|
126
|
+
.toSet()
|
|
127
|
+
|
|
128
|
+
return skuIds.map { skuId ->
|
|
129
|
+
val hasActive = skuId in activeSkus
|
|
130
|
+
mapOf(
|
|
131
|
+
"product_ref_id" to skuId,
|
|
132
|
+
"intro_eligible" to !hasActive,
|
|
133
|
+
"promo_eligible" to !hasActive,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Purchase ───────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Initiate a purchase for the given [skuId].
|
|
142
|
+
*
|
|
143
|
+
* Amazon IAP does not support offer selection, offer signatures, or
|
|
144
|
+
* app account tokens — those parameters are ignored with a debug log.
|
|
145
|
+
*/
|
|
146
|
+
override suspend fun purchase(
|
|
147
|
+
activity: Activity,
|
|
148
|
+
skuId: String,
|
|
149
|
+
offerId: String?,
|
|
150
|
+
offerSignature: String?,
|
|
151
|
+
appAccountToken: String?,
|
|
152
|
+
): Map<String, Any?> {
|
|
153
|
+
ensureListenerRegistered(
|
|
154
|
+
context ?: error(
|
|
155
|
+
"Context not available. Call initConnection() or setContextForTesting() first.",
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if (offerId != null || offerSignature != null || appAccountToken != null) {
|
|
160
|
+
Log.d(
|
|
161
|
+
TAG,
|
|
162
|
+
"Amazon IAP ignoring unsupported options: " +
|
|
163
|
+
"offerId=$offerId, offerSignature=$offerSignature, " +
|
|
164
|
+
"appAccountToken=$appAccountToken",
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return suspendCancellableCoroutine { cont ->
|
|
169
|
+
val reqId = PurchasingService.purchase(skuId)
|
|
170
|
+
val key = reqId.toString()
|
|
171
|
+
pendingPurchaseReqs[key] = cont
|
|
172
|
+
cont.invokeOnCancellation { pendingPurchaseReqs.remove(key) }
|
|
173
|
+
|
|
174
|
+
if (!ExpoNamiIapAmazon.autoRegisterEnabled()) {
|
|
175
|
+
scope.launch {
|
|
176
|
+
delay(TIMEOUT_MS)
|
|
177
|
+
pendingPurchaseReqs.remove(key)?.resumeWithException(
|
|
178
|
+
ExpoNamiIapException.Unknown(
|
|
179
|
+
"Amazon request timed out (receiver mode" +
|
|
180
|
+
" — ensure host app calls" +
|
|
181
|
+
" ExpoNamiIapAmazonReceiver.onPurchaseResponse)",
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Restore ────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Restore purchases by fetching all purchase update receipts.
|
|
193
|
+
*
|
|
194
|
+
* Each receipt is mapped to a transaction dictionary and emitted
|
|
195
|
+
* via [transactionListener].
|
|
196
|
+
*/
|
|
197
|
+
override suspend fun restorePurchases(): List<Map<String, Any?>> {
|
|
198
|
+
val receipts = fetchPurchaseUpdates()
|
|
199
|
+
return receipts.map { receipt ->
|
|
200
|
+
val dict = receiptToDict(receipt)
|
|
201
|
+
transactionListener?.invoke(dict)
|
|
202
|
+
dict
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Finish ─────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Notify Amazon that a transaction has been fulfilled.
|
|
210
|
+
*
|
|
211
|
+
* This is fire-and-forget — Amazon IAP does not provide a callback
|
|
212
|
+
* for `notifyFulfillment`.
|
|
213
|
+
*/
|
|
214
|
+
override suspend fun finishTransaction(transactionId: String) {
|
|
215
|
+
PurchasingService.notifyFulfillment(transactionId, FulfillmentResult.FULFILLED)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Listeners ──────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
override fun onTransactionUpdate(callback: (Map<String, Any?>) -> Unit) {
|
|
221
|
+
transactionListener = callback
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
override fun onError(callback: (Map<String, Any?>) -> Unit) {
|
|
225
|
+
errorListener = callback
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Response handling (called by AmazonPurchasingListener) ─────────
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Handle a product data response from Amazon IAP.
|
|
232
|
+
*
|
|
233
|
+
* Looks up the pending continuation by request ID and resolves it
|
|
234
|
+
* with the mapped product list, or rejects on failure/not-supported.
|
|
235
|
+
*/
|
|
236
|
+
internal fun handleProductDataResponse(response: ProductDataResponse) {
|
|
237
|
+
val cont = pendingProductReqs.remove(response.requestId.toString()) ?: return
|
|
238
|
+
when (response.requestStatus) {
|
|
239
|
+
ProductDataResponse.RequestStatus.SUCCESSFUL -> {
|
|
240
|
+
val products =
|
|
241
|
+
response.productData.values.map { AmazonProductMapper.toMap(it) }
|
|
242
|
+
cont.resume(products)
|
|
243
|
+
}
|
|
244
|
+
ProductDataResponse.RequestStatus.FAILED,
|
|
245
|
+
ProductDataResponse.RequestStatus.NOT_SUPPORTED,
|
|
246
|
+
-> {
|
|
247
|
+
cont.resumeWithException(
|
|
248
|
+
ExpoNamiIapException.Unknown(
|
|
249
|
+
"Product data failed: ${response.requestStatus}",
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Handle a purchase response from Amazon IAP.
|
|
258
|
+
*
|
|
259
|
+
* Resolves or rejects the pending continuation stored in [pendingPurchaseReqs]
|
|
260
|
+
* based on [PurchaseResponse.RequestStatus].
|
|
261
|
+
*/
|
|
262
|
+
internal fun handlePurchaseResponse(response: PurchaseResponse) {
|
|
263
|
+
val cont = pendingPurchaseReqs.remove(response.requestId.toString()) ?: return
|
|
264
|
+
when (response.requestStatus) {
|
|
265
|
+
PurchaseResponse.RequestStatus.SUCCESSFUL -> {
|
|
266
|
+
val receipt = response.receipt
|
|
267
|
+
val dict = receiptToDict(receipt)
|
|
268
|
+
transactionListener?.invoke(dict)
|
|
269
|
+
cont.resume(dict)
|
|
270
|
+
}
|
|
271
|
+
PurchaseResponse.RequestStatus.FAILED -> {
|
|
272
|
+
val ex = ExpoNamiIapException.Unknown("Purchase failed", "FAILED")
|
|
273
|
+
errorListener?.invoke(exceptionToDict(ex))
|
|
274
|
+
cont.resumeWithException(ex)
|
|
275
|
+
}
|
|
276
|
+
PurchaseResponse.RequestStatus.INVALID_SKU -> {
|
|
277
|
+
val ex =
|
|
278
|
+
ExpoNamiIapException.ItemUnavailable(
|
|
279
|
+
"Invalid SKU",
|
|
280
|
+
"INVALID_SKU",
|
|
281
|
+
)
|
|
282
|
+
errorListener?.invoke(exceptionToDict(ex))
|
|
283
|
+
cont.resumeWithException(ex)
|
|
284
|
+
}
|
|
285
|
+
PurchaseResponse.RequestStatus.ALREADY_PURCHASED -> {
|
|
286
|
+
val ex = ExpoNamiIapException.AlreadyOwned("ALREADY_PURCHASED")
|
|
287
|
+
errorListener?.invoke(exceptionToDict(ex))
|
|
288
|
+
cont.resumeWithException(ex)
|
|
289
|
+
}
|
|
290
|
+
PurchaseResponse.RequestStatus.NOT_SUPPORTED -> {
|
|
291
|
+
val ex =
|
|
292
|
+
ExpoNamiIapException.NotAllowed(
|
|
293
|
+
"Purchase not supported",
|
|
294
|
+
"NOT_SUPPORTED",
|
|
295
|
+
)
|
|
296
|
+
errorListener?.invoke(exceptionToDict(ex))
|
|
297
|
+
cont.resumeWithException(ex)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Handle a purchase updates response from Amazon IAP.
|
|
304
|
+
*
|
|
305
|
+
* Resolves the pending continuation in [pendingPurchaseUpdatesReqs]
|
|
306
|
+
* with the list of [Receipt]s, or rejects on failure.
|
|
307
|
+
*/
|
|
308
|
+
internal fun handlePurchaseUpdatesResponse(response: PurchaseUpdatesResponse) {
|
|
309
|
+
val cont = pendingPurchaseUpdatesReqs.remove(response.requestId.toString()) ?: return
|
|
310
|
+
when (response.requestStatus) {
|
|
311
|
+
PurchaseUpdatesResponse.RequestStatus.SUCCESSFUL -> {
|
|
312
|
+
cont.resume(response.receipts ?: emptyList())
|
|
313
|
+
}
|
|
314
|
+
PurchaseUpdatesResponse.RequestStatus.FAILED,
|
|
315
|
+
PurchaseUpdatesResponse.RequestStatus.NOT_SUPPORTED,
|
|
316
|
+
-> {
|
|
317
|
+
cont.resumeWithException(
|
|
318
|
+
ExpoNamiIapException.Unknown(
|
|
319
|
+
"Purchase updates failed: ${response.requestStatus}",
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Internals ──────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Ensure the [AmazonPurchasingListener] is registered with
|
|
330
|
+
* `PurchasingService`. This is idempotent — Amazon's SDK uses a
|
|
331
|
+
* last-writer-wins model, so repeated calls are safe.
|
|
332
|
+
*
|
|
333
|
+
* In receiver mode ([ExpoNamiIapAmazon.autoRegisterEnabled] == false),
|
|
334
|
+
* listener registration is skipped. The host app is responsible for
|
|
335
|
+
* calling `PurchasingService.registerListener` and forwarding events
|
|
336
|
+
* via [ExpoNamiIapAmazonReceiver].
|
|
337
|
+
*/
|
|
338
|
+
private fun ensureListenerRegistered(ctx: Context) {
|
|
339
|
+
if (listenerRegistered) return
|
|
340
|
+
if (!ExpoNamiIapAmazon.autoRegisterEnabled()) {
|
|
341
|
+
listenerRegistered = true
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
val newListener = AmazonPurchasingListener(this)
|
|
345
|
+
PurchasingService.registerListener(ctx, newListener)
|
|
346
|
+
listener = newListener
|
|
347
|
+
listenerRegistered = true
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Fetch purchase update receipts via `PurchasingService.getPurchaseUpdates(false)`.
|
|
352
|
+
*
|
|
353
|
+
* Used by both [restorePurchases] and [checkEligibility].
|
|
354
|
+
*/
|
|
355
|
+
private suspend fun fetchPurchaseUpdates(): List<Receipt> {
|
|
356
|
+
ensureListenerRegistered(
|
|
357
|
+
context ?: error(
|
|
358
|
+
"Context not available. Call initConnection() or setContextForTesting() first.",
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
return suspendCancellableCoroutine { cont ->
|
|
363
|
+
val reqId = PurchasingService.getPurchaseUpdates(false)
|
|
364
|
+
val key = reqId.toString()
|
|
365
|
+
pendingPurchaseUpdatesReqs[key] = cont
|
|
366
|
+
cont.invokeOnCancellation { pendingPurchaseUpdatesReqs.remove(key) }
|
|
367
|
+
|
|
368
|
+
if (!ExpoNamiIapAmazon.autoRegisterEnabled()) {
|
|
369
|
+
scope.launch {
|
|
370
|
+
delay(TIMEOUT_MS)
|
|
371
|
+
pendingPurchaseUpdatesReqs.remove(key)?.resumeWithException(
|
|
372
|
+
ExpoNamiIapException.Unknown(
|
|
373
|
+
"Amazon request timed out (receiver mode" +
|
|
374
|
+
" — ensure host app calls" +
|
|
375
|
+
" ExpoNamiIapAmazonReceiver" +
|
|
376
|
+
".onPurchaseUpdatesResponse)",
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Convert an Amazon IAP [Receipt] into a dictionary matching the
|
|
386
|
+
* `ExpoNamiIapTransaction` TypeScript type.
|
|
387
|
+
*
|
|
388
|
+
* Amazon does not distinguish `transactionId` from `originalTransactionId`,
|
|
389
|
+
* so both are set to [Receipt.getReceiptId].
|
|
390
|
+
*/
|
|
391
|
+
internal fun receiptToDict(receipt: Receipt): Map<String, Any?> =
|
|
392
|
+
mapOf(
|
|
393
|
+
"skuId" to receipt.sku,
|
|
394
|
+
"transactionId" to receipt.receiptId,
|
|
395
|
+
"originalTransactionId" to receipt.receiptId,
|
|
396
|
+
"receipt" to receipt.receiptId,
|
|
397
|
+
"purchaseToken" to receipt.receiptId,
|
|
398
|
+
"purchaseTime" to receipt.purchaseDate?.time,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Convert an [ExpoNamiIapException] to an error dictionary for listener dispatch.
|
|
403
|
+
*/
|
|
404
|
+
private fun exceptionToDict(ex: ExpoNamiIapException): Map<String, Any?> =
|
|
405
|
+
mapOf(
|
|
406
|
+
"code" to ex.code,
|
|
407
|
+
"message" to ex.message,
|
|
408
|
+
"nativeCode" to ex.nativeCode,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
// ── Testing ────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Set a [Context] for testing purposes without calling [initConnection].
|
|
415
|
+
* This allows testing auto-registration in [getProducts] without a
|
|
416
|
+
* prior [initConnection] call.
|
|
417
|
+
* @suppress This method is for testing only.
|
|
418
|
+
*/
|
|
419
|
+
internal fun setContextForTesting(ctx: Context) {
|
|
420
|
+
this.context = ctx
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
companion object {
|
|
424
|
+
private const val TAG = "AmazonIapBackend"
|
|
425
|
+
internal const val TIMEOUT_MS = 30_000L
|
|
426
|
+
}
|
|
427
|
+
}
|