expo-iap 2.9.6 → 2.9.7

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/.eslintrc.js CHANGED
@@ -5,5 +5,29 @@ module.exports = {
5
5
  rules: {
6
6
  'eslint-comments/no-unlimited-disable': 0,
7
7
  'eslint-comments/no-unused-disable': 0,
8
+ // Prevent ambiguous imports that Metro may mis-resolve
9
+ 'no-restricted-imports': [
10
+ 'error',
11
+ {
12
+ paths: [
13
+ {
14
+ name: '.',
15
+ message:
16
+ "Avoid `import from '.'`; use './index' or an explicit path.",
17
+ },
18
+ ],
19
+ },
20
+ ],
21
+ 'no-restricted-modules': [
22
+ 'error',
23
+ {
24
+ paths: [
25
+ {
26
+ name: '.',
27
+ message: "Avoid `require('.')`; use './index' or an explicit path.",
28
+ },
29
+ ],
30
+ },
31
+ ],
8
32
  },
9
33
  };
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [2.9.7] - 2025-09-12
4
+
5
+ ### Changed
6
+
7
+ - Android: remove `ensureConnection` wrapper in favor of `BillingClient` auto-reconnect and a simpler `getBillingClientOrReject` precheck
8
+ - Android: also verify `BillingClient.isReady` before proceeding to avoid sporadic failures
9
+ - Android: drop deprecated product fields from mapping (`displayName`, `name`, `oneTimePurchaseOfferDetails`, `subscriptionOfferDetails`) in favor of `...Android` suffixed fields
10
+ - iOS: add `ensureConnection()` guard to all public async APIs; fix main-actor state updates and minor warnings
11
+
12
+ ### Fixed
13
+
14
+ - Android: fix stray brace that prematurely closed `ModuleDefinition` and ktlint trailing spaces
15
+
3
16
  ## [2.9.6] - 2025-09-11
4
17
 
5
18
  ### Fixed
@@ -155,268 +155,248 @@ class ExpoIapModule :
155
155
  }
156
156
 
