expo-iap 3.0.8 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CLAUDE.md +2 -2
  2. package/CONTRIBUTING.md +19 -0
  3. package/README.md +18 -6
  4. package/android/build.gradle +24 -1
  5. package/android/src/main/java/expo/modules/iap/ExpoIapLog.kt +69 -0
  6. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +190 -59
  7. package/build/index.d.ts +20 -47
  8. package/build/index.d.ts.map +1 -1
  9. package/build/index.js +94 -137
  10. package/build/index.js.map +1 -1
  11. package/build/modules/android.d.ts.map +1 -1
  12. package/build/modules/android.js +2 -1
  13. package/build/modules/android.js.map +1 -1
  14. package/build/modules/ios.d.ts +16 -1
  15. package/build/modules/ios.d.ts.map +1 -1
  16. package/build/modules/ios.js +29 -16
  17. package/build/modules/ios.js.map +1 -1
  18. package/build/types.d.ts +8 -6
  19. package/build/types.d.ts.map +1 -1
  20. package/build/types.js.map +1 -1
  21. package/build/useIAP.d.ts +1 -1
  22. package/build/useIAP.d.ts.map +1 -1
  23. package/build/useIAP.js +12 -15
  24. package/build/useIAP.js.map +1 -1
  25. package/build/utils/errorMapping.d.ts +32 -23
  26. package/build/utils/errorMapping.d.ts.map +1 -1
  27. package/build/utils/errorMapping.js +117 -22
  28. package/build/utils/errorMapping.js.map +1 -1
  29. package/ios/ExpoIap.podspec +3 -2
  30. package/ios/ExpoIapHelper.swift +96 -0
  31. package/ios/ExpoIapLog.swift +127 -0
  32. package/ios/ExpoIapModule.swift +218 -340
  33. package/openiap-versions.json +5 -0
  34. package/package.json +2 -2
  35. package/plugin/build/withIAP.js +6 -4
  36. package/plugin/src/withIAP.ts +14 -4
  37. package/scripts/update-types.mjs +20 -1
  38. package/src/index.ts +122 -165
  39. package/src/modules/android.ts +2 -1
  40. package/src/modules/ios.ts +31 -19
  41. package/src/types.ts +8 -6
  42. package/src/useIAP.ts +17 -25
  43. package/src/utils/errorMapping.ts +203 -23
  44. package/build/purchase-error.d.ts +0 -67
  45. package/build/purchase-error.d.ts.map +0 -1
  46. package/build/purchase-error.js +0 -166
  47. package/build/purchase-error.js.map +0 -1
  48. package/build/utils/purchase.d.ts +0 -9
  49. package/build/utils/purchase.d.ts.map +0 -1
  50. package/build/utils/purchase.js +0 -34
  51. package/build/utils/purchase.js.map +0 -1
  52. package/src/purchase-error.ts +0 -265
  53. package/src/utils/purchase.ts +0 -52
package/CLAUDE.md CHANGED
@@ -93,7 +93,7 @@ The library follows the OpenIAP type specifications with platform-specific exten
93
93
  ### Hook API Semantics (useIAP)
94
94
 
95
95
  - Inside the `useIAP` hook, most methods return `Promise<void>` and update internal state. Do not design examples or implementations that expect data from these methods.
96
- - Examples: `fetchProducts`, `requestProducts`, `getProducts`/`getSubscriptions` (deprecated helpers), `requestPurchase`, `getAvailablePurchases`.
96
+ - Examples: `fetchProducts`, `requestPurchase`, `getAvailablePurchases`.
97
97
  - After calling, consume state from the hook: `products`, `subscriptions`, `availablePurchases`, etc.
98
98
  - Defined exceptions that DO return values in the hook:
99
99
  - `getActiveSubscriptions(subscriptionIds?) => Promise<ActiveSubscription[]>` (also updates `activeSubscriptions` state)
@@ -102,7 +102,7 @@ The library follows the OpenIAP type specifications with platform-specific exten
102
102
 
103
103
  ### API Method Naming
104
104
 
105
- - Functions that depend on event results should use `request` prefix (e.g., `requestPurchase`, `requestSubscription`)
105
+ - Functions that depend on event results should use `request` prefix (e.g., `requestPurchase`)
106
106
  - Follow OpenIAP terminology: <https://www.openiap.dev/docs/apis#terminology>
107
107
  - Do not use generic prefixes like `get`, `find` - refer to the official terminology
108
108
 
