Skip to content

Commit 34752a4

Browse files
authored
Fix Android IAP Crash & Transaction Flow (#24)
This PR fixes an Android IAP crash and improves transaction handling: 1. **Safe Event Handling**: Wrapped `sendEvent` in `try-catch` to prevent `NullPointerException` crashes in `ExpoIapModule` (Commit: `fix: safely handle events in android mod`). 2. **Transaction Fix**: Enhanced `finishTransaction` in `useIap` for stable async flow (Commit: `fix: finishTransaction in useIap`).
1 parent 63801bd commit 34752a4

File tree

5 files changed

+138
-48
lines changed

5 files changed

+138
-48
lines changed

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

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class ExpoIapModule :
3232
const val E_INIT_CONNECTION = "E_INIT_CONNECTION"
3333
const val E_QUERY_PRODUCT = "E_QUERY_PRODUCT"
3434
const val EMPTY_SKU_LIST = "EMPTY_SKU_LIST"
35+
private const val PROMISE_BUY_ITEM = "PROMISE_BUY_ITEM"
3536
}
3637

3738
object IapEvent {
@@ -62,7 +63,12 @@ class ExpoIapModule :
6263
val errorData = PlayUtils.getBillingResponseData(responseCode)
6364
error["code"] = errorData.code
6465
error["message"] = errorData.message
65-
sendEvent(IapEvent.PURCHASE_ERROR, error.toMap())
66+
try {
67+
sendEvent(IapEvent.PURCHASE_ERROR, error.toMap())
68+
} catch (e: Exception) {
69+
Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
70+
}
71+
PromiseUtils.rejectPromisesForKey(PROMISE_BUY_ITEM, errorData.code, errorData.message, null)
6672
return
6773
}
6874

@@ -90,8 +96,13 @@ class ExpoIapModule :
9096
item["obfuscatedProfileIdAndroid"] = accountIdentifiers.obfuscatedProfileId
9197
}
9298
promiseItems.add(item.toMap())
93-
sendEvent(IapEvent.PURCHASE_UPDATED, item.toMap())
99+
try {
100+
sendEvent(IapEvent.PURCHASE_UPDATED, item.toMap())
101+
} catch (e: Exception) {
102+
Log.e(TAG, "Failed to send PURCHASE_UPDATED event: ${e.message}")
103+
}
94104
}
105+
PromiseUtils.resolvePromisesForKey(PROMISE_BUY_ITEM, promiseItems)
95106
} else {
96107
val result =
97108
mutableMapOf<String, Any?>(
@@ -100,7 +111,12 @@ class ExpoIapModule :
100111
"extraMessage" to
101112
"The purchases are null. This is a normal behavior if you have requested DEFERRED proration. If not please report an issue.",
102113
)
103-
sendEvent(IapEvent.PURCHASE_UPDATED, result.toMap())
114+
try {
115+
sendEvent(IapEvent.PURCHASE_UPDATED, result.toMap())
116+
} catch (e: Exception) {
117+
Log.e(TAG, "Failed to send PURCHASE_UPDATED event: ${e.message}")
118+
}
119+
PromiseUtils.resolvePromisesForKey(PROMISE_BUY_ITEM, result)
104120
}
105121
}
106122

@@ -301,22 +317,29 @@ class ExpoIapModule :
301317
val isOfferPersonalized = params["isOfferPersonalized"] as? Boolean ?: false
302318

303319
if (currentActivity == null) {
304-
throw Exception("getCurrentActivity returned null")
320+
promise.reject("E_UNKNOWN", "getCurrentActivity returned null", null)
321+
return@AsyncFunction
305322
}
306323