157
157
  AsyncFunction("fetchProducts") { type: String, skuArr: Array<String>, promise: Promise ->
158
- ensureConnection(promise) { billingClient ->
159
- val skuList =
160
- skuArr.map { sku ->
161
- QueryProductDetailsParams.Product
162
- .newBuilder()
163
- .setProductId(sku)
164
- .setProductType(type)
165
- .build()
166
- }
167
-
168
- if (skuList.isEmpty()) {
169
- promise.reject(IapErrorCode.E_EMPTY_SKU_LIST, "The SKU list is empty.", null)
170
- return@ensureConnection
171
- }
158
+ val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
172
159
 
173
- val params =
174
- QueryProductDetailsParams
160
+ val skuList =
161
+ skuArr.map { sku ->
162
+ QueryProductDetailsParams.Product
175
163
  .newBuilder()
176
- .setProductList(skuList)
164
+ .setProductId(sku)
165
+ .setProductType(type)
177
166
  .build()
167
+ }
178
168
 
179
- billingClient.queryProductDetailsAsync(params) { billingResult: BillingResult, productDetailsResult: QueryProductDetailsResult ->
180
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
181
- promise.reject(
182
- IapErrorCode.E_QUERY_PRODUCT,
183
- "Error querying product details: ${billingResult.debugMessage}",
184
- null,
185
- )
186
- return@queryProductDetailsAsync
187
- }
169
+ if (skuList.isEmpty()) {
170
+ promise.reject(IapErrorCode.E_EMPTY_SKU_LIST, "The SKU list is empty.", null)
171
+ return@AsyncFunction
172
+ }
188
173
 
189
- val productDetailsList = productDetailsResult.productDetailsList ?: emptyList()
190
-
191
- val items =
192
- productDetailsList.map { productDetails ->
193
- skus[productDetails.productId] = productDetails
194
-
195
- val currency = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode
196
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.priceCurrencyCode
197
- ?: "Unknown"
198
- val displayPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice
199
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
200
- ?: "N/A"
201
-
202
- // Prepare reusable data
203
- val oneTimePurchaseData = productDetails.oneTimePurchaseOfferDetails?.let {
204
- mapOf(
205
- "priceCurrencyCode" to it.priceCurrencyCode,
206
- "formattedPrice" to it.formattedPrice,
207
- "priceAmountMicros" to it.priceAmountMicros.toString(),
208
- )
209
- }
210
-
211
- val subscriptionOfferData = productDetails.subscriptionOfferDetails?.map { subscriptionOfferDetailsItem ->
212
- mapOf(
213
- "basePlanId" to subscriptionOfferDetailsItem.basePlanId,
214
- "offerId" to subscriptionOfferDetailsItem.offerId,
215
- "offerToken" to subscriptionOfferDetailsItem.offerToken,
216
- "offerTags" to subscriptionOfferDetailsItem.offerTags,
217
- "pricingPhases" to
218
- mapOf(
219
- "pricingPhaseList" to
220
- subscriptionOfferDetailsItem.pricingPhases.pricingPhaseList.map
221
- { pricingPhaseItem ->
222
- mapOf(
223
- "formattedPrice" to pricingPhaseItem.formattedPrice,
224
- "priceCurrencyCode" to pricingPhaseItem.priceCurrencyCode,
225
- "billingPeriod" to pricingPhaseItem.billingPeriod,
226
- "billingCycleCount" to pricingPhaseItem.billingCycleCount,
227
- "priceAmountMicros" to
228
- pricingPhaseItem.priceAmountMicros.toString(),
229
- "recurrenceMode" to pricingPhaseItem.recurrenceMode,
230
- )
231
- },
232
- ),
233
- )
234
- }
174
+ val params =
175
+ QueryProductDetailsParams
176
+ .newBuilder()
177
+ .setProductList(skuList)
178
+ .build()
235
179
 
236
- // Convert Android productType to our expected 'inapp' or 'subs'
237
- val productType = if (productDetails.productType == BillingClient.ProductType.SUBS) "subs" else "inapp"
238
-
180
+ billingClient.queryProductDetailsAsync(params) { billingResult: BillingResult, productDetailsResult: QueryProductDetailsResult ->
181
+ if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
182
+ promise.reject(
183
+ IapErrorCode.E_QUERY_PRODUCT,
184
+ "Error querying product details: ${billingResult.debugMessage}",
185
+ null,
186
+ )
187
+ return@queryProductDetailsAsync
188
+ }
189
+
190
+ val productDetailsList = productDetailsResult.productDetailsList ?: emptyList()
191
+
192
+ val items =
193
+ productDetailsList.map { productDetails ->
194
+ skus[productDetails.productId] = productDetails
195
+
196
+ val currency = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode
197
+ ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.priceCurrencyCode
198
+ ?: "Unknown"
199
+ val displayPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice
200
+ ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
201
+ ?: "N/A"
202
+
203
+ // Prepare reusable data
204
+ val oneTimePurchaseData = productDetails.oneTimePurchaseOfferDetails?.let {
239
205
  mapOf(
240
- "id" to productDetails.productId,
241
- "title" to productDetails.title,
242
- "description" to productDetails.description,
243
- "type" to productType,
244
- // New field names with Android suffix
245
- "nameAndroid" to productDetails.name,
246
- "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseData,
247
- "subscriptionOfferDetailsAndroid" to subscriptionOfferData,
248
- "platform" to "android",
249
- "currency" to currency,
250
- "displayPrice" to displayPrice,
251
- // START: Deprecated - will be removed in v2.9.0
252
- // Use nameAndroid instead of displayName
253
- "displayName" to productDetails.name,
254
- // Use nameAndroid instead of name
255
- "name" to productDetails.name,
256
- // Use oneTimePurchaseOfferDetailsAndroid instead of oneTimePurchaseOfferDetails
257
- "oneTimePurchaseOfferDetails" to oneTimePurchaseData,
258
- // Use subscriptionOfferDetailsAndroid instead of subscriptionOfferDetails
259
- "subscriptionOfferDetails" to subscriptionOfferData,
260
- // END: Deprecated - will be removed in v2.9.0
206
+ "priceCurrencyCode" to it.priceCurrencyCode,
207
+ "formattedPrice" to it.formattedPrice,
208
+ "priceAmountMicros" to it.priceAmountMicros.toString(),
261
209
  )
262
210
  }
263
- promise.resolve(items)
264
- }
211
+
212
+ val subscriptionOfferData = productDetails.subscriptionOfferDetails?.map { subscriptionOfferDetailsItem ->
213
+ mapOf(
214
+ "basePlanId" to subscriptionOfferDetailsItem.basePlanId,
215
+ "offerId" to subscriptionOfferDetailsItem.offerId,
216
+ "offerToken" to subscriptionOfferDetailsItem.offerToken,
217
+ "offerTags" to subscriptionOfferDetailsItem.offerTags,
218
+ "pricingPhases" to
219
+ mapOf(
220
+ "pricingPhaseList" to
221
+ subscriptionOfferDetailsItem.pricingPhases.pricingPhaseList.map
222
+ { pricingPhaseItem ->
223
+ mapOf(
224
+ "formattedPrice" to pricingPhaseItem.formattedPrice,
225
+ "priceCurrencyCode" to pricingPhaseItem.priceCurrencyCode,
226
+ "billingPeriod" to pricingPhaseItem.billingPeriod,
227
+ "billingCycleCount" to pricingPhaseItem.billingCycleCount,
228
+ "priceAmountMicros" to
229
+ pricingPhaseItem.priceAmountMicros.toString(),
230
+ "recurrenceMode" to pricingPhaseItem.recurrenceMode,
231
+ )
232
+ },
233
+ ),
234
+ )
235
+ }
236
+
237
+ // Convert Android productType to our expected 'inapp' or 'subs'
238
+ val productType = if (productDetails.productType == BillingClient.ProductType.SUBS) "subs" else "inapp"
239
+
240
+ mapOf(
241
+ "id" to productDetails.productId,
242
+ "title" to productDetails.title,
243
+ "description" to productDetails.description,
244
+ "type" to productType,
245
+ // New field names with Android suffix
246
+ "nameAndroid" to productDetails.name,
247
+ "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseData,
248
+ "subscriptionOfferDetailsAndroid" to subscriptionOfferData,
249
+ "platform" to "android",
250
+ "currency" to currency,
251
+ "displayPrice" to displayPrice,
252
+
253
+ )
254
+ }
255
+ promise.resolve(items)
265
256
  }
266
257
  }
267
258
 
