@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
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
+ }