Skip to content

Commit 4261f06

Browse files
authored
feat(openiap): migrate sdk to openiap 1.0.8 schema (#216)
## Summary - update the JS surface and examples to match the OpenIAP 1.0.8 schema - link the new purchase error helpers and normalize cross-platform payloads - refresh docs, generated types, and build step to target OpenIAP 1.0.8 data ## Testing - bun run lint - bun run test - cd example && bun run test <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Added cross-platform getStorefront and deepLinkToSubscriptions; Android subscription offers supported. - Breaking Changes - Renamed getAvailablePurchases → getAvailableItems. - Error API reorganized: purchase error surface replaced by new centralized error helpers. - Removed setValueAsync, restorePurchases, and PI exports. - Improvements - Unified purchase/product normalization and JSON payloads; more robust purchase flows and deep-link behavior. - Documentation - API docs updated for new error handling. - Examples - Added PurchaseDetails component and updated example UIs. - Chores - Bumped Android dependency/version and type-generation tag. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 3c8ba7a commit 4261f06

File tree

20 files changed

+833
-714
lines changed

20 files changed

+833
-714
lines changed

android/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,6 @@ dependencies {
5858
implementation project(":openiap-google")
5959
} else {
6060
// Fallback to published artifact when local project isn't linked
61-
implementation "io.github.hyochan.openiap:openiap-google:1.1.12"
61+
implementation "io.github.hyochan.openiap:openiap-google:1.2.2"
6262
}
6363
}

android/src/main/java/expo/modules/iap/ExpoIapModule.kt

Lines changed: 145 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,22 @@ package expo.modules.iap
22

33
import android.content.Context
44
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
59
import dev.hyo.openiap.OpenIapError
610
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
1121
import expo.modules.kotlin.Promise
1222
import expo.modules.kotlin.exception.Exceptions
1323
import expo.modules.kotlin.modules.Module
@@ -18,6 +28,7 @@ import kotlinx.coroutines.Job
1828
import kotlinx.coroutines.launch
1929
import kotlinx.coroutines.sync.Mutex
2030
import kotlinx.coroutines.sync.withLock
31+
import java.util.Locale
2132
import java.util.concurrent.ConcurrentLinkedQueue
2233
import java.util.concurrent.atomic.AtomicBoolean
2334

@@ -59,7 +70,20 @@ class ExpoIapModule : Module() {
5970
pendingEvents.add(name to payload)
6071
}
6172

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