307324
ensureConnection(promise) { billingClient ->
325+
PromiseUtils.addPromiseForKey(PROMISE_BUY_ITEM, promise)
326+
308327
if (type == BillingClient.ProductType.SUBS && skuArr.size != offerTokenArr.size) {
309-
val debugMessage =
310-
"The number of skus (${skuArr.size}) must match: the number of offerTokens (${offerTokenArr.size}) for Subscriptions"
311-
sendEvent(
312-
IapEvent.PURCHASE_ERROR,
313-
mapOf(
314-
"debugMessage" to debugMessage,
315-
"code" to "E_SKU_OFFER_MISMATCH",
316-
"message" to debugMessage,
317-
),
318-
)
319-
throw Exception(debugMessage)
328+
val debugMessage = "The number of skus (${skuArr.size}) must match: the number of offerTokens (${offerTokenArr.size}) for Subscriptions"
329+
try {
330+
sendEvent(
331+
IapEvent.PURCHASE_ERROR,
332+
mapOf(
333+
"debugMessage" to debugMessage,
334+
"code" to "E_SKU_OFFER_MISMATCH",
335+
"message" to debugMessage,
336+
)
337+
)
338+
} catch (e: Exception) {
339+
Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
340+
}
341+
promise.reject("E_SKU_OFFER_MISMATCH", debugMessage, null)
342+
return@ensureConnection
320343
}
321344

322345
val productParamsList =
@@ -325,16 +348,21 @@ class ExpoIapModule :
325348
if (selectedSku == null) {
326349
val debugMessage =
327350
"The sku was not found. Please fetch products first by calling getItems"
328-
sendEvent(
329-
IapEvent.PURCHASE_ERROR,
330-
mapOf(
331-
"debugMessage" to debugMessage,
332-
"code" to "E_SKU_NOT_FOUND",
333-
"message" to debugMessage,
334-
"productId" to sku,
335-
),
336-
)
337-
throw Exception(debugMessage)
351+
try {
352+
sendEvent(
353+
IapEvent.PURCHASE_ERROR,
354+
mapOf(
355+
"debugMessage" to debugMessage,
356+
"code" to "E_SKU_NOT_FOUND",
357+
"message" to debugMessage,
358+
"productId" to sku,
359+
),
360+
)
361+
} catch (e: Exception) {
362+
Log.e(TAG, "Failed to send PURCHASE_ERROR event: ${e.message}")
363+
}
364+
promise.reject("E_SKU_NOT_FOUND", debugMessage, null)
365+
return@ensureConnection
338366
}
339367

340368
val productDetailParams =
@@ -378,7 +406,6 @@ class ExpoIapModule :
378406
}
379407
subscriptionUpdateParams.setSubscriptionReplacementMode(mode)
380408
}
381-
382409
builder.setSubscriptionUpdateParams(subscriptionUpdateParams.build())
383410
}
384411

@@ -389,14 +416,10 @@ class ExpoIapModule :
389416
val billingResult = billingClient.launchBillingFlow(currentActivity, flowParams)
390417

391418
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
392-
promise.reject(
393-
"Billing Error",
394-
billingResult.debugMessage,
395-
null,
396-
)
419+
val errorData = PlayUtils.getBillingResponseData(billingResult.responseCode)
420+
promise.reject(errorData.code, billingResult.debugMessage, null)
421+
return@ensureConnection
397422
}
398-
399-
promise.resolve(true)
400423
}
401424
}
402425

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,76 @@ object PromiseUtils {
2929
const val E_DEVELOPER_ERROR = "E_DEVELOPER_ERROR"
3030
const val E_BILLING_RESPONSE_JSON_PARSE_ERROR = "E_BILLING_RESPONSE_JSON_PARSE_ERROR"
3131
const val E_CONNECTION_CLOSED = "E_CONNECTION_CLOSED"
32+
33+
fun addPromiseForKey(
34+
key: String,
35+
promise: Promise,
36+
) {
37+
promises.getOrPut(key) { mutableListOf() }.add(promise)
38+
}
39+
40+
fun resolvePromisesForKey(
41+
key: String,
42+
value: Any?,
43+
) {
44+
promises[key]?.forEach { promise ->
45+
promise.safeResolve(value)
46+
}
47+
promises.remove(key)
48+
}
49+
50+
fun rejectAllPendingPromises() {
51+
promises.flatMap { it.value }.forEach { promise ->
52+
promise.safeReject(E_CONNECTION_CLOSED, "Connection has been closed", null)
53+
}
54+
promises.clear()
55+
}
56+
57+
fun rejectPromisesForKey(
58+
key: String,
59+
code: String,
60+
message: String?,
61+
err: Exception?,
62+
) {
63+
promises[key]?.forEach { promise ->
64+
promise.safeReject(code, message, err)
65+
}
66+
promises.remove(key)
67+
}
68+
}
69+
70+
const val TAG = "IapPromises"
71+
72+
fun Promise.safeResolve(value: Any?) {
73+
try {
74+
this.resolve(value)
75+
} catch (e: RuntimeException) {
76+
Log.d(TAG, "Already consumed ${e.message}")
77+
}
78+
}
79+
80+
fun Promise.safeReject(message: String) = this.safeReject(message, null, null)
81+
82+
fun Promise.safeReject(
83+
code: String,
84+
message: String?,
85+
) = this.safeReject(code, message, null)
86+
87+
fun Promise.safeReject(
88+
code: String,
89+
throwable: Throwable?,
90+
) = this.safeReject(code, null, throwable)
91+
92+
fun Promise.safeReject(
93+
code: String,
94+
message: String?,
95+
throwable: Throwable?,
96+
) {
97+
try {
98+
this.reject(code, message, throwable)
99+
} catch (e: RuntimeException) {
100+
Log.d(TAG, "Already consumed ${e.message}")
101+
}
32102
}
33103