package/CONTRIBUTING.md CHANGED
@@ -8,6 +8,7 @@ Thank you for your interest in contributing to expo-iap! This guide will help yo
8
8
  - [Package Manager](#-package-manager)
9
9
  - [Running the Example App](#-running-the-example-app)
10
10
  - [Development Guidelines](#-development-guidelines)
11
+ - [OpenIAP Version Management](#-openiap-version-management)
11
12
  - [Testing](#-testing)
12
13
  - [Code Style](#-code-style)
13
14
  - [Submitting Changes](#-submitting-changes)
@@ -223,6 +224,24 @@ The generated TypeScript definitions in `src/types.ts` come from the [`openiap-g
223
224
 
224
225
  Always ensure the repository builds and tests succeed after regenerating the types.
225
226
 
227
+ ## 🔢 OpenIAP Version Management
228
+
229
+ All native and type-generation version numbers are sourced from `openiap-versions.json` at the repository root:
230
+
231
+ - `apple` → iOS Pod dependency (`ios/ExpoIap.podspec`).
232
+ - `google` → Android artifact (`android/build.gradle`, Expo config plugin).
233
+ - `gql` → GraphQL type generator (`scripts/update-types.mjs`).
234
+
235
+ When bumping dependencies:
236
+
237
+ 1. Update the relevant fields in `openiap-versions.json`.
238
+ 2. For iOS changes, run `cd ios && pod install` (and commit the Pod.lock if required by the workflow).
239
+ 3. For Android, re-run Gradle (`bun run android`) so the new artifact is pulled down.
240
+ 4. When the `gql` value changes, run `bun run generate:types` to refresh `src/types.ts`.
241
+ 5. Commit the updated JSON, regenerated files, and any resulting lockfile changes together.
242
+
243
+ If the JSON file is missing or malformed, build scripts (Gradle, Podspec, the type generator) will fail fast — fix the JSON rather than hard-coding version strings in multiple locations.
244
+
226
245
  ### Development Workflow
227
246
 
228
247
  1. **Before starting work**:
package/README.md CHANGED
@@ -5,9 +5,12 @@
5
5
 
6
6
  [![Version](http://img.shields.io/npm/v/expo-iap.svg?style=flat-square)](https://npmjs.org/package/expo-iap) [![Download](http://img.shields.io/npm/dm/expo-iap.svg?style=flat-square)](https://npmjs.org/package/expo-iap) [![CI](https://github.com/hyochan/expo-iap/actions/workflows/ci.yml/badge.svg)](https://github.com/hyochan/expo-iap/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/hyochan/expo-iap/graph/badge.svg?token=47VMTY5NyM)](https://codecov.io/gh/hyochan/expo-iap) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fhyochan%2Fexpo-iap.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fhyochan%2Fexpo-iap?ref=badge_shield&issueType=license)
7
7
 
8
- In app purchase module in [Expo](https://docs.expo.dev/guides/in-app-purchases) that conforms to the [Open IAP specification](https://openiap.dev)
9
-
10
- <a href="https://openiap.dev"><img src="https://openiap.dev/logo.png" alt="Open IAP" height="40" /></a>
8
+ Expo IAP is a powerful in-app purchase solution for Expo and React Native applications that conforms to the Open IAP specification. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support.
9
+
10
+ If you're shipping an app with expo-iap, we’d love to hear about it—please share your product and feedback in [Who's using Expo IAP?](https://github.com/hyochan/expo-iap/discussions/143). Community stories help us keep improving the ecosystem.
11
+
12
+ <a href="https://openiap.dev"><img src="https://openiap.dev/logo.png" alt="Open IAP" height="40" /></a>
13
+
11
14
  </div>
12
15
 
13
16
  ## 📚 Documentation
@@ -44,7 +47,7 @@ npx expo install expo-iap
44
47
  "expo-build-properties",
45
48
  {
46
49
  "android": {
47
- "kotlinVersion": "2.0.21"
50
+ "kotlinVersion": "2.1.20"
48
51
  }
49
52
  }
50
53
  ]
@@ -53,6 +56,8 @@ npx expo install expo-iap
53
56
  }
54
57
  ```
55
58
 
59
+ If you're targeting Expo SDK 54 or newer, please confirm whether this manual override is still required and share findings with the community at [github.com/hyochan/expo-iap/discussions](https://github.com/hyochan/expo-iap/discussions).
60
+
56
61
  ## Contributing
57
62
 
58
63
  We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on:
@@ -67,14 +72,21 @@ For detailed usage examples and error handling, see the [documentation](https://
67
72
 
68
73
  > Sharing your thoughts—any feedback would be greatly appreciated!
69
74
 
70
- ## Sponsors
75
+ ## Our Sponsors
71
76
 
72
77
  💼 **[View Our Sponsors](https://openiap.dev/sponsors)**
73
78
 
79
+ We're building the OpenIAP ecosystem—defining the spec at [openiap.dev](https://www.openiap.dev), maintaining [openiap-gql](https://github.com/hyodotdev/openiap-gql) for the shared type system, and shipping platform SDKs like [openiap-apple](https://github.com/hyodotdev/openiap-apple) and [openiap-google](https://github.com/hyodotdev/openiap-google) that power [expo-iap](https://github.com/hyochan/expo-iap), [flutter_inapp_purchase](https://github.com/hyochan/flutter_inapp_purchase), React Native, and [kmp-iap](https://github.com/hyochan/kmp-iap). The work so far has focused on untangling fragmented APIs; the next milestone is a streamlined purchase flow: `initConnection → fetchProducts → requestPurchase → (server receipt validation) → finishTransaction`.
80
+
81
+ Your sponsorship helps ensure developers across platforms, OS, and frameworks can implement in-app purchases without headaches. It also fuels new plugins, payment systems, and partner integrations already being explored in the OpenIAP community. Sponsors receive shout-outs in every release and can request tailored support depending on tier. If you’re interested—or have rollout feedback to share—you can view sponsorship options at [openiap.dev/sponsors](https://openiap.dev/sponsors).
82
+
74
83
  ### <p style="color: rgb(255, 182, 193);">Angel</p>
75
84
 
76
85
  <a href="https://meta.com">
77
- <img width="600" alt="courier_dot_com" src="https://static.xx.fbcdn.net/rsrc.php/y3/r/y6QsbGgc866.svg" />
86
+ <div style="display: inline-flex; flex-direction: column; align-items: center; gap: 0.25rem; padding: 0.75rem 1rem; border-radius: 12px; background: rgba(212, 165, 116, 0.12);">
87
+ <img alt="Meta" src="https://www.openiap.dev/meta.svg" style="width: 120px;" />
88
+ <span style="font-size: 0.85rem; font-weight: 600; color: rgb(107, 78, 61); text-align: center; width: 100%;">Meta</span>
89
+ </div>
78
90
  </a>
79
91
 
80
92
  ## Past Supporters
@@ -1,3 +1,5 @@
1
+ import groovy.json.JsonSlurper
2
+
1
3
  apply plugin: 'com.android.library'
2
4
  apply plugin: 'kotlin-android'
3
5
 
@@ -10,6 +12,27 @@ applyKotlinExpoModulesCorePlugin()
10
12
  useCoreDependencies()
11
13
  useExpoPublishing()
12
14
 
15
+ def resolveOpenIapVersionsFile() {
16
+ def candidates = [
17
+ new File(projectDir.parentFile, 'openiap-versions.json'),
18
+ new File(rootDir.parentFile ?: rootDir, 'openiap-versions.json'),
19
+ new File(rootProject.projectDir.parentFile ?: rootProject.projectDir, 'openiap-versions.json')
20
+ ]
21
+ return candidates.find { it.exists() }
22
+ }
23
+
24
+ def openiapVersionsFile = resolveOpenIapVersionsFile()
25
+ if (openiapVersionsFile == null) {
26
+ throw new GradleException("expo-iap: Unable to locate openiap-versions.json")
27
+ }
28
+
29
+ def openiapVersions = new JsonSlurper().parse(openiapVersionsFile)
30
+ def googleVersion = (openiapVersions instanceof Map) ? openiapVersions.google : null
31
+ if (!(googleVersion instanceof String) || !googleVersion.trim()) {
32
+ throw new GradleException("expo-iap: 'google' version missing or invalid in openiap-versions.json")
33
+ }
34
+ def googleVersionString = googleVersion.trim()
35
+
13
36
  // If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
14
37
  // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
15
38
  // Most of the time, you may like to manage the Android SDK versions yourself.
@@ -58,6 +81,6 @@ dependencies {
58
81
  implementation project(":openiap-google")
59
82
  } else {
60
83
  // Fallback to published artifact when local project isn't linked
61
- implementation "io.github.hyochan.openiap:openiap-google:1.1.12"
84
+ implementation "io.github.hyochan.openiap:openiap-google:${googleVersionString}"
62
85
  }
63
86
  }
@@ -0,0 +1,69 @@
1
+ package expo.modules.iap
2
+
3
+ import android.util.Log
4
+ import org.json.JSONArray
5
+ import org.json.JSONObject
6
+
7
+ internal object ExpoIapLog {
8
+ private const val TAG = "ExpoIap"
9
+
10
+ fun payload(
11
+ name: String,
12
+ payload: Any?,
13
+ ) {
14
+ debug("$name payload: ${stringify(payload)}")
15
+ }
16
+
17
+ fun result(
18
+ name: String,
19
+ value: Any?,
20
+ ) {
21
+ debug("$name result: ${stringify(value)}")
22
+ }
23
+
24
+ fun failure(
25
+ name: String,
26
+ error: Throwable,
27
+ ) {
28
+ Log.e(TAG, "$name failed: ${error.localizedMessage}", error)
29
+ }
30
+
31
+ fun debug(message: String) {
32
+ Log.d(TAG, message)
33
+ }
34
+
35
+ private fun stringify(value: Any?): String {
36
+ val sanitized = sanitize(value) ?: return "null"
37
+ return when (sanitized) {
38
+ is String -> sanitized
39
+ is Number, is Boolean -> sanitized.toString()
40
+ is Map<*, *> -> JSONObject(sanitized).toString()
41
+ is List<*> -> JSONArray(sanitized).toString()
42
+ else -> sanitized.toString()
43
+ }
44
+ }
45
+
46
+ private fun sanitize(value: Any?): Any? {
47
+ if (value == null) return null
48
+
49
+ return when (value) {
50
+ is Map<*, *> -> sanitizeMap(value)
51
+ is List<*> -> value.mapNotNull { sanitize(it) }
52
+ is Array<*> -> value.mapNotNull { sanitize(it) }
53
+ else -> value
54
+ }
55
+ }
56
+
57
+ private fun sanitizeMap(source: Map<*, *>): Map<String, Any?> {
58
+ val sanitized = linkedMapOf<String, Any?>()
59
+ for ((rawKey, rawValue) in source) {
60
+ val key = rawKey as? String ?: continue
61
+ if (key.lowercase().contains("token")) {
62
+ sanitized[key] = "hidden"
63
+ continue
64
+ }
65
+ sanitized[key] = sanitize(rawValue)
66
+ }
67
+ return sanitized
68
+ }
69
+ }
@@ -2,12 +2,22 @@ package expo.modules.iap
2
2
 
3
3
  import android.content.Context
4
4
  import android.util.Log
5
+ import dev.hyo.openiap.AndroidSubscriptionOfferInput
6
+ import dev.hyo.openiap.DeepLinkOptions
7
+ import dev.hyo.openiap.FetchProductsResultProducts
8
+ import dev.hyo.openiap.FetchProductsResultSubscriptions
5
9
  import dev.hyo.openiap.OpenIapError
6
10
  import dev.hyo.openiap.OpenIapModule
7
- import dev.hyo.openiap.models.DeepLinkOptions
8
- import dev.hyo.openiap.models.ProductRequest
9
- import dev.hyo.openiap.models.RequestPurchaseParams
10
- import dev.hyo.openiap.models.RequestSubscriptionAndroidProps
11
+ import dev.hyo.openiap.ProductQueryType
12
+ import dev.hyo.openiap.ProductRequest
13
+ import dev.hyo.openiap.Purchase
14
+ import dev.hyo.openiap.RequestPurchaseAndroidProps
15
+ import dev.hyo.openiap.RequestPurchaseProps
16
+ import dev.hyo.openiap.RequestPurchasePropsByPlatforms
17
+ import dev.hyo.openiap.RequestPurchaseResultPurchase
18
+ import dev.hyo.openiap.RequestPurchaseResultPurchases
19
+ import dev.hyo.openiap.RequestSubscriptionAndroidProps
20
+ import dev.hyo.openiap.RequestSubscriptionPropsByPlatforms
11
21
  import expo.modules.kotlin.Promise
12
22
  import expo.modules.kotlin.exception.Exceptions
13
23
  import expo.modules.kotlin.modules.Module
@@ -18,6 +28,7 @@ import kotlinx.coroutines.Job
18
28
  import kotlinx.coroutines.launch
19
29
  import kotlinx.coroutines.sync.Mutex
20
30
  import kotlinx.coroutines.sync.withLock
31
+ import java.util.Locale
21
32
  import java.util.concurrent.ConcurrentLinkedQueue
22
33
  import java.util.concurrent.atomic.AtomicBoolean
23
34
 
@@ -59,7 +70,20 @@ class ExpoIapModule : Module() {
59
70
  pendingEvents.add(name to payload)
60
71
  }
61
72
 
62
- // Mapping helpers now provided by openiap-google (toJSON helpers)
73
+ private fun parseProductQueryType(rawType: String?): ProductQueryType {
74
+ val normalized =
75
+ rawType
76
+ ?.trim()
77
+ ?.lowercase(Locale.US)
78
+ ?.replace("-", "")
79
+ ?.replace("_", "")
80
+
81
+ return when (normalized) {
82
+ "subs" -> ProductQueryType.Subs
83
+ "all" -> ProductQueryType.All
84
+ else -> ProductQueryType.InApp
85
+ }
86
+ }
63
87
 
64
88
  override fun definition() =
65
89
  ModuleDefinition {
@@ -72,6 +96,7 @@ class ExpoIapModule : Module() {
72
96
  Events(EVENT_PURCHASE_UPDATED, EVENT_PURCHASE_ERROR)
73
97
 
74
98
  AsyncFunction("initConnection") { promise: Promise ->
99
+ ExpoIapLog.payload("initConnection", null)
75
100
  scope.launch {
76
101
  connectionMutex.withLock {
77
102
  try {
@@ -82,6 +107,7 @@ class ExpoIapModule : Module() {
82
107
 
83
108
  // If already connected, short-circuit
84
109
  if (connectionReady.get()) {
110
+ ExpoIapLog.result("initConnection", true)
85
111
  promise.resolve(true)
86
112
  return@withLock
87
113
  }
@@ -90,8 +116,9 @@ class ExpoIapModule : Module() {
90
116
  if (!listenersAttached) {
91
117
  listenersAttached = true
92
118
  openIap.addPurchaseUpdateListener { p ->
93
- runCatching { emitOrQueue(EVENT_PURCHASE_UPDATED, p.toJSON()) }
94
- .onFailure { Log.e(TAG, "Failed to buffer/send PURCHASE_UPDATED", it) }
119
+ runCatching {
120
+ emitOrQueue(EVENT_PURCHASE_UPDATED, p.toJson())
121
+ }.onFailure { Log.e(TAG, "Failed to buffer/send PURCHASE_UPDATED", it) }
95
122
  }
96
123
  openIap.addPurchaseErrorListener { e ->
97
124
  runCatching { emitOrQueue(EVENT_PURCHASE_ERROR, e.toJSON()) }
@@ -104,6 +131,10 @@ class ExpoIapModule : Module() {
104
131
  if (!ok) {
105
132
  // Clear any buffered events from a failed init
106
133
  pendingEvents.clear()
134
+ ExpoIapLog.failure(
135
+ "initConnection",
136
+ IllegalStateException("Failed to initialize connection"),
137
+ )
107
138
  promise.reject(OpenIapError.InitConnection.CODE, "Failed to initialize connection", null)
108
139
  return@withLock
109
140
  }
@@ -117,8 +148,10 @@ class ExpoIapModule : Module() {
117
148
  .onFailure { Log.e(TAG, "Failed to flush buffered event: ${ev.first}", it) }
118
149
  }
119
150
 
151
+ ExpoIapLog.result("initConnection", true)
120
152
  promise.resolve(true)
121
153
  } catch (e: Exception) {
154
+ ExpoIapLog.failure("initConnection", e)
122
155
  promise.reject(OpenIapError.InitConnection.CODE, e.message, e)
123
156
  }
124
157
  }
@@ -126,35 +159,54 @@ class ExpoIapModule : Module() {
126
159
  }
127
160
 
128
161
  AsyncFunction("endConnection") { promise: Promise ->
162
+ ExpoIapLog.payload("endConnection", null)
129
163
  scope.launch {
130
164
  connectionMutex.withLock {
131
165
  runCatching { openIap.endConnection() }
132
166
  // Reset connection state and clear any buffered events
133
167
  connectionReady.set(false)
134
168
  pendingEvents.clear()
169
+ ExpoIapLog.result("endConnection", true)
135
170
  promise.resolve(true)
136
171
  }
137
172
  }
138
173
  }
139
174
 
140
175
  AsyncFunction("fetchProducts") { type: String, skuArr: Array<String>, promise: Promise ->
176
+ ExpoIapLog.payload(
177
+ "fetchProductsAndroid",
178
+ mapOf("type" to type, "skus" to skuArr.toList()),
179
+ )
141
180
  scope.launch {
142
181
  try {
143
- val reqType = ProductRequest.ProductRequestType.fromString(type)
144
- val products = openIap.fetchProducts(ProductRequest(skuArr.toList(), reqType))
145
- promise.resolve(products.map { it.toJSON() })
182
+ val queryType = parseProductQueryType(type)
183
+ val request = ProductRequest(skuArr.toList(), queryType)
184
+ val result = openIap.fetchProducts(request)
185
+ val payload =
186
+ when (result) {
187
+ is FetchProductsResultProducts -> result.value.orEmpty().map { it.toJson() }
188
+ is FetchProductsResultSubscriptions -> result.value.orEmpty().map { it.toJson() }
189
+ else -> emptyList<Map<String, Any?>>()
190
+ }
191
+ ExpoIapLog.result("fetchProductsAndroid", payload)
192
+ promise.resolve(payload)
146
193
  } catch (e: Exception) {
194
+ ExpoIapLog.failure("fetchProductsAndroid", e)
147
195
  promise.reject(OpenIapError.QueryProduct.CODE, e.message, null)
148
196
  }
149
197
  }
150
198
  }
151
199
 
152
200
  AsyncFunction("getAvailableItems") { promise: Promise ->
201
+ ExpoIapLog.payload("getAvailableItemsAndroid", null)
153
202
  scope.launch {
154
203
  try {
155
204
  val purchases = openIap.getAvailablePurchases(null)
156
- promise.resolve(purchases.map { it.toJSON() })
205
+ val payload = purchases.map { it.toJson() }
206
+ ExpoIapLog.result("getAvailableItemsAndroid", payload)
207
+ promise.resolve(payload)
157
208
  } catch (e: Exception) {
209
+ ExpoIapLog.failure("getAvailableItemsAndroid", e)
158
210
  promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, null)
159
211
  }
160
212
  }
@@ -164,11 +216,22 @@ class ExpoIapModule : Module() {
164
216
  AsyncFunction("deepLinkToSubscriptionsAndroid") { params: Map<String, Any?>, promise: Promise ->
165
217
  val sku = (params["sku"] ?: params["skuAndroid"]) as? String
166
218
  val packageName = (params["packageName"] ?: params["packageNameAndroid"]) as? String
219
+ ExpoIapLog.payload(
220
+ "deepLinkToSubscriptionsAndroid",
221
+ mapOf("sku" to sku, "packageName" to packageName),
222
+ )
167
223
  scope.launch {
168
224
  try {
169
- openIap.deepLinkToSubscriptions(DeepLinkOptions(sku, packageName))
225
+ openIap.deepLinkToSubscriptions(
226
+ DeepLinkOptions(
227
+ packageNameAndroid = packageName,
228
+ skuAndroid = sku,
229
+ ),
230
+ )
231
+ ExpoIapLog.result("deepLinkToSubscriptionsAndroid", true)
170
232
  promise.resolve(null)
171
233
  } catch (e: Exception) {
234
+ ExpoIapLog.failure("deepLinkToSubscriptionsAndroid", e)
172
235
  promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, null)
173
236
  }
174
237
  }
@@ -176,18 +239,22 @@ class ExpoIapModule : Module() {
176
239
 
177
240
  // Get Google Play storefront country code (Android)
178
241
  AsyncFunction("getStorefrontAndroid") { promise: Promise ->
242
+ ExpoIapLog.payload("getStorefrontAndroid", null)
179
243
  scope.launch {
180
244
  try {
181
245
  val code = openIap.getStorefront()
246
+ ExpoIapLog.result("getStorefrontAndroid", code)
182
247
  promise.resolve(code)
183
248
  } catch (e: Exception) {
249
+ ExpoIapLog.failure("getStorefrontAndroid", e)
184
250
  promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e)
185
251
  }
186
252
  }
187
253
  }
188
254
 
189
255
  AsyncFunction("requestPurchase") { params: Map<String, Any?>, promise: Promise ->
190
- val type = params["type"] as String
256
+ ExpoIapLog.payload("requestPurchaseAndroid", params)
257
+ val type = params["type"] as? String
191
258
  val skus: List<String> =
192
259
  (params["skus"] as? List<*>)?.filterIsInstance<String>()
193
260
  ?: (params["skuArr"] as? List<*>)?.filterIsInstance<String>()
@@ -199,7 +266,7 @@ class ExpoIapModule : Module() {
199
266
  val isOfferPersonalized = params["isOfferPersonalized"] as? Boolean ?: false
200
267
  val offerTokenArr =
201
268
  (params["offerTokenArr"] as? List<*>)?.filterIsInstance<String>() ?: emptyList()
202
- val subscriptionOffersParam =
269
+ val explicitSubscriptionOffers =
203
270
  (params["subscriptionOffers"] as? List<*>)?.mapNotNull { rawOffer ->
204
271
  val offerMap = rawOffer as? Map<*, *> ?: return@mapNotNull null
205
272
  val sku = offerMap["sku"] as? String
@@ -207,67 +274,123 @@ class ExpoIapModule : Module() {
207
274
  if (sku.isNullOrEmpty() || offerToken.isNullOrEmpty()) {
208
275
  null
209
276
  } else {
210
- RequestSubscriptionAndroidProps.SubscriptionOffer(sku = sku, offerToken = offerToken)
277
+ AndroidSubscriptionOfferInput(offerToken = offerToken, sku = sku)
211
278
  }
212
279
  } ?: emptyList()
280
+ val purchaseToken =
281
+ (params["purchaseTokenAndroid"] ?: params["purchaseToken"]) as? String
282
+ val replacementMode =
283
+ (params["replacementModeAndroid"] ?: params["replacementMode"]) as? Number
213
284
 
214
- PromiseUtils.addPromiseForKey(PromiseUtils.PROMISE_BUY_ITEM, promise)
215
- scope.launch {
216
- try {
217
- openIap.setActivity(currentActivity)
218
- val reqType = ProductRequest.ProductRequestType.fromString(type)
219
- val subscriptionOffers =
220
- if (reqType == ProductRequest.ProductRequestType.Subs) {
221
- when {
222
- subscriptionOffersParam.isNotEmpty() -> subscriptionOffersParam
223
- offerTokenArr.isNotEmpty() ->
224
- skus.zip(offerTokenArr).mapNotNull { (sku, token) ->
225
- if (token.isNotEmpty()) {
226
- RequestSubscriptionAndroidProps.SubscriptionOffer(
227
- sku = sku,
228
- offerToken = token,
229
- )
230
- } else {
231
- null
232
- }
233
- }
234
- else -> emptyList()
235
- }
285
+ val productType =
286
+ when (parseProductQueryType(type)) {
287
+ ProductQueryType.Subs -> ProductQueryType.Subs
288
+ else -> ProductQueryType.InApp
289
+ }
290
+
291
+ val fallbackOffers =
292
+ if (explicitSubscriptionOffers.isEmpty() && offerTokenArr.isNotEmpty()) {
293
+ skus.zip(offerTokenArr).mapNotNull { (sku, token) ->
294
+ if (token.isNotEmpty()) {
295
+ AndroidSubscriptionOfferInput(offerToken = token, sku = sku)
236
296
  } else {
237
- emptyList()
297
+ null
238
298
  }
239
- val result =
240
- openIap.requestPurchase(
241
- RequestPurchaseParams(
242
- skus = skus,
299
+ }
300
+ } else {
301
+ emptyList()
302
+ }
303
+
304
+ val subscriptionOffers =
305
+ (explicitSubscriptionOffers.ifEmpty { fallbackOffers })
306
+ .takeIf { it.isNotEmpty() }
307
+
308
+ val requestProps =
309
+ when (productType) {
310
+ ProductQueryType.Subs -> {
311
+ val android =
312
+ RequestSubscriptionAndroidProps(
313
+ isOfferPersonalized = isOfferPersonalized,
243
314
  obfuscatedAccountIdAndroid = obfuscatedAccountId,
244
315
  obfuscatedProfileIdAndroid = obfuscatedProfileId,
245
- isOfferPersonalized = isOfferPersonalized,
316
+ purchaseTokenAndroid = purchaseToken,
317
+ replacementModeAndroid = replacementMode?.toInt(),
318
+ skus = skus,
246
319
  subscriptionOffers = subscriptionOffers,
247
- ),
248
- reqType,
320
+ )
321
+ RequestPurchaseProps(
322
+ request =
323
+ RequestPurchaseProps.Request.Subscription(
324
+ RequestSubscriptionPropsByPlatforms(android = android),
325
+ ),
326
+ type = ProductQueryType.Subs,
249
327
  )
250
- result.forEach { p ->
251
- try {
252
- emitOrQueue(EVENT_PURCHASE_UPDATED, p.toJSON())
253
- } catch (ex: Exception) {
254
- Log.e(TAG, "Failed to send PURCHASE_UPDATED event (requestPurchase)", ex)
328
+ }
329
+
330
+ else -> {
331
+ val android =
332
+ RequestPurchaseAndroidProps(
333
+ isOfferPersonalized = isOfferPersonalized,
334
+ obfuscatedAccountIdAndroid = obfuscatedAccountId,
335
+ obfuscatedProfileIdAndroid = obfuscatedProfileId,
336
+ skus = skus,
337
+ )
338
+ RequestPurchaseProps(
339
+ request =
340
+ RequestPurchaseProps.Request.Purchase(
341
+ RequestPurchasePropsByPlatforms(android = android),
342
+ ),
343
+ type = ProductQueryType.InApp,
344
+ )
345
+ }
346
+ }
347
+
348
+ PromiseUtils.addPromiseForKey(PromiseUtils.PROMISE_BUY_ITEM, promise)
349
+ scope.launch {
350
+ try {
351
+ openIap.setActivity(currentActivity)
352
+ val result = openIap.requestPurchase(requestProps)
353
+ val purchases =
354
+ when (result) {
355
+ is RequestPurchaseResultPurchases -> result.value.orEmpty()
356
+ is RequestPurchaseResultPurchase -> result.value?.let(::listOf).orEmpty()
357
+ else -> emptyList()
358
+ }
359
+ ExpoIapLog.result(
360
+ "requestPurchaseAndroid",
361
+ purchases.map { it.toJson() },
362
+ )
363
+ purchases.forEach { purchase ->
364
+ runCatching {
365
+ emitOrQueue(EVENT_PURCHASE_UPDATED, purchase.toJson())
366
+ }.onFailure { ex ->
367
+ Log.e(
368
+ TAG,
369
+ "Failed to send PURCHASE_UPDATED event (requestPurchase)",
370
+ ex,
371
+ )
255
372
  }
256
373
  }
257
- PromiseUtils.resolvePromisesForKey(PromiseUtils.PROMISE_BUY_ITEM, result.map { it.toJSON() })
374
+ PromiseUtils.resolvePromisesForKey(
375
+ PromiseUtils.PROMISE_BUY_ITEM,
376
+ purchases.map { it.toJson() },
377
+ )
258
378
  } catch (e: Exception) {
379
+ ExpoIapLog.failure("requestPurchaseAndroid", e)
259
380
  val errorMap =
260
381
  mapOf(
261
382
  "code" to OpenIapError.PurchaseFailed.CODE,
262
383
  "message" to (e.message ?: "Purchase failed"),
263
384
  "platform" to "android",
264
385
  )
265
- try {
266
- emitOrQueue(EVENT_PURCHASE_ERROR, errorMap)
267
- } catch (ex: Exception) {
268
- Log.e(TAG, "Failed to send PURCHASE_ERROR event (requestPurchase)", ex)
269
- }
270
- // Reject and clear any pending promises for this purchase flow
386
+ runCatching { emitOrQueue(EVENT_PURCHASE_ERROR, errorMap) }
387
+ .onFailure { ex ->
388
+ Log.e(
389
+ TAG,
390
+ "Failed to send PURCHASE_ERROR event (requestPurchase)",
391
+ ex,
392
+ )
393
+ }
271
394
  PromiseUtils.rejectPromisesForKey(
272
395
  PromiseUtils.PROMISE_BUY_ITEM,
273
396
  OpenIapError.PurchaseFailed.CODE,
@@ -279,11 +402,15 @@ class ExpoIapModule : Module() {
279
402
  }
280
403
 
281
404
  AsyncFunction("acknowledgePurchaseAndroid") { token: String, promise: Promise ->
405
+ ExpoIapLog.payload("acknowledgePurchaseAndroid", mapOf("token" to token))
282
406
  scope.launch {
283
407
  try {
284
408
  openIap.acknowledgePurchaseAndroid(token)
285
- promise.resolve(mapOf("responseCode" to 0))
409
+ val response = mapOf("responseCode" to 0)
410
+ ExpoIapLog.result("acknowledgePurchaseAndroid", response)
411
+ promise.resolve(response)
286
412
  } catch (e: Exception) {
413
+ ExpoIapLog.failure("acknowledgePurchaseAndroid", e)
287
414
  promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, null)
288
415
  }
289
416
  }
@@ -291,11 +418,15 @@ class ExpoIapModule : Module() {
291
418
 
292
419
  // New name: consumePurchaseAndroid
293
420
  AsyncFunction("consumePurchaseAndroid") { token: String, promise: Promise ->
421
+ ExpoIapLog.payload("consumePurchaseAndroid", mapOf("token" to token))
294
422
  scope.launch {
295
423
  try {
296
424
  openIap.consumePurchaseAndroid(token)
297
- promise.resolve(mapOf("responseCode" to 0, "purchaseToken" to token))
425
+ val response = mapOf("responseCode" to 0, "purchaseToken" to token)
426
+ ExpoIapLog.result("consumePurchaseAndroid", response)
427
+ promise.resolve(response)
298
428
  } catch (e: Exception) {
429
+ ExpoIapLog.failure("consumePurchaseAndroid", e)
299
430
  promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, null)
300
431
  }
301
432
  }