268
259
  AsyncFunction("requestProducts") { type: String, skuArr: Array<String>, promise: Promise ->
269
260
  Log.w("ExpoIap", "WARNING: requestProducts is deprecated. Use fetchProducts instead. The 'request' prefix should only be used for event-based operations. This method will be removed in version 3.0.0.")
270
-
271
- ensureConnection(promise) { billingClient ->
272
- val skuList =
273
- skuArr.map { sku ->
274
- QueryProductDetailsParams.Product
275
- .newBuilder()
276
- .setProductId(sku)
277
- .setProductType(type)
278
- .build()
279
- }
261
+ val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
280
262
 
281
- if (skuList.isEmpty()) {
282
- promise.reject(IapErrorCode.E_EMPTY_SKU_LIST, "The SKU list is empty.", null)
283
- return@ensureConnection
284
- }
285
-
286
- val params =
287
- QueryProductDetailsParams
263
+ val skuList =
264
+ skuArr.map { sku ->
265
+ QueryProductDetailsParams.Product
288
266
  .newBuilder()
289
- .setProductList(skuList)
267
+ .setProductId(sku)
268
+ .setProductType(type)
290
269
  .build()
270
+ }
291
271
 
292
- billingClient.queryProductDetailsAsync(params) { billingResult: BillingResult, productDetailsResult: QueryProductDetailsResult ->
293
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
294
- promise.reject(
295
- IapErrorCode.E_QUERY_PRODUCT,
296
- "Error querying product details: ${billingResult.debugMessage}",
297
- null,
298
- )
299
- return@queryProductDetailsAsync
300
- }
272
+ if (skuList.isEmpty()) {
273
+ promise.reject(IapErrorCode.E_EMPTY_SKU_LIST, "The SKU list is empty.", null)
274
+ return@AsyncFunction
275
+ }
301
276
 
302
- val productDetailsList = productDetailsResult.productDetailsList ?: emptyList()
303
-
304
- val items =
305
- productDetailsList.map { productDetails ->
306
- skus[productDetails.productId] = productDetails
307
-
308
- val currency = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode
309
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.priceCurrencyCode
310
- ?: "Unknown"
311
- val displayPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice
312
- ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
313
- ?: "N/A"
314
-
315
- // Prepare reusable data
316
- val oneTimePurchaseData = productDetails.oneTimePurchaseOfferDetails?.let {
317
- mapOf(
318
- "priceCurrencyCode" to it.priceCurrencyCode,
319
- "formattedPrice" to it.formattedPrice,
320
- "priceAmountMicros" to it.priceAmountMicros.toString(),
321
- )
322
- }
323
-
324
- val subscriptionOfferData = productDetails.subscriptionOfferDetails?.map { subscriptionOfferDetailsItem ->
325
- mapOf(
326
- "basePlanId" to subscriptionOfferDetailsItem.basePlanId,
327
- "offerId" to subscriptionOfferDetailsItem.offerId,
328
- "offerToken" to subscriptionOfferDetailsItem.offerToken,
329
- "offerTags" to subscriptionOfferDetailsItem.offerTags,
330
- "pricingPhases" to
331
- mapOf(
332
- "pricingPhaseList" to
333
- subscriptionOfferDetailsItem.pricingPhases.pricingPhaseList.map
334
- { pricingPhaseItem ->
335
- mapOf(
336
- "formattedPrice" to pricingPhaseItem.formattedPrice,
337
- "priceCurrencyCode" to pricingPhaseItem.priceCurrencyCode,
338
- "billingPeriod" to pricingPhaseItem.billingPeriod,
339
- "billingCycleCount" to pricingPhaseItem.billingCycleCount,
340
- "priceAmountMicros" to
341
- pricingPhaseItem.priceAmountMicros.toString(),
342
- "recurrenceMode" to pricingPhaseItem.recurrenceMode,
343
- )
344
- },
345
- ),
346
- )
347
- }
277
+ val params =
278
+ QueryProductDetailsParams
279
+ .newBuilder()
280
+ .setProductList(skuList)
281
+ .build()
282
+
283
+ billingClient.queryProductDetailsAsync(params) { billingResult: BillingResult, productDetailsResult: QueryProductDetailsResult ->
284
+ if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
285
+ promise.reject(
286
+ IapErrorCode.E_QUERY_PRODUCT,
287
+ "Error querying product details: ${billingResult.debugMessage}",
288
+ null,
289
+ )
290
+ return@queryProductDetailsAsync
291
+ }
292
+
293
+ val productDetailsList = productDetailsResult.productDetailsList ?: emptyList()
348
294
 
349
- // Convert Android productType to our expected 'inapp' or 'subs'
350
- val productType = if (productDetails.productType == BillingClient.ProductType.SUBS) "subs" else "inapp"
351
-
295
+ val items =
296
+ productDetailsList.map { productDetails ->
297
+ skus[productDetails.productId] = productDetails
298
+
299
+ val currency = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode
300
+ ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.priceCurrencyCode
301
+ ?: "Unknown"
302
+ val displayPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice
303
+ ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
304
+ ?: "N/A"
305
+
306
+ // Prepare reusable data
307
+ val oneTimePurchaseData = productDetails.oneTimePurchaseOfferDetails?.let {
352
308
  mapOf(
353
- "id" to productDetails.productId,
354
- "title" to productDetails.title,
355
- "description" to productDetails.description,
356
- "type" to productType,
357
- // New field names with Android suffix
358
- "nameAndroid" to productDetails.name,
359
- "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseData,
360
- "subscriptionOfferDetailsAndroid" to subscriptionOfferData,
361
- "platform" to "android",
362
- "currency" to currency,
363
- "displayPrice" to displayPrice,
364
- // START: Deprecated - will be removed in v2.9.0
365
- // Use nameAndroid instead of displayName
366
- "displayName" to productDetails.name,
367
- // Use nameAndroid instead of name
368
- "name" to productDetails.name,
369
- // Use oneTimePurchaseOfferDetailsAndroid instead of oneTimePurchaseOfferDetails
370
- "oneTimePurchaseOfferDetails" to oneTimePurchaseData,
371
- // Use subscriptionOfferDetailsAndroid instead of subscriptionOfferDetails
372
- "subscriptionOfferDetails" to subscriptionOfferData,
373
- // END: Deprecated - will be removed in v2.9.0
309
+ "priceCurrencyCode" to it.priceCurrencyCode,
310
+ "formattedPrice" to it.formattedPrice,
311
+ "priceAmountMicros" to it.priceAmountMicros.toString(),
374
312
  )
375
313
  }
376
- promise.resolve(items)
377
- }
314
+
315
+ val subscriptionOfferData = productDetails.subscriptionOfferDetails?.map { subscriptionOfferDetailsItem ->
316
+ mapOf(
317
+ "basePlanId" to subscriptionOfferDetailsItem.basePlanId,
318
+ "offerId" to subscriptionOfferDetailsItem.offerId,
319
+ "offerToken" to subscriptionOfferDetailsItem.offerToken,
320
+ "offerTags" to subscriptionOfferDetailsItem.offerTags,
321
+ "pricingPhases" to
322
+ mapOf(
323
+ "pricingPhaseList" to
324
+ subscriptionOfferDetailsItem.pricingPhases.pricingPhaseList.map
325
+ { pricingPhaseItem ->
326
+ mapOf(
327
+ "formattedPrice" to pricingPhaseItem.formattedPrice,
328
+ "priceCurrencyCode" to pricingPhaseItem.priceCurrencyCode,
329
+ "billingPeriod" to pricingPhaseItem.billingPeriod,
330
+ "billingCycleCount" to pricingPhaseItem.billingCycleCount,
331
+ "priceAmountMicros" to
332
+ pricingPhaseItem.priceAmountMicros.toString(),
333
+ "recurrenceMode" to pricingPhaseItem.recurrenceMode,
334
+ )
335
+ },
336
+ ),
337
+ )
338
+ }
339
+
340
+ // Convert Android productType to our expected 'inapp' or 'subs'
341
+ val productType = if (productDetails.productType == BillingClient.ProductType.SUBS) "subs" else "inapp"
342
+
343
+ mapOf(
344
+ "id" to productDetails.productId,
345
+ "title" to productDetails.title,
346
+ "description" to productDetails.description,
347
+ "type" to productType,
348
+ // New field names with Android suffix
349
+ "nameAndroid" to productDetails.name,
350
+ "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseData,
351
+ "subscriptionOfferDetailsAndroid" to subscriptionOfferData,
352
+ "platform" to "android",
353
+ "currency" to currency,
354
+ "displayPrice" to displayPrice,
355
+
356
+ )
357
+ }
358
+ promise.resolve(items)
378
359
  }
379
360
  }