34104
object PlayUtils {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "expo-iap",
3-
"version": "2.2.4",
3+
"version": "2.2.5",
44
"description": "In App Purchase module in Expo",
55
"main": "build/index.js",
66
"types": "build/index.d.ts",

src/index.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from './ExpoIap.types';
1616
import ExpoIapModule from './ExpoIapModule';
1717
import {
18+
ProductPurchaseAndroid,
1819
RequestPurchaseAndroidProps,
1920
RequestSubscriptionAndroidProps,
2021
} from './types/ExpoIapAndroid.types';
@@ -323,20 +324,20 @@ export const finishTransaction = ({
323324
return Promise.resolve(true);
324325
},
325326
android: async () => {
326-
// Check if the purchase is from Android by checking the platform property
327-
if (
328-
purchase.platform !== 'android' ||
329-
!('purchaseTokenAndroid' in purchase)
330-
) {
327+
const androidPurchase = purchase as ProductPurchaseAndroid;
328+
329+
if (!('purchaseTokenAndroid' in androidPurchase)) {
331330
return Promise.reject(
332-
new Error('purchaseTokenAndroid is required to finish transaction'),
331+
new Error('purchaseToken is required to finish transaction'),
333332
);
334333
}
335334
if (isConsumable) {
336-
return ExpoIapModule.consumeProduct(purchase.purchaseTokenAndroid);
335+
return ExpoIapModule.consumeProduct(
336+
androidPurchase.purchaseTokenAndroid,
337+
);
337338
} else {
338339
return ExpoIapModule.acknowledgePurchase(
339-
purchase.purchaseTokenAndroid,
340+
androidPurchase.purchaseTokenAndroid,
340341
);
341342
}
342343
},

src/useIap.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getProducts,
88
getAvailablePurchases,
99
getPurchaseHistory,
10+
finishTransaction as finishTransactionInternal,
1011
getSubscriptions,
1112
} from './';
1213
import {useCallback, useEffect, useState, useRef} from 'react';
@@ -35,11 +36,9 @@ type IAP_STATUS = {
3536
finishTransaction: ({
3637
purchase,
3738
isConsumable,
38-
developerPayloadAndroid,
3939
}: {
4040
purchase: Purchase;
4141
isConsumable?: boolean;
42-
developerPayloadAndroid?: string;
4342
}) => Promise<string | boolean | PurchaseResult | void>;
4443
getAvailablePurchases: () => Promise<void>;
4544
getPurchaseHistories: () => Promise<void>;
@@ -94,17 +93,14 @@ export function useIAP(): IAP_STATUS {
9493
async ({
9594
purchase,
9695
isConsumable,
97-
developerPayloadAndroid,
9896
}: {
9997
purchase: ProductPurchase;
10098
isConsumable?: boolean;
101-
developerPayloadAndroid?: string;
10299
}): Promise<string | boolean | PurchaseResult | void> => {
103100
try {
104-
return await finishTransaction({
101+
return await finishTransactionInternal({
105102
purchase,
106103
isConsumable,
107-
developerPayloadAndroid,
108104
});
109105
} catch (err) {
110106
throw err;

0 commit comments

Comments
 (0)