6488
override fun definition() =
6589
ModuleDefinition {
@@ -90,8 +114,9 @@ class ExpoIapModule : Module() {
90114
if (!listenersAttached) {
91115
listenersAttached = true
92116
openIap.addPurchaseUpdateListener { p ->
93-
runCatching { emitOrQueue(EVENT_PURCHASE_UPDATED, p.toJSON()) }
94-
.onFailure { Log.e(TAG, "Failed to buffer/send PURCHASE_UPDATED", it) }
117+
runCatching {
118+
emitOrQueue(EVENT_PURCHASE_UPDATED, p.toJson())
119+
}.onFailure { Log.e(TAG, "Failed to buffer/send PURCHASE_UPDATED", it) }
95120
}
96121
openIap.addPurchaseErrorListener { e ->
97122
runCatching { emitOrQueue(EVENT_PURCHASE_ERROR, e.toJSON()) }
@@ -140,9 +165,16 @@ class ExpoIapModule : Module() {
140165
AsyncFunction("fetchProducts") { type: String, skuArr: Array<String>, promise: Promise ->
141166
scope.launch {
142167
try {
143-
val reqType = ProductRequest.ProductRequestType.fromString(type)
144-
val products = openIap.fetchProducts(ProductRequest(skuArr.toList(), reqType))
145-
promise.resolve(products.map { it.toJSON() })
168+
val queryType = parseProductQueryType(type)
169+
val request = ProductRequest(skuArr.toList(), queryType)
170+
val result = openIap.fetchProducts(request)
171+
val payload =
172+
when (result) {
173+
is FetchProductsResultProducts -> result.value.orEmpty().map { it.toJson() }
174+
is FetchProductsResultSubscriptions -> result.value.orEmpty().map { it.toJson() }
175+
else -> emptyList<Map<String, Any?>>()
176+
}
177+
promise.resolve(payload)
146178
} catch (e: Exception) {
147179
promise.reject(OpenIapError.QueryProduct.CODE, e.message, null)
148180
}
@@ -153,7 +185,7 @@ class ExpoIapModule : Module() {
153185
scope.launch {
154186
try {
155187
val purchases = openIap.getAvailablePurchases(null)
156-
promise.resolve(purchases.map { it.toJSON() })
188+
promise.resolve(purchases.map { it.toJson() })
157189
} catch (e: Exception) {
158190
promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, null)
159191
}
@@ -166,7 +198,12 @@ class ExpoIapModule : Module() {
166198
val packageName = (params["packageName"] ?: params["packageNameAndroid"]) as? String
167199
scope.launch {
168200
try {
169-
openIap.deepLinkToSubscriptions(DeepLinkOptions(sku, packageName))
201+
openIap.deepLinkToSubscriptions(
202+
DeepLinkOptions(
203+
packageNameAndroid = packageName,
204+
skuAndroid = sku,
205+
),
206+
)
170207
promise.resolve(null)
171208
} catch (e: Exception) {
172209
promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, null)
@@ -187,7 +224,7 @@ class ExpoIapModule : Module() {
187224
}
188225

189226
AsyncFunction("requestPurchase") { params: Map<String, Any?>, promise: Promise ->
190-
val type = params["type"] as String
227+
val type = params["type"] as? String
191228
val skus: List<String> =
192229
(params["skus"] as? List<*>)?.filterIsInstance<String>()
193230
?: (params["skuArr"] as? List<*>)?.filterIsInstance<String>()
@@ -199,75 +236,126 @@ class ExpoIapModule : Module() {
199236
val isOfferPersonalized = params["isOfferPersonalized"] as? Boolean ?: false
200237
val offerTokenArr =
201238
(params["offerTokenArr"] as? List<*>)?.filterIsInstance<String>() ?: emptyList()
202-
val subscriptionOffersParam =
239+
val explicitSubscriptionOffers =
203240
(params["subscriptionOffers"] as? List<*>)?.mapNotNull { rawOffer ->
204241
val offerMap = rawOffer as? Map<*, *> ?: return@mapNotNull null
205242
val sku = offerMap["sku"] as? String
206243
val offerToken = offerMap["offerToken"] as? String
207244
if (sku.isNullOrEmpty() || offerToken.isNullOrEmpty()) {
208245
null
209246
} else {
210-
RequestSubscriptionAndroidProps.SubscriptionOffer(sku = sku, offerToken = offerToken)
247+
AndroidSubscriptionOfferInput(offerToken = offerToken, sku = sku)
211248
}
212249
} ?: emptyList()
250+
val purchaseToken =
251+
(params["purchaseTokenAndroid"] ?: params["purchaseToken"]) as? String
252+
val replacementMode =
253+
(params["replacementModeAndroid"] ?: params["replacementMode"]) as? Number
213254

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-
}
255+
val productType =
256+
when (parseProductQueryType(type)) {
257+
ProductQueryType.Subs -> ProductQueryType.Subs
258+
else -> ProductQueryType.InApp
259+
}
260+
261+
val fallbackOffers =
262+
if (explicitSubscriptionOffers.isEmpty() && offerTokenArr.isNotEmpty()) {
263+
skus.zip(offerTokenArr).mapNotNull { (sku, token) ->
264+
if (token.isNotEmpty()) {
265+
AndroidSubscriptionOfferInput(offerToken = token, sku = sku)
236266
} else {
237-
emptyList()
267+
null
238268
}
239-
val result =
240-
openIap.requestPurchase(
241-
RequestPurchaseParams(
242-
skus = skus,
269+
}
270+
} else {
271+
emptyList()
272+
}
273+
274+
val subscriptionOffers =
275+
(explicitSubscriptionOffers.ifEmpty { fallbackOffers })
276+
.takeIf { it.isNotEmpty() }
277+
278+
val requestProps =
279+
when (productType) {
280+
ProductQueryType.Subs -> {
281+
val android =
282+
RequestSubscriptionAndroidProps(
283+
isOfferPersonalized = isOfferPersonalized,
243284
obfuscatedAccountIdAndroid = obfuscatedAccountId,
244285
obfuscatedProfileIdAndroid = obfuscatedProfileId,
245-
isOfferPersonalized = isOfferPersonalized,
286+
purchaseTokenAndroid = purchaseToken,
287+
replacementModeAndroid = replacementMode?.toInt(),
288+
skus = skus,
246289
subscriptionOffers = subscriptionOffers,
247-
),
248-
reqType,
290+
)
291+
RequestPurchaseProps(
292+
request =
293+
RequestPurchaseProps.Request.Subscription(
294+
RequestSubscriptionPropsByPlatforms(android = android),
295+
),
296+
type = ProductQueryType.Subs,
249297
)
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)
298+
}
299+
300+
else -> {
301+
val android =
302+
RequestPurchaseAndroidProps(
303+
isOfferPersonalized = isOfferPersonalized,
304+
obfuscatedAccountIdAndroid = obfuscatedAccountId,
305+
obfuscatedProfileIdAndroid = obfuscatedProfileId,
306+
skus = skus,
307+
)
308+
RequestPurchaseProps(
309+
request =
310+
RequestPurchaseProps.Request.Purchase(
311+
RequestPurchasePropsByPlatforms(android = android),
312+
),
313+
type = ProductQueryType.InApp,
314+
)
315+
}
316+
}
317+
318+
PromiseUtils.addPromiseForKey(PromiseUtils.PROMISE_BUY_ITEM, promise)
319+
scope.launch {
320+
try {
321+
openIap.setActivity(currentActivity)
322+
val result = openIap.requestPurchase(requestProps)
323+
val purchases =
324+
when (result) {
325+
is RequestPurchaseResultPurchases -> result.value.orEmpty()
326+
is RequestPurchaseResultPurchase -> result.value?.let(::listOf).orEmpty()
327+
else -> emptyList()
328+
}
329+
purchases.forEach { purchase ->
330+
runCatching {
331+
emitOrQueue(EVENT_PURCHASE_UPDATED, purchase.toJson())
332+
}.onFailure { ex ->
333+
Log.e(
334+
TAG,
335+
"Failed to send PURCHASE_UPDATED event (requestPurchase)",
336+
ex,
337+
)
255338
}
256339
}
257-
PromiseUtils.resolvePromisesForKey(PromiseUtils.PROMISE_BUY_ITEM, result.map { it.toJSON() })
340+
PromiseUtils.resolvePromisesForKey(
341+
PromiseUtils.PROMISE_BUY_ITEM,
342+
purchases.map { it.toJson() },
343+
)
258344
} catch (e: Exception) {
259345
val errorMap =
260346
mapOf(
261347
"code" to OpenIapError.PurchaseFailed.CODE,
262348
"message" to (e.message ?: "Purchase failed"),
263349
"platform" to "android",
264350
)
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
351+
runCatching { emitOrQueue(EVENT_PURCHASE_ERROR, errorMap) }
352+
.onFailure { ex ->
353+
Log.e(
354+
TAG,
355+
"Failed to send PURCHASE_ERROR event (requestPurchase)",
356+
ex,
357+
)
358+
}
271359
PromiseUtils.rejectPromisesForKey(
272360
PromiseUtils.PROMISE_BUY_ITEM,
273361
OpenIapError.PurchaseFailed.CODE,

docs/docs/api/types.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ The expo-iap type surface is now generated in one place: `src/types.ts`. The fil
99
Key runtime helpers that build on these types live alongside them:
1010

1111
- `src/types.ts` – auto-generated enums and interfaces
12-
- `src/purchase-error.ts` – typed error helpers (`PurchaseError`, `ErrorCodeUtils`)
12+
- `src/utils/errorMapping.ts` – typed error helpers (`createPurchaseError`, `ErrorCodeUtils`)
1313
- `src/helpers/subscription.ts` – subscription utilities that re-export `ActiveSubscription`
1414

1515
Below is a curated overview of the most commonly used types. Consult `src/types.ts` for the full schema.
@@ -43,7 +43,7 @@ export enum ErrorCode {
4343
}
4444
```
4545

46-
Use `PurchaseError` from `src/purchase-error.ts` to work with typed errors and platform mappings.
46+
Use `createPurchaseError` from `src/utils/errorMapping.ts` to work with typed errors and platform mappings.
4747

4848
## Product Types
4949

@@ -177,7 +177,7 @@ Use the higher-level `validateReceipt` helper exported from `src/index.ts` for a
177177
## Where to Find Everything
178178

179179
- For the exhaustive list of enums and interfaces, open `src/types.ts`.
180-
- For error handling utilities (`PurchaseError`, `ErrorCodeUtils`), use `src/purchase-error.ts`.
180+
- For error handling utilities (`createPurchaseError`, `ErrorCodeUtils`), use `src/utils/errorMapping.ts`.
181181
- All generated types are re-exported from the package root so consumers can import from `expo-iap` directly:
182182

183183
```ts

0 commit comments

Comments
 (0)