380
361
 
381
362
  AsyncFunction("getAvailableItemsByType") { type: String, promise: Promise ->
382
- ensureConnection(promise) { billingClient ->
383
- val items = mutableListOf<Map<String, Any?>>()
384
- billingClient.queryPurchasesAsync(
385
- QueryPurchasesParams
386
- .newBuilder()
387
- .setProductType(
388
- if (type == "subs") BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP,
389
- ).build(),
390
- ) { billingResult: BillingResult, purchases: List<Purchase>? ->
391
- if (!isValidResult(billingResult, promise)) return@queryPurchasesAsync
392
- purchases?.forEach { purchase ->
393
- val item =
394
- mutableMapOf<String, Any?>(
395
- "id" to purchase.orderId,
396
- "productId" to purchase.products.firstOrNull() as Any?,
397
- "ids" to purchase.products,
398
- "transactionId" to purchase.orderId, // @deprecated - use id instead
399
- "transactionDate" to purchase.purchaseTime.toDouble(),
400
- "transactionReceipt" to purchase.originalJson,
401
- "orderId" to purchase.orderId,
402
- "purchaseTokenAndroid" to purchase.purchaseToken,
403
- "purchaseToken" to purchase.purchaseToken,
404
- "developerPayloadAndroid" to purchase.developerPayload,
405
- "signatureAndroid" to purchase.signature,
406
- "purchaseStateAndroid" to purchase.purchaseState,
407
- "isAcknowledgedAndroid" to purchase.isAcknowledged,
408
- "packageNameAndroid" to purchase.packageName,
409
- "obfuscatedAccountIdAndroid" to purchase.accountIdentifiers?.obfuscatedAccountId,
410
- "obfuscatedProfileIdAndroid" to purchase.accountIdentifiers?.obfuscatedProfileId,
411
- "platform" to "android",
412
- )
413
- if (type == BillingClient.ProductType.SUBS) {
414
- item["autoRenewingAndroid"] = purchase.isAutoRenewing
415
- }
416
- items.add(item)
363
+ val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
364
+ val items = mutableListOf<Map<String, Any?>>()
365
+ billingClient.queryPurchasesAsync(
366
+ QueryPurchasesParams
367
+ .newBuilder()
368
+ .setProductType(
369
+ if (type == "subs") BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP,
370
+ ).build(),
371
+ ) { billingResult: BillingResult, purchases: List<Purchase>? ->
372
+ if (!isValidResult(billingResult, promise)) return@queryPurchasesAsync
373
+ purchases?.forEach { purchase ->
374
+ val item =
375
+ mutableMapOf<String, Any?>(
376
+ "id" to purchase.orderId,
377
+ "productId" to purchase.products.firstOrNull() as Any?,
378
+ "ids" to purchase.products,
379
+ "transactionId" to purchase.orderId, // @deprecated - use id instead
380
+ "transactionDate" to purchase.purchaseTime.toDouble(),
381
+ "transactionReceipt" to purchase.originalJson,
382
+ "orderId" to purchase.orderId,
383
+ "purchaseTokenAndroid" to purchase.purchaseToken,
384
+ "purchaseToken" to purchase.purchaseToken,
385
+ "developerPayloadAndroid" to purchase.developerPayload,
386
+ "signatureAndroid" to purchase.signature,
387
+ "purchaseStateAndroid" to purchase.purchaseState,
388
+ "isAcknowledgedAndroid" to purchase.isAcknowledged,
389
+ "packageNameAndroid" to purchase.packageName,
390
+ "obfuscatedAccountIdAndroid" to purchase.accountIdentifiers?.obfuscatedAccountId,
391
+ "obfuscatedProfileIdAndroid" to purchase.accountIdentifiers?.obfuscatedProfileId,
392
+ "platform" to "android",
393
+ )
394
+ if (type == BillingClient.ProductType.SUBS) {
395
+ item["autoRenewingAndroid"] = purchase.isAutoRenewing
417
396
  }
418
- promise.resolve(items)
397
+ items.add(item)
419
398
  }
399
+ promise.resolve(items)
420
400
  }
421
401
  }
422
402
 
@@ -443,7 +423,7 @@ class ExpoIapModule :
443
423
  return@AsyncFunction
444
424
  }
445
425
 
446
- ensureConnection(promise) { billingClient ->
426
+ val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
447
427
  PromiseUtils.addPromiseForKey(IapConstants.PROMISE_BUY_ITEM, promise)
448
428
 
449
429
  if (type == BillingClient.ProductType.SUBS && skuArr.size != offerTokenArr.size) {
@@ -461,7 +441,7 @@ class ExpoIapModule :
461
441
  Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
462
442
  }
463
443
  promise.reject(IapErrorCode.E_SKU_OFFER_MISMATCH, debugMessage, null)
464
- return@ensureConnection
444
+ return@AsyncFunction
465
445
  }
466
446
 
467
447
  val productParamsList =
@@ -484,7 +464,7 @@ class ExpoIapModule :
484
464
  Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
485
465
  }
486
466
  promise.reject(IapErrorCode.E_SKU_NOT_FOUND, debugMessage, null)
487
- return@ensureConnection
467
+ return@AsyncFunction
488
468
  }
