From c4ecde361f8be7d81e21bd5fdf6b84a4f206b554 Mon Sep 17 00:00:00 2001 From: amauryliet Date: Wed, 26 Aug 2020 15:17:51 +0200 Subject: [PATCH 1/6] fix: When possible, properly check that module is defined (instead of throwing non-catchable promise --- index.ts | 54 +++++++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/index.ts b/index.ts index dc0ebbb65..8823092d4 100644 --- a/index.ts +++ b/index.ts @@ -208,7 +208,7 @@ export const consumeAllItemsAndroid = (): Promise => Platform.select({ ios: async () => Promise.resolve(), android: async () => { - checkNativeAndroidAvailable(); + await checkNativeAndroidAvailable(); return RNIapModule.refreshItems(); }, })(); @@ -250,7 +250,7 @@ export const getSubscriptions = (skus: string[]): Promise => ); }, android: async () => { - checkNativeAndroidAvailable(); + await checkNativeAndroidAvailable(); return RNIapModule.getItemsByType(ANDROID_ITEM_TYPE_SUBSCRIPTION, skus); }, })(); @@ -264,11 +264,11 @@ InAppPurchase | SubscriptionPurchase )[]> => Platform.select({ ios: async () => { - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.getAvailableItems(); }, android: async () => { - checkNativeAndroidAvailable(); + await checkNativeAndroidAvailable(); const products = await RNIapModule.getPurchaseHistoryByType( ANDROID_ITEM_TYPE_IAP, ); @@ -288,11 +288,11 @@ InAppPurchase | SubscriptionPurchase )[]> => Platform.select({ ios: async () => { - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.getAvailableItems(); }, android: async () => { - checkNativeAndroidAvailable(); + await checkNativeAndroidAvailable(); const products = await RNIapModule.getAvailableItemsByType( ANDROID_ITEM_TYPE_IAP, ); @@ -328,14 +328,14 @@ export const requestPurchase = ( 'You are dangerously allowing react-native-iap to finish your transaction automatically. You should set andDangerouslyFinishTransactionAutomatically to false when calling requestPurchase and call finishTransaction manually when you have delivered the purchased goods to the user. It defaults to true to provide backwards compatibility. Will default to false in version 4.0.0.', ); } - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.buyProduct( sku, andDangerouslyFinishTransactionAutomaticallyIOS, ); }, android: async () => { - checkNativeAndroidAvailable(); + await checkNativeAndroidAvailable(); return RNIapModule.buyItemByType( ANDROID_ITEM_TYPE_IAP, sku, @@ -376,14 +376,14 @@ export const requestSubscription = ( 'You are dangerously allowing react-native-iap to finish your transaction automatically. You should set andDangerouslyFinishTransactionAutomatically to false when calling requestPurchase and call finishTransaction manually when you have delivered the purchased goods to the user. It defaults to true to provide backwards compatibility. Will default to false in version 4.0.0.', ); } - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.buyProduct( sku, andDangerouslyFinishTransactionAutomaticallyIOS, ); }, android: async () => { - checkNativeAndroidAvailable(); + await checkNativeAndroidAvailable(); if (!prorationModeAndroid) prorationModeAndroid = -1; return RNIapModule.buyItemByType( ANDROID_ITEM_TYPE_SUBSCRIPTION, @@ -407,7 +407,7 @@ export const requestPurchaseWithQuantityIOS = ( ): Promise => Platform.select({ ios: async () => { - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.buyProductWithQuantityIOS(sku, quantity); }, })(); @@ -423,7 +423,7 @@ export const requestPurchaseWithQuantityIOS = ( export const finishTransactionIOS = (transactionId: string): Promise => Platform.select({ ios: async () => { - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.finishTransaction(transactionId); }, })(); @@ -442,7 +442,7 @@ export const finishTransaction = ( ): Promise => { return Platform.select({ ios: async () => { - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.finishTransaction(purchase.transactionId); }, android: async () => { @@ -480,7 +480,7 @@ export const finishTransaction = ( export const clearTransactionIOS = (): Promise => { return Platform.select({ ios: async () => { - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.clearTransaction(); }, android: async () => Promise.resolve(), @@ -492,13 +492,13 @@ export const clearTransactionIOS = (): Promise => { * Remove all products which are validated by Apple server. * @returns {void} */ -export const clearProductsIOS = (): void => +export const clearProductsIOS = (): Promise => Platform.select({ - ios: () => { - checkNativeiOSAvailable(); + ios: async () => { + await checkNativeiOSAvailable(); return RNIapIos.clearProducts(); }, - android: async () => Promise.resolve, + android: async () => undefined, })(); /** @@ -513,7 +513,7 @@ export const acknowledgePurchaseAndroid = ( Platform.select({ ios: async () => Promise.resolve(), android: async () => { - checkNativeAndroidAvailable(); + await checkNativeAndroidAvailable(); return RNIapModule.acknowledgePurchase(token, developerPayload); }, })(); @@ -530,7 +530,7 @@ export const consumePurchaseAndroid = ( Platform.select({ ios: async () => Promise.resolve(), android: async () => { - checkNativeAndroidAvailable(); + await checkNativeAndroidAvailable(); return RNIapModule.consumeProduct(token, developerPayload); }, })(); @@ -543,7 +543,7 @@ export const consumePurchaseAndroid = ( export const getPromotedProductIOS = (): Promise => Platform.select({ ios: async () => { - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.promotedProduct(); }, android: async () => Promise.resolve(), @@ -557,7 +557,7 @@ export const getPromotedProductIOS = (): Promise => export const buyPromotedProductIOS = (): Promise => Platform.select({ ios: async () => { - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.buyPromotedProduct(); }, android: async () => Promise.resolve(), @@ -585,7 +585,7 @@ export const requestPurchaseWithOfferIOS = ( ): Promise => Platform.select({ ios: async () => { - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.buyProductWithOffer(sku, forUser, withOffer); }, android: async () => Promise.resolve(), @@ -700,9 +700,9 @@ export const purchaseErrorListener = ( * Get the current receipt base64 encoded in IOS. * @returns {Promise} */ -export const getReceiptIOS = (): Promise => { +export const getReceiptIOS = async (): Promise => { if (Platform.OS === 'ios') { - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.requestReceipt(); } }; @@ -711,9 +711,9 @@ export const getReceiptIOS = (): Promise => { * Get the pending purchases in IOS. * @returns {Promise} */ -export const getPendingPurchasesIOS = (): Promise => { +export const getPendingPurchasesIOS = async (): Promise => { if (Platform.OS === 'ios') { - checkNativeiOSAvailable(); + await checkNativeiOSAvailable(); return RNIapIos.getPendingTransactions(); } }; From df3e055ec8c2217e1bab638bfbaa7b3d8ec26eae Mon Sep 17 00:00:00 2001 From: amauryliet Date: Wed, 26 Aug 2020 16:02:59 +0200 Subject: [PATCH 2/6] fix: Avoid consuming all purchase blindly, but only pending ones If the purchase is really pending, nothing will happen (error). Otherwise, the Play Store cache will be force updated --- .../java/com/dooboolab/RNIap/RNIapModule.java | 85 ++++++++++++++----- index.ts | 18 ++++ 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/android/src/main/java/com/dooboolab/RNIap/RNIapModule.java b/android/src/main/java/com/dooboolab/RNIap/RNIapModule.java index 566fc0792..2b5e9d10d 100644 --- a/android/src/main/java/com/dooboolab/RNIap/RNIapModule.java +++ b/android/src/main/java/com/dooboolab/RNIap/RNIapModule.java @@ -20,6 +20,7 @@ import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -177,6 +178,35 @@ public void endConnection(final Promise promise) { } } + private void consumeItems(final List purchases, final Promise promise) { + consumeItems(purchases, promise, BillingClient.BillingResponseCode.OK); + } + + private void consumeItems(final List purchases, final Promise promise, final int expectedResponseCode) { + for (Purchase purchase : purchases) { + final ConsumeParams consumeParams = ConsumeParams.newBuilder() + .setPurchaseToken(purchase.getPurchaseToken()) + .setDeveloperPayload(purchase.getDeveloperPayload()) + .build(); + + final ConsumeResponseListener listener = new ConsumeResponseListener() { + @Override + public void onConsumeResponse(BillingResult billingResult, String outToken) { + if (billingResult.getResponseCode() != expectedResponseCode) { + DoobooUtils.getInstance().rejectPromiseWithBillingError(promise, billingResult.getResponseCode()); + return; + } + try { + promise.resolve(true); + } catch (ObjectAlreadyConsumedException oce) { + promise.reject(oce.getMessage()); + } + } + }; + billingClient.consumeAsync(consumeParams, listener); + } + } + @ReactMethod public void refreshItems(final Promise promise) { // Purchase.PurchasesResult purchasesResult = billingClient.queryPurchases(BillingClient.SkuType.INAPP); @@ -196,29 +226,42 @@ public void run() { return; } + consumeItems(purchases, promise); + } + }); + } + + @ReactMethod + public void flushFailedPurchasesCachedAsPending(final Promise promise) { + ensureConnection(promise, new Runnable() { + @Override + public void run() { + final WritableNativeArray array = new WritableNativeArray(); + Purchase.PurchasesResult result = billingClient.queryPurchases(BillingClient.SkuType.INAPP); + if (result == null) { + // No results for query + promise.resolve(false); + return; + } + final List purchases = result.getPurchasesList(); + if (purchases == null) { + // No purchases found + promise.resolve(false); + return; + } + final List pendingPurchases = Collections.EMPTY_LIST; for (Purchase purchase : purchases) { - final ConsumeParams consumeParams = ConsumeParams.newBuilder() - .setPurchaseToken(purchase.getPurchaseToken()) - .setDeveloperPayload(purchase.getDeveloperPayload()) - .build(); - - final ConsumeResponseListener listener = new ConsumeResponseListener() { - @Override - public void onConsumeResponse(BillingResult billingResult, String outToken) { - if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) { - DoobooUtils.getInstance().rejectPromiseWithBillingError(promise, billingResult.getResponseCode()); - return; - } - array.pushString(outToken); - try { - promise.resolve(true); - } catch (ObjectAlreadyConsumedException oce) { - promise.reject(oce.getMessage()); - } - } - }; - billingClient.consumeAsync(consumeParams, listener); + // we only want to try to consume PENDING items, in order to force cache-refresh for them + if (purchase.getPurchaseState() == Purchase.PurchaseState.PENDING) { + pendingPurchases.add(purchase); + } + } + if (pendingPurchases.size() == 0) { + promise.resolve(false); + return; } + + consumeItems(pendingPurchases, promise, BillingClient.BillingResponseCode.ITEM_NOT_OWNED); } }); } diff --git a/index.ts b/index.ts index 8823092d4..6d2d9380e 100644 --- a/index.ts +++ b/index.ts @@ -202,6 +202,10 @@ export const endConnectionAndroid = (): Promise => { /** * Consume all remaining tokens. Android only. + * This is considered dangerous as you should deliver the purchased feature BEFORE consuming it. + * If you used this method to refresh Play Store cache (of failed pending payment still marked as failed), + * prefer using flushFailedPurchasesCachedAsPendingAndroid + * @deprecated * @returns {Promise} */ export const consumeAllItemsAndroid = (): Promise => @@ -213,6 +217,19 @@ export const consumeAllItemsAndroid = (): Promise => }, })(); +/** + * Consume all 'ghost' purchases (that is, pending payment that already failed but is still marked as pending in Play Store cache). Android only. + * @returns {Promise} + */ +export const flushFailedPurchasesCachedAsPendingAndroid = (): Promise => + Platform.select({ + ios: async () => Promise.resolve(), + android: async () => { + await checkNativeAndroidAvailable(); + return RNIapModule.flushFailedPurchasesCachedAsPending(); + }, + })(); + /** * Get a list of products (consumable and non-consumable items, but not subscriptions) * @param {string[]} skus The item skus @@ -729,6 +746,7 @@ const iapUtils = { getAvailablePurchases, getPendingPurchasesIOS, consumeAllItemsAndroid, + flushFailedPurchasesCachedAsPendingAndroid, clearProductsIOS, clearTransactionIOS, acknowledgePurchaseAndroid, From 5395ebba02151b5fa7037fece053cf684de1c823 Mon Sep 17 00:00:00 2001 From: amauryliet Date: Wed, 26 Aug 2020 16:34:13 +0200 Subject: [PATCH 3/6] docs: Warn users of dangerous usage of consumeAllItemsAndroid --- index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 6d2d9380e..5ae1510da 100644 --- a/index.ts +++ b/index.ts @@ -208,14 +208,16 @@ export const endConnectionAndroid = (): Promise => { * @deprecated * @returns {Promise} */ -export const consumeAllItemsAndroid = (): Promise => - Platform.select({ +export const consumeAllItemsAndroid = (): Promise => { + console.warn('consumeAllItemsAndroid is deprecated and will be removed in the future. Please use flushFailedPurchasesCachedAsPendingAndroid instead'); + return Platform.select({ ios: async () => Promise.resolve(), android: async () => { await checkNativeAndroidAvailable(); return RNIapModule.refreshItems(); }, })(); +}; /** * Consume all 'ghost' purchases (that is, pending payment that already failed but is still marked as pending in Play Store cache). Android only. From 4f8f7a1c978e25ca850f58692754baf30cfbe48d Mon Sep 17 00:00:00 2001 From: amauryliet Date: Thu, 27 Aug 2020 17:38:54 +0200 Subject: [PATCH 4/6] docs: Explain consumeAllItemsAndroid and flushFailedPurchasesCachedAsPendingAndroid usage in readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0471cfdf8..3364908c5 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,8 @@ _*deprecated_
~~`buySubscription(sku: string)`~~
  • sku: subscription ID/ `getPendingPurchasesIOS()` | `Promise` | **IOS only**
    Gets all the transactions which are pending to be finished. `validateReceiptIos(body: Record, devMode: boolean)`
    • body: receiptBody
    • devMode: isTest
    | `Object\|boolean` | **iOS only**
    Validate receipt. `endConnection()` | `Promise` | End billing connection. -`consumeAllItemsAndroid()` | `Promise` | **Android only**
    Consume all items so they are able to buy again. +`consumeAllItemsAndroid()` | `Promise` | **Android only**
    Consume all items so they are able to buy again. ⚠️ Use in dev only (as you should deliver the purchased feature BEFORE consuming it) +`flushFailedPurchasesCachedAsPendingAndroid()` | `Promise` | **Android only**
    Consume all 'ghost' purchases (that is, pending payment that already failed but is still marked as pending in Play Store cache) `consumePurchaseAndroid(token: string, payload?: string)`
    • token: purchase token
    • payload: developerPayload
    | `void` | **Android only**
    Finish a purchase. All purchases should be finished once you have delivered the purchased items. E.g. by recording the purchase in your database or on your server. `acknowledgePurchaseAndroid(token: string, payload?: string)`
    • token: purchase token
    • payload: developerPayload
    | `Promise` | **Android only**
    Acknowledge a product. Like above for non-consumables. Use `finishTransaction` instead for both platforms since version 4.1.0 or later. `consumePurchaseAndroid(token: string, payload?: string)`
    • token: purchase token
    • payload: developerPayload
    | `Promise` | **Android only**
    Consume a product. Like above for consumables. Use `finishTransaction` instead for both platforms since version 4.1.0 or later. From deaffa8413f07ba472b67626ef223985df1c1c52 Mon Sep 17 00:00:00 2001 From: amauryliet Date: Thu, 27 Aug 2020 17:48:39 +0200 Subject: [PATCH 5/6] docs: Make sure to explicit recommended sdk usage (with initConnection & flushFailedPurchasesCachedA --- README.md | 76 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 3364908c5..9e9a2a5ea 100644 --- a/README.md +++ b/README.md @@ -348,42 +348,52 @@ class RootComponent extends Component<*> { purchaseErrorSubscription = null componentDidMount() { - this.purchaseUpdateSubscription = purchaseUpdatedListener((purchase: InAppPurchase | SubscriptionPurchase | ProductPurchase ) => { - console.log('purchaseUpdatedListener', purchase); - const receipt = purchase.transactionReceipt; - if (receipt) { - yourAPI.deliverOrDownloadFancyInAppPurchase(purchase.transactionReceipt) - .then( async (deliveryResult) => { - if (isSuccess(deliveryResult)) { - // Tell the store that you have delivered what has been paid for. - // Failure to do this will result in the purchase being refunded on Android and - // the purchase event will reappear on every relaunch of the app until you succeed - // in doing the below. It will also be impossible for the user to purchase consumables - // again until you do this. - if (Platform.OS === 'ios') { - await RNIap.finishTransactionIOS(purchase.transactionId); - } else if (Platform.OS === 'android') { - // If consumable (can be purchased again) - await RNIap.consumePurchaseAndroid(purchase.purchaseToken); - // If not consumable - await RNIap.acknowledgePurchaseAndroid(purchase.purchaseToken); - } - - // From react-native-iap@4.1.0 you can simplify above `method`. Try to wrap the statement with `try` and `catch` to also grab the `error` message. - // If consumable (can be purchased again) - await RNIap.finishTransaction(purchase, true); - // If not consumable - await RNIap.finishTransaction(purchase, false); - } else { - // Retry / conclude the purchase is fraudulent, etc... + Iap.initConnection().then(() => { + // we make sure that "ghost" pending payment are removed + // (ghost = failed pending payment that are still marked as pending in Google's native Vending module cache) + Iap.flushFailedPurchasesCachedAsPendingAndroid().catch(() => { + // exception can happen here if: + // - there are pending purchases that are still pending (we can't consume a pending purchase) + // in any case, you might not want to do anything special with the error + }).then(() => { + this.purchaseUpdateSubscription = purchaseUpdatedListener((purchase: InAppPurchase | SubscriptionPurchase | ProductPurchase ) => { + console.log('purchaseUpdatedListener', purchase); + const receipt = purchase.transactionReceipt; + if (receipt) { + yourAPI.deliverOrDownloadFancyInAppPurchase(purchase.transactionReceipt) + .then( async (deliveryResult) => { + if (isSuccess(deliveryResult)) { + // Tell the store that you have delivered what has been paid for. + // Failure to do this will result in the purchase being refunded on Android and + // the purchase event will reappear on every relaunch of the app until you succeed + // in doing the below. It will also be impossible for the user to purchase consumables + // again until you do this. + if (Platform.OS === 'ios') { + await RNIap.finishTransactionIOS(purchase.transactionId); + } else if (Platform.OS === 'android') { + // If consumable (can be purchased again) + await RNIap.consumePurchaseAndroid(purchase.purchaseToken); + // If not consumable + await RNIap.acknowledgePurchaseAndroid(purchase.purchaseToken); + } + + // From react-native-iap@4.1.0 you can simplify above `method`. Try to wrap the statement with `try` and `catch` to also grab the `error` message. + // If consumable (can be purchased again) + await RNIap.finishTransaction(purchase, true); + // If not consumable + await RNIap.finishTransaction(purchase, false); + } else { + // Retry / conclude the purchase is fraudulent, etc... + } + }); } }); - } - }); - this.purchaseErrorSubscription = purchaseErrorListener((error: PurchaseError) => { - console.warn('purchaseErrorListener', error); - }); + this.purchaseErrorSubscription = purchaseErrorListener((error: PurchaseError) => { + console.warn('purchaseErrorListener', error); + }); + }) + }) } componentWillUnmount() { From 56dd17a4f4adee2bc7702bff8b6ea1cef684cc3a Mon Sep 17 00:00:00 2001 From: amauryliet Date: Thu, 27 Aug 2020 18:21:01 +0200 Subject: [PATCH 6/6] docs: Update exemple app according to doc --- IapExample/App.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IapExample/App.js b/IapExample/App.js index 52d03e305..284848809 100644 --- a/IapExample/App.js +++ b/IapExample/App.js @@ -116,7 +116,7 @@ class Page extends Component { async componentDidMount(): void { try { const result = await RNIap.initConnection(); - await RNIap.consumeAllItemsAndroid(); + await RNIap.flushFailedPurchasesCachedAsPendingAndroid(); console.log('result', result); } catch (err) { console.warn(err.code, err.message);