489
469
 
490
470
  val productDetailParams =
@@ -541,7 +521,7 @@ class ExpoIapModule :
541
521
  val errorData = PlayUtils.getBillingResponseData(billingResult.responseCode)
542
522
  var errorMessage = billingResult.debugMessage ?: errorData.message
543
523
  var subResponseCode: Int? = null
544
-
524
+
545
525
  // Check for sub-response codes (v8.0.0+)
546
526
  try {
547
527
  subResponseCode = billingResult.javaClass.getMethod("getSubResponseCode").invoke(billingResult) as? Int
@@ -555,7 +535,7 @@ class ExpoIapModule :
555
535
  } catch (e: Exception) {
556
536
  // Method doesn't exist in older versions, ignore
557
537
  }
558
-
538
+
559
539
  // Send error event to match iOS behavior
560
540
  val errorMap = mutableMapOf<String, Any?>(
561
541
  "responseCode" to billingResult.responseCode,
@@ -563,37 +543,35 @@ class ExpoIapModule :
563
543
  "code" to errorData.code,
564
544
  "message" to errorMessage
565
545
  )
566
-
546
+
567
547
  // Add product ID if available
568
548
  if (skuArr.isNotEmpty()) {
569
549
  errorMap["productId"] = skuArr.first()
570
550
  }
571
-
551
+
572
552
  // Add sub-response code if available
573
553
  subResponseCode?.let {
574
554
  if (it != 0) {
575
555
  errorMap["subResponseCode"] = it
576
556
  }
577
557
  }
578
-
558
+
579
559
  try {
580
560
  sendEvent(OpenIapEvent.PURCHASE_ERROR, errorMap.toMap())
581
561
  } catch (e: Exception) {
582
562
  Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
583
563
  }
584
-
564
+
585
565
  promise.reject(errorData.code, errorMessage, null)
586
- return@ensureConnection
566
+ return@AsyncFunction
587
567
  }
588
- }
589
568
  }
590
569
 
591
570
  AsyncFunction("acknowledgePurchaseAndroid") {
592
571
  token: String,
593
572
  promise: Promise,
594
573
  ->
595
-
596
- ensureConnection(promise) { billingClient ->
574
+ val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
597
575
  val acknowledgePurchaseParams =
598
576
  AcknowledgePurchaseParams
599
577
  .newBuilder()
@@ -617,7 +595,6 @@ class ExpoIapModule :
617
595
  map["message"] = errorData.message
618
596
  promise.resolve(map)
619
597
  }
620
- }
621
598
  }
622
599
 
623
600
  AsyncFunction("consumeProductAndroid") {
@@ -627,25 +604,24 @@ class ExpoIapModule :
627
604
 
628
605
  val params = ConsumeParams.newBuilder().setPurchaseToken(token).build()
629
606
 
630
- ensureConnection(promise) { billingClient ->
631
- billingClient.consumeAsync(params) { billingResult: BillingResult, purchaseToken: String? ->
632
- if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
633
- PlayUtils.rejectPromiseWithBillingError(
634
- promise,
635
- billingResult.responseCode,
636
- )
637
- return@consumeAsync
638
- }
639
-
640
- val map = mutableMapOf<String, Any?>()
641
- map["responseCode"] = billingResult.responseCode
642
- map["debugMessage"] = billingResult.debugMessage
643
- val errorData = PlayUtils.getBillingResponseData(billingResult.responseCode)
644
- map["code"] = errorData.code
645
- map["message"] = errorData.message
646
- map["purchaseTokenAndroid"] = purchaseToken
647
- promise.resolve(map)
607
+ val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
608
+ billingClient.consumeAsync(params) { billingResult: BillingResult, purchaseToken: String? ->
609
+ if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
610
+ PlayUtils.rejectPromiseWithBillingError(
611
+ promise,
612
+ billingResult.responseCode,
613
+ )
614
+ return@consumeAsync
648
615
  }
616
+
617
+ val map = mutableMapOf<String, Any?>()
618
+ map["responseCode"] = billingResult.responseCode
619
+ map["debugMessage"] = billingResult.debugMessage
620
+ val errorData = PlayUtils.getBillingResponseData(billingResult.responseCode)
621
+ map["code"] = errorData.code
622
+ map["message"] = errorData.message
623
+ map["purchaseTokenAndroid"] = purchaseToken
624
+ promise.resolve(map)
649
625
  }
650
626
  }
651
627
 
@@ -653,19 +629,18 @@ class ExpoIapModule :
653
629
  AsyncFunction("getStorefront") {
654
630
  promise: Promise,
655
631
  ->
656
- ensureConnection(promise) { billingClient ->
657
- billingClient.getBillingConfigAsync(
658
- GetBillingConfigParams.newBuilder().build(),
659
- BillingConfigResponseListener { result: BillingResult, config: BillingConfig? ->
660
- if (result.responseCode == BillingClient.BillingResponseCode.OK) {
661
- promise.safeResolve(config?.countryCode.orEmpty())
662
- } else {
663
- val debugMessage = result.debugMessage.orEmpty()
664
- promise.safeReject(result.responseCode.toString(), debugMessage)
665
- }
666
- },
667
- )
668
- }
632
+ val billingClient = getBillingClientOrReject(promise) ?: return@AsyncFunction
633
+ billingClient.getBillingConfigAsync(
634
+ GetBillingConfigParams.newBuilder().build(),
635
+ BillingConfigResponseListener { result: BillingResult, config: BillingConfig? ->
636
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
637
+ promise.safeResolve(config?.countryCode.orEmpty())
638
+ } else {
639
+ val debugMessage = result.debugMessage.orEmpty()
640
+ promise.safeReject(result.responseCode.toString(), debugMessage)
641
+ }
642
+ },
643
+ )
669
644
  }
670
645
  }
671
646
 
@@ -684,18 +659,25 @@ class ExpoIapModule :
684
659
  return true
685
660
  }
686
661
 
687
- private fun ensureConnection(
688
- promise: Promise,
689
- callback: (billingClient: BillingClient) -> Unit,
690
- ) {
691
- // With auto-reconnection enabled, we only need to check if billing client exists
692
- // The service will automatically reconnect when needed
693
- if (billingClientCache != null) {
694
- callback(billingClientCache!!)
695
- return
662
+ private fun getBillingClientOrReject(promise: Promise): BillingClient? {
663
+ val client = billingClientCache
664
+ if (client == null) {
665
+ promise.reject(
666
+ IapErrorCode.E_INIT_CONNECTION,
667
+ "Connection not initialized. Call initConnection() first.",
668
+ null,
669
+ )
670
+ return null
696
671
  }
697
-
698
- initBillingClient(promise, callback)
672
+ if (!client.isReady) {
673
+ promise.reject(
674
+ IapErrorCode.E_INIT_CONNECTION,
675
+ "BillingClient not ready. Wait for initConnection() to complete.",
676
+ null,
677
+ )
678
+ return null
679
+ }
680
+ return client
699
681
  }
700
682
 
701
683
  private fun initBillingClient(
@@ -22,6 +22,8 @@ struct OpenIapEvent {
22
22
  @available(iOS 15.0, tvOS 15.0, *)
23
23
  @MainActor
24
24
  public class ExpoIapModule: Module {
25
+ // Connection state for local validation parity with RN module
26
+ private var isInitialized: Bool = false
25
27
  // Subscriptions for OpenIapModule event listeners
26
28
  private var purchaseUpdatedSub: Subscription?
27
29
  private var purchaseErrorSub: Subscription?
@@ -65,6 +67,8 @@ public class ExpoIapModule: Module {
65
67
  AsyncFunction("initConnection") { () async throws -> Bool in
66
68
  logDebug("initConnection called")
67
69
  let isConnected = try await OpenIapModule.shared.initConnection()
70
+ // Track initialization locally for ensureConnection()
71
+ await MainActor.run { self.isInitialized = isConnected }
68
72
  logDebug("Connection initialized: \(isConnected)")
69
73
  return isConnected
70
74
  }
@@ -74,12 +78,14 @@ public class ExpoIapModule: Module {
74
78
  let _ = try await OpenIapModule.shared.endConnection()
75
79
 
76
80
  logDebug("Connection ended")
81
+ await MainActor.run { self.isInitialized = false }
77
82
  return true
78
83
  }
79
84
 
80
85
  // MARK: - Product Management
81
86
 
82
87
  AsyncFunction("fetchProducts") { (params: [String: Any]) async throws -> [[String: Any?]] in
88
+ try await ensureConnection()
83
89
  logDebug("fetchProducts raw params: \(params)")
84
90
 
85
91
  // Handle both object format {skus: [...], type: "..."} and array format
@@ -148,6 +154,7 @@ public class ExpoIapModule: Module {
148
154
  guard let sku = params["sku"] as? String, !sku.isEmpty else {
149
155
  throw OpenIapError.make(code: OpenIapError.E_PURCHASE_ERROR, message: "Missing required 'sku'")
150
156
  }
157
+ try await ensureConnection()
151
158
 
152
159
  // Optional fields
153
160
  let andFinish = (params["andDangerouslyFinishTransactionAutomatically"] as? Bool) ?? false
@@ -205,6 +212,7 @@ public class ExpoIapModule: Module {
205
212
  }
206
213
 
207
214
  AsyncFunction("finishTransaction") { (transactionId: String) async throws -> Bool in
215
+ try await ensureConnection()
208
216
  logDebug("finishTransaction called with id: \(transactionId)")
209
217
  let result = try await OpenIapModule.shared.finishTransaction(transactionIdentifier: transactionId)
210
218
  return result
@@ -213,6 +221,7 @@ public class ExpoIapModule: Module {
213
221
  // MARK: - Purchase History
214
222
 
215
223
  AsyncFunction("getAvailablePurchases") { (options: [String: Any?]?) async throws -> [[String: Any?]] in
224
+ try await ensureConnection()
216
225
  logDebug("getAvailablePurchases called")
217
226
 
218
227
  // Build options and get purchases directly from OpenIapModule
@@ -228,6 +237,7 @@ public class ExpoIapModule: Module {
228
237
 
229
238
  // Legacy function for backward compatibility
230
239
  AsyncFunction("getAvailableItems") { (alsoPublishToEventListener: Bool, onlyIncludeActiveItems: Bool) async throws -> [[String: Any?]] in
240
+ try await ensureConnection()
231
241
  logDebug("getAvailableItems called (legacy)")
232
242
 
233
243
  let purchaseOptions = OpenIapGetAvailablePurchasesProps(
@@ -239,6 +249,7 @@ public class ExpoIapModule: Module {
239
249
  }
240
250
 
241
251
  AsyncFunction("getPendingTransactionsIOS") { () async throws -> [[String: Any?]] in
252
+ try await ensureConnection()
242
253
  logDebug("getPendingTransactionsIOS called")
243
254
 
244
255
  let pendingTransactions = try await OpenIapModule.shared.getPendingTransactionsIOS()
@@ -246,6 +257,7 @@ public class ExpoIapModule: Module {
246
257
  }
247
258
 
248
259
  AsyncFunction("clearTransactionIOS") { () async throws -> Bool in
260
+ try await ensureConnection()
249
261
  logDebug("clearTransactionIOS called")
250
262
  try await OpenIapModule.shared.clearTransactionIOS()
251
263
  return true
@@ -254,17 +266,20 @@ public class ExpoIapModule: Module {
254
266
  // MARK: - Receipt & Validation
255
267
 
256
268
  AsyncFunction("getReceiptIOS") { () async throws -> String in
269
+ try await ensureConnection()
257
270
  logDebug("getReceiptIOS called")
258
271
  return try await OpenIapModule.shared.getReceiptDataIOS() ?? ""
259
272
  }
260
273
 
261
274
  AsyncFunction("requestReceiptRefreshIOS") { () async throws -> String in
275
+ try await ensureConnection()
262
276
  logDebug("requestReceiptRefreshIOS called")
263
277
  // Receipt refresh is handled automatically by StoreKit 2
264
278
  return try await OpenIapModule.shared.getReceiptDataIOS() ?? ""
265
279
  }
266
280
 
267
281
  AsyncFunction("validateReceiptIOS") { (sku: String) async throws -> [String: Any?] in
282
+ try await ensureConnection()
268
283
  logDebug("validateReceiptIOS called for sku: \(sku)")
269
284
  do {
270
285
  // Use OpenIapReceiptValidationProps to keep naming parity with OpenIAP
@@ -286,12 +301,14 @@ public class ExpoIapModule: Module {
286
301
  // MARK: - iOS Specific Features
287
302
 
288
303
  AsyncFunction("presentCodeRedemptionSheetIOS") { () async throws -> Bool in
304
+ try await ensureConnection()
289
305
  logDebug("presentCodeRedemptionSheetIOS called")
290
306
  let _ = try await OpenIapModule.shared.presentCodeRedemptionSheetIOS()
291
307
  return true
292
308
  }
293
309
 
294
310
  AsyncFunction("showManageSubscriptionsIOS") { () async throws -> Bool in
311
+ try await ensureConnection()
295
312
  logDebug("showManageSubscriptionsIOS called")
296
313
  let _ = try await OpenIapModule.shared.showManageSubscriptionsIOS()
297
314
  return true
@@ -310,11 +327,13 @@ public class ExpoIapModule: Module {
310
327
  }
311
328
 
312
329
  AsyncFunction("beginRefundRequestIOS") { (sku: String) async throws -> String? in
330
+ try await ensureConnection()
313
331
  logDebug("beginRefundRequestIOS called for sku: \(sku)")
314
332
  return try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku)
315
333
  }
316
334
 
317
335
  AsyncFunction("getPromotedProductIOS") { () async throws -> [String: Any?]? in
336
+ try await ensureConnection()
318
337
  logDebug("getPromotedProductIOS called")
319
338
 
320
339
  if let promoted = try await OpenIapModule.shared.getPromotedProductIOS() {
@@ -327,11 +346,13 @@ public class ExpoIapModule: Module {
327
346
  return nil
328
347
  }
329
348
  AsyncFunction("getStorefrontIOS") { () async throws -> String in
349
+ try await ensureConnection()
330
350
  logDebug("getStorefrontIOS called")
331
351
  return try await OpenIapModule.shared.getStorefrontIOS()
332
352
  }
333
353
 
334
354
  AsyncFunction("syncIOS") { () async throws -> Bool in
355
+ try await ensureConnection()
335
356
  logDebug("syncIOS called")
336
357
  return try await OpenIapModule.shared.syncIOS()
337
358
  }
@@ -339,21 +360,25 @@ public class ExpoIapModule: Module {
339
360
  // MARK: - Additional iOS Methods
340
361
 
341
362
  AsyncFunction("isTransactionVerifiedIOS") { (sku: String) async throws -> Bool in
363
+ try await ensureConnection()
342
364
  logDebug("isTransactionVerifiedIOS called for sku: \(sku)")
343
365
  return await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku)
344
366
  }
345
367
 
346
368
  AsyncFunction("getTransactionJwsIOS") { (sku: String) async throws -> String? in
369
+ try await ensureConnection()
347
370
  logDebug("getTransactionJwsIOS called for sku: \(sku)")
348
371
  return try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku)
349
372
  }
350
373
 
351
374
  AsyncFunction("isEligibleForIntroOfferIOS") { (groupID: String) async throws -> Bool in
375
+ try await ensureConnection()
352
376
  logDebug("isEligibleForIntroOfferIOS called for groupID: \(groupID)")
353
377
  return await OpenIapModule.shared.isEligibleForIntroOfferIOS(groupID: groupID)
354
378
  }
355
379
 
356
380
  AsyncFunction("subscriptionStatusIOS") { (sku: String) async throws -> [[String: Any?]]? in
381
+ try await ensureConnection()
357
382
  logDebug("subscriptionStatusIOS called for sku: \(sku)")
358
383
 
359
384
  if let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku) {
@@ -365,27 +390,9 @@ public class ExpoIapModule: Module {
365
390
  ]
366
391
 
367
392
  if let info = status.renewalInfo {
368
- // Convert autoRenewStatus to a proper boolean for willAutoRenew
369
- let willAutoRenew: Bool = {
370
- // Try boolean first
371
- if let b = info.autoRenewStatus as? Bool { return b }
372
- // Fallback to string normalization
373
- let normalized = String(describing: info.autoRenewStatus).lowercased()
374
- let truthy = Set([
375
- "willrenew",
376
- "will_autorenew",
377
- "will-auto-renew",
378
- "auto_renew_on",
379
- "true",
380
- "1",
381
- "on",
382
- "yes",
383
- ])
384
- return truthy.contains(normalized)
385
- }()
386
-
393
+ // autoRenewStatus is a Bool from OpenIAP types
387
394
  let renewalInfo: [String: Any?] = [
388
- "willAutoRenew": willAutoRenew,
395
+ "willAutoRenew": info.autoRenewStatus,
389
396
  "autoRenewPreference": info.autoRenewPreference
390
397
  ]
391
398
  dict["renewalInfo"] = renewalInfo
@@ -398,6 +405,7 @@ public class ExpoIapModule: Module {
398
405
  }
399
406
 
400
407
  AsyncFunction("currentEntitlementIOS") { (sku: String) async throws -> [String: Any?]? in
408
+ try await ensureConnection()
401
409
  logDebug("currentEntitlementIOS called for sku: \(sku)")
402
410
  do {
403
411
  if let entitlement = try await OpenIapModule.shared.currentEntitlementIOS(sku: sku) {
@@ -410,6 +418,7 @@ public class ExpoIapModule: Module {
410
418
  }
411
419
 
412
420
  AsyncFunction("latestTransactionIOS") { (sku: String) async throws -> [String: Any?]? in
421
+ try await ensureConnection()
413
422
  logDebug("latestTransactionIOS called for sku: \(sku)")
414
423
  do {
415
424
  if let transaction = try await OpenIapModule.shared.latestTransactionIOS(sku: sku) {
@@ -468,4 +477,15 @@ public class ExpoIapModule: Module {
468
477
  _ = try? await OpenIapModule.shared.endConnection()
469
478
  }
470
479
 
480
+ // MARK: - Private Helper Methods
481
+
482
+ private func ensureConnection() throws {
483
+ guard isInitialized else {
484
+ throw OpenIapError.make(
485
+ code: OpenIapError.E_INIT_CONNECTION,
486
+ message: "Connection not initialized. Call initConnection() first."
487
+ )
488
+ }
489
+ }
490
+
471
491
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-iap",
3
- "version": "2.9.6",
3
+ "version": "2.9.7",
4
4
  "description": "In App Purchase module in Expo",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",