diff --git a/examples/Utilities/InAppPurchasesDemo.h b/examples/Utilities/InAppPurchasesDemo.h index 8979b1832c..646eee816f 100644 --- a/examples/Utilities/InAppPurchasesDemo.h +++ b/examples/Utilities/InAppPurchasesDemo.h @@ -192,20 +192,23 @@ private: { purchaseInProgress = false; - auto idx = findVoiceIndexFromIdentifier (info.purchase.productId); - - if (isPositiveAndBelow (idx, voiceProducts.size())) + for (const auto productId : info.purchase.productIds) { - auto& voiceProduct = voiceProducts.getReference (idx); + auto idx = findVoiceIndexFromIdentifier (productId); - voiceProduct.isPurchased = success; - voiceProduct.purchaseInProgress = false; - } - else - { - // On failure Play Store will not tell us which purchase failed - for (auto& voiceProduct : voiceProducts) + if (isPositiveAndBelow (idx, voiceProducts.size())) + { + auto& voiceProduct = voiceProducts.getReference (idx); + + voiceProduct.isPurchased = success; voiceProduct.purchaseInProgress = false; + } + else + { + // On failure Play Store will not tell us which purchase failed + for (auto& voiceProduct : voiceProducts) + voiceProduct.purchaseInProgress = false; + } } guiUpdater.triggerAsyncUpdate(); @@ -217,13 +220,16 @@ private: { for (auto& info : infos) { - auto idx = findVoiceIndexFromIdentifier (info.purchase.productId); - - if (isPositiveAndBelow (idx, voiceProducts.size())) + for (const auto productId : info.purchase.productIds) { - auto& voiceProduct = voiceProducts.getReference (idx); + auto idx = findVoiceIndexFromIdentifier (productId); + + if (isPositiveAndBelow (idx, voiceProducts.size())) + { + auto& voiceProduct = voiceProducts.getReference (idx); - voiceProduct.isPurchased = true; + voiceProduct.isPurchased = true; + } } } diff --git a/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h b/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h index 00e2569d19..03282608fe 100644 --- a/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h +++ b/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h @@ -867,7 +867,7 @@ private: mo << " implementation files('libs/" << File (d).getFileName() << "')" << newLine; if (isInAppBillingEnabled()) - mo << " implementation 'com.android.billingclient:billing:2.1.0'" << newLine; + mo << " implementation 'com.android.billingclient:billing:5.0.0'" << newLine; if (areRemoteNotificationsEnabled()) { diff --git a/modules/juce_gui_basics/native/javaopt/app/com/rmsl/juce/JuceActivity.java b/modules/juce_gui_basics/native/javaopt/app/com/rmsl/juce/JuceActivity.java index f62ff4d6ae..ea41054211 100644 --- a/modules/juce_gui_basics/native/javaopt/app/com/rmsl/juce/JuceActivity.java +++ b/modules/juce_gui_basics/native/javaopt/app/com/rmsl/juce/JuceActivity.java @@ -33,6 +33,7 @@ public class JuceActivity extends Activity { //============================================================================== private native void appNewIntent (Intent intent); + private native void appOnResume(); @Override protected void onNewIntent (Intent intent) @@ -42,4 +43,12 @@ public class JuceActivity extends Activity appNewIntent (intent); } + + @Override + protected void onResume() + { + super.onResume(); + + appOnResume(); + } } diff --git a/modules/juce_gui_basics/native/juce_android_Windowing.cpp b/modules/juce_gui_basics/native/juce_android_Windowing.cpp index 88c8064395..b794429f66 100644 --- a/modules/juce_gui_basics/native/juce_android_Windowing.cpp +++ b/modules/juce_gui_basics/native/juce_android_Windowing.cpp @@ -126,12 +126,18 @@ static const uint8 javaComponentPeerView[] = //============================================================================== #if JUCE_PUSH_NOTIFICATIONS && JUCE_MODULE_AVAILABLE_juce_gui_extra - extern bool juce_handleNotificationIntent (void*); - extern void juce_firebaseDeviceNotificationsTokenRefreshed (void*); - extern void juce_firebaseRemoteNotificationReceived (void*); - extern void juce_firebaseRemoteMessagesDeleted(); - extern void juce_firebaseRemoteMessageSent(void*); - extern void juce_firebaseRemoteMessageSendError (void*, void*); + bool juce_handleNotificationIntent (void*); + void juce_firebaseDeviceNotificationsTokenRefreshed (void*); + void juce_firebaseRemoteNotificationReceived (void*); + void juce_firebaseRemoteMessagesDeleted(); + void juce_firebaseRemoteMessageSent(void*); + void juce_firebaseRemoteMessageSendError (void*, void*); +#endif + +#if JUCE_IN_APP_PURCHASES && JUCE_MODULE_AVAILABLE_juce_product_unlocking + void juce_handleOnResume(); +#else + static void juce_handleOnResume() {} #endif //============================================================================== @@ -2091,7 +2097,8 @@ const int KeyPress::rewindKey = extendedKeyModifier + 72; struct JuceActivityNewIntentListener { #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ - CALLBACK (appNewIntent, "appNewIntent", "(Landroid/content/Intent;)V") + CALLBACK (appNewIntent, "appNewIntent", "(Landroid/content/Intent;)V") \ + CALLBACK (appOnResume, "appOnResume", "()V") DECLARE_JNI_CLASS (JavaActivity, JUCE_PUSH_NOTIFICATIONS_ACTIVITY) #undef JNI_CLASS_MEMBERS @@ -2100,6 +2107,11 @@ const int KeyPress::rewindKey = extendedKeyModifier + 72; { juce_handleNotificationIntent (static_cast (intentData)); } + + static void JNICALL appOnResume (JNIEnv*, jobject) + { + juce_handleOnResume(); + } }; JuceActivityNewIntentListener::JavaActivity_Class JuceActivityNewIntentListener::JavaActivity; diff --git a/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.h b/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.h index 6044968b8e..7c2c7c3a7e 100644 --- a/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.h +++ b/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.h @@ -71,8 +71,8 @@ public: /** A unique order identifier for the transaction (generated by the store). */ String orderId; - /** A unique identifier of in-app product that was purchased. */ - String productId; + /** Unique identifiers of the products that were purchased. */ + StringArray productIds; /** This will be bundle ID on iOS and package name on Android, of the application for which this in-app product was purchased. */ @@ -147,7 +147,7 @@ public: virtual void productPurchaseFinished (const PurchaseInfo&, bool /*success*/, const String& /*statusDescription*/) {} /** Called when a list of all purchases is restored. This can be used to figure out to - which products a user is entitled to. + which products a user is entitled. NOTE: It is possible to receive this callback for the same purchase multiple times. If that happens, only the newest set of downloads and the newest orderId will be valid, the old ones should be not used anymore! @@ -210,7 +210,7 @@ public: const String& upgradeOrDowngradeFromSubscriptionWithProductIdentifier = {}, bool creditForUnusedSubscription = true); - /** Asynchronously asks about a list of products that a user has already bought. Upon completion, Listener::purchasesListReceived() + /** Asynchronously asks about a list of products that a user has already bought. Upon completion, Listener::purchasesListRestored() callback will be invoked. The user may be prompted to login first. @param includeDownloadInfo (iOS only) if true, then after restoration is successful, the downloads array passed to @@ -258,8 +258,8 @@ public: //============================================================================== #ifndef DOXYGEN [[deprecated ("On Android, it is no longer necessary to specify whether the product being purchased is a subscription " - "and only a single subscription can be upgraded/downgraded. Use the updated purchaseProduct method " - "which takes a single String argument.")]] + "and only a single subscription can be upgraded/downgraded. Use the updated purchaseProduct method " + "which takes a single String argument.")]] void purchaseProduct (const String& productIdentifier, bool isSubscription, const StringArray& upgradeOrDowngradeFromSubscriptionsWithProductIdentifiers = {}, diff --git a/modules/juce_product_unlocking/native/javaopt/app/com/rmsl/juce/JuceBillingClient.java b/modules/juce_product_unlocking/native/javaopt/app/com/rmsl/juce/JuceBillingClient.java index 656d783bba..4f9a982941 100644 --- a/modules/juce_product_unlocking/native/javaopt/app/com/rmsl/juce/JuceBillingClient.java +++ b/modules/juce_product_unlocking/native/javaopt/app/com/rmsl/juce/JuceBillingClient.java @@ -27,8 +27,9 @@ package com.rmsl.juce; import com.android.billingclient.api.*; -public class JuceBillingClient implements PurchasesUpdatedListener, BillingClientStateListener { - private native void skuDetailsQueryCallback(long host, java.util.List skuDetails); +public class JuceBillingClient implements PurchasesUpdatedListener, + BillingClientStateListener { + private native void productDetailsQueryCallback(long host, java.util.List productDetails); private native void purchasesListQueryCallback(long host, java.util.List purchases); private native void purchaseCompletedCallback(long host, Purchase purchase, int responseCode); private native void purchaseConsumedCallback(long host, String productIdentifier, int responseCode); @@ -57,36 +58,39 @@ public class JuceBillingClient implements PurchasesUpdatedListener, BillingClien == BillingClient.BillingResponseCode.OK; } - public void querySkuDetails(final String[] skusToQuery) { - executeOnBillingClientConnection(new Runnable() { - @Override - public void run() { - final java.util.List skuList = java.util.Arrays.asList(skusToQuery); + public QueryProductDetailsParams getProductListParams(final String[] productsToQuery, String type) { + java.util.ArrayList productList = new java.util.ArrayList<>(); - SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder() - .setSkusList(skuList) - .setType(BillingClient.SkuType.INAPP); + for (String product : productsToQuery) + productList.add(QueryProductDetailsParams.Product.newBuilder().setProductId(product).setProductType(type).build()); - billingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() { - @Override - public void onSkuDetailsResponse(BillingResult billingResult, final java.util.List inAppSkuDetails) { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder() - .setSkusList(skuList) - .setType(BillingClient.SkuType.SUBS); - - billingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() { - @Override - public void onSkuDetailsResponse(BillingResult billingResult, java.util.List subsSkuDetails) { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - subsSkuDetails.addAll(inAppSkuDetails); - skuDetailsQueryCallback(host, subsSkuDetails); - } - } - }); - } + return QueryProductDetailsParams.newBuilder().setProductList(productList).build(); + } + + public void queryProductDetailsImpl(final String[] productsToQuery, java.util.List productTypes, java.util.List details) { + if (productTypes == null || productTypes.isEmpty()) { + productDetailsQueryCallback(host, details); + } else { + billingClient.queryProductDetailsAsync(getProductListParams(productsToQuery, productTypes.get(0)), new ProductDetailsResponseListener() { + @Override + public void onProductDetailsResponse(BillingResult billingResult, java.util.List newDetails) { + if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { + details.addAll(newDetails); + queryProductDetailsImpl(productsToQuery, productTypes.subList(1, productTypes.size()), details); + } else { + queryProductDetailsImpl(productsToQuery, null, details); } - }); + } + }); + } + } + + public void queryProductDetails(final String[] productsToQuery) { + executeOnBillingClientConnection(new Runnable() { + @Override + public void run() { + String[] toCheck = {BillingClient.ProductType.INAPP, BillingClient.ProductType.SUBS}; + queryProductDetailsImpl(productsToQuery, java.util.Arrays.asList(toCheck), new java.util.ArrayList()); } }); } @@ -100,23 +104,30 @@ public class JuceBillingClient implements PurchasesUpdatedListener, BillingClien }); } + private void queryPurchasesImpl(java.util.List toCheck, java.util.ArrayList purchases) { + if (toCheck == null || toCheck.isEmpty()) { + purchasesListQueryCallback(host, purchases); + } else { + billingClient.queryPurchasesAsync(QueryPurchasesParams.newBuilder().setProductType(toCheck.get(0)).build(), new PurchasesResponseListener() { + @Override + public void onQueryPurchasesResponse(BillingResult billingResult, java.util.List list) { + if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { + purchases.addAll(list); + queryPurchasesImpl(toCheck.subList(1, toCheck.size()), purchases); + } else { + queryPurchasesImpl(null, purchases); + } + } + }); + } + } + public void queryPurchases() { executeOnBillingClientConnection(new Runnable() { @Override public void run() { - Purchase.PurchasesResult inAppPurchases = billingClient.queryPurchases(BillingClient.SkuType.INAPP); - Purchase.PurchasesResult subsPurchases = billingClient.queryPurchases(BillingClient.SkuType.SUBS); - - if (inAppPurchases.getResponseCode() == BillingClient.BillingResponseCode.OK - && subsPurchases.getResponseCode() == BillingClient.BillingResponseCode.OK) { - java.util.List purchaseList = inAppPurchases.getPurchasesList(); - purchaseList.addAll(subsPurchases.getPurchasesList()); - - purchasesListQueryCallback(host, purchaseList); - return; - } - - purchasesListQueryCallback(host, null); + String[] toCheck = {BillingClient.ProductType.INAPP, BillingClient.ProductType.SUBS}; + queryPurchasesImpl(java.util.Arrays.asList(toCheck), new java.util.ArrayList()); } }); } @@ -211,5 +222,5 @@ public class JuceBillingClient implements PurchasesUpdatedListener, BillingClien } private long host = 0; - private BillingClient billingClient; + private final BillingClient billingClient; } diff --git a/modules/juce_product_unlocking/native/juce_android_InAppPurchases.cpp b/modules/juce_product_unlocking/native/juce_android_InAppPurchases.cpp index d2c635918f..9312a3b2c6 100644 --- a/modules/juce_product_unlocking/native/juce_android_InAppPurchases.cpp +++ b/modules/juce_product_unlocking/native/juce_android_InAppPurchases.cpp @@ -27,13 +27,46 @@ namespace juce { #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ - METHOD (getSku, "getSku", "()Ljava/lang/String;") \ - METHOD (getTitle, "getTitle", "()Ljava/lang/String;") \ - METHOD (getDescription, "getDescription", "()Ljava/lang/String;") \ - METHOD (getPrice, "getPrice", "()Ljava/lang/String;") \ - METHOD (getPriceCurrencyCode, "getPriceCurrencyCode", "()Ljava/lang/String;") + METHOD (getProductId, "getProductId", "()Ljava/lang/String;") \ + METHOD (getTitle, "getTitle", "()Ljava/lang/String;") \ + METHOD (getDescription, "getDescription", "()Ljava/lang/String;") \ + METHOD (getOneTimePurchaseOfferDetails, "getOneTimePurchaseOfferDetails", "()Lcom/android/billingclient/api/ProductDetails$OneTimePurchaseOfferDetails;") \ + METHOD (getSubscriptionOfferDetails, "getSubscriptionOfferDetails", "()Ljava/util/List;") -DECLARE_JNI_CLASS (SkuDetails, "com/android/billingclient/api/SkuDetails") +DECLARE_JNI_CLASS (ProductDetails, "com/android/billingclient/api/ProductDetails") +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ + METHOD (getFormattedPrice, "getFormattedPrice", "()Ljava/lang/String;") \ + METHOD (getPriceCurrencyCode, "getPriceCurrencyCode", "()Ljava/lang/String;") + +DECLARE_JNI_CLASS (OneTimePurchaseOfferDetails, "com/android/billingclient/api/ProductDetails$OneTimePurchaseOfferDetails") +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ + METHOD (getFormattedPrice, "getFormattedPrice", "()Ljava/lang/String;") \ + METHOD (getPriceCurrencyCode, "getPriceCurrencyCode", "()Ljava/lang/String;") + +DECLARE_JNI_CLASS (PricingPhase, "com/android/billingclient/api/ProductDetails$PricingPhase") +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ + METHOD (getOfferToken, "getOfferToken", "()Ljava/lang/String;") \ + METHOD (getPricingPhases, "getPricingPhases", "()Lcom/android/billingclient/api/ProductDetails$PricingPhases;") + +DECLARE_JNI_CLASS (SubscriptionOfferDetails, "com/android/billingclient/api/ProductDetails$SubscriptionOfferDetails") +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ + METHOD (getPricingPhaseList, "getPricingPhaseList", "()Ljava/util/List;") + +DECLARE_JNI_CLASS (PricingPhases, "com/android/billingclient/api/ProductDetails$PricingPhases") +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ + STATICMETHOD (newBuilder, "newBuilder", "()Lcom/android/billingclient/api/BillingFlowParams$ProductDetailsParams$Builder;") + +DECLARE_JNI_CLASS (BillingFlowParamsProductDetailsParams, "com/android/billingclient/api/BillingFlowParams$ProductDetailsParams") #undef JNI_CLASS_MEMBERS #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ @@ -43,33 +76,81 @@ DECLARE_JNI_CLASS (BillingFlowParams, "com/android/billingclient/api/BillingFlow #undef JNI_CLASS_MEMBERS #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ - METHOD (build, "build", "()Lcom/android/billingclient/api/BillingFlowParams;") \ - METHOD (setOldSku, "setOldSku", "(Ljava/lang/String;Ljava/lang/String;)Lcom/android/billingclient/api/BillingFlowParams$Builder;") \ - METHOD (setReplaceSkusProrationMode, "setReplaceSkusProrationMode", "(I)Lcom/android/billingclient/api/BillingFlowParams$Builder;") \ - METHOD (setSkuDetails, "setSkuDetails", "(Lcom/android/billingclient/api/SkuDetails;)Lcom/android/billingclient/api/BillingFlowParams$Builder;") + STATICMETHOD (newBuilder, "newBuilder", "()Lcom/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams$Builder;") + +DECLARE_JNI_CLASS (BillingFlowParamsSubscriptionUpdateParams, "com/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams") +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ + METHOD (build, "build", "()Lcom/android/billingclient/api/BillingFlowParams;") \ + METHOD (setSubscriptionUpdateParams, "setSubscriptionUpdateParams", "(Lcom/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams;)Lcom/android/billingclient/api/BillingFlowParams$Builder;") \ + METHOD (setProductDetailsParamsList, "setProductDetailsParamsList", "(Ljava/util/List;)Lcom/android/billingclient/api/BillingFlowParams$Builder;") DECLARE_JNI_CLASS (BillingFlowParamsBuilder, "com/android/billingclient/api/BillingFlowParams$Builder") #undef JNI_CLASS_MEMBERS +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ + METHOD (build, "build", "()Lcom/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams;") \ + METHOD (setOldPurchaseToken, "setOldPurchaseToken", "(Ljava/lang/String;)Lcom/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams$Builder;") \ + METHOD (setReplaceProrationMode, "setReplaceProrationMode", "(I)Lcom/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams$Builder;") + +DECLARE_JNI_CLASS (BillingFlowParamsSubscriptionUpdateParamsBuilder, "com/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams$Builder") +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ + METHOD (build, "build", "()Lcom/android/billingclient/api/BillingFlowParams$ProductDetailsParams;") \ + METHOD (setOfferToken, "setOfferToken", "(Ljava/lang/String;)Lcom/android/billingclient/api/BillingFlowParams$ProductDetailsParams$Builder;") \ + METHOD (setProductDetails, "setProductDetails", "(Lcom/android/billingclient/api/ProductDetails;)Lcom/android/billingclient/api/BillingFlowParams$ProductDetailsParams$Builder;") + +DECLARE_JNI_CLASS (BillingFlowParamsProductDetailsParamsBuilder, "com/android/billingclient/api/BillingFlowParams$ProductDetailsParams$Builder") +#undef JNI_CLASS_MEMBERS + #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ METHOD (getOrderId, "getOrderId", "()Ljava/lang/String;") \ - METHOD (getSku, "getSku", "()Ljava/lang/String;") \ + METHOD (getPurchaseState, "getPurchaseState", "()I") \ + METHOD (getProducts, "getProducts", "()Ljava/util/List;") \ METHOD (getPackageName, "getPackageName", "()Ljava/lang/String;") \ - METHOD (getPurchaseTime, "getPurchaseTime", "()J") \ + METHOD (getPurchaseTime, "getPurchaseTime", "()J") \ METHOD (getPurchaseToken, "getPurchaseToken", "()Ljava/lang/String;") DECLARE_JNI_CLASS (AndroidPurchase, "com/android/billingclient/api/Purchase") #undef JNI_CLASS_MEMBERS +template +static void callOnMainThread (Fn&& fn) +{ + if (MessageManager::getInstance()->isThisTheMessageThread()) + fn(); + else + MessageManager::callAsync (std::forward (fn)); +} + +inline StringArray javaListOfStringToJuceStringArray (const LocalRef& javaArray) +{ + if (javaArray.get() == nullptr) + return {}; + + auto* env = getEnv(); + + StringArray result; + + const auto size = env->CallIntMethod (javaArray, JavaList.size); + + for (int i = 0; i < size; ++i) + result.add (juceString (LocalRef { (jstring) env->CallObjectMethod (javaArray, JavaList.get, i) }.get())); + + return result; +} + //============================================================================== struct InAppPurchases::Pimpl { Pimpl (InAppPurchases& parent) : owner (parent), - billingClient (LocalRef (getEnv()->NewObject (JuceBillingClient, - JuceBillingClient.constructor, - getAppContext().get(), - (jlong) this))) + billingClient (LocalRef { getEnv()->NewObject (JuceBillingClient, + JuceBillingClient.constructor, + getAppContext().get(), + (jlong) this) }) { } @@ -86,46 +167,52 @@ struct InAppPurchases::Pimpl void getProductsInformation (const StringArray& productIdentifiers) { - skuDetailsQueryCallbackQueue.emplace ([this] (LocalRef skuDetailsList) + productDetailsQueryCallbackQueue.emplace ([this] (LocalRef productDetailsList) { - if (skuDetailsList != nullptr) + if (productDetailsList != nullptr) { auto* env = getEnv(); Array products; - for (int i = 0; i < env->CallIntMethod (skuDetailsList, JavaList.size); ++i) - products.add (buildProduct (LocalRef (env->CallObjectMethod (skuDetailsList, JavaList.get, i)))); + for (int i = 0; i < env->CallIntMethod (productDetailsList, JavaList.size); ++i) + products.add (buildProduct (LocalRef { env->CallObjectMethod (productDetailsList, JavaList.get, i) })); - owner.listeners.call ([&] (Listener& l) { l.productsInfoReturned (products); }); + callMemberOnMainThread ([this, products] + { + owner.listeners.call ([&] (Listener& l) { l.productsInfoReturned (products); }); + }); } }); - querySkuDetailsAsync (convertToLowerCase (productIdentifiers)); + queryProductDetailsAsync (convertToLowerCase (productIdentifiers)); } void purchaseProduct (const String& productIdentifier, const String& subscriptionIdentifier, bool creditForUnusedSubscription) { - skuDetailsQueryCallbackQueue.emplace ([=] (LocalRef skuDetailsList) + productDetailsQueryCallbackQueue.emplace ([=] (LocalRef productDetailsList) { - if (skuDetailsList != nullptr) + if (productDetailsList != nullptr) { auto* env = getEnv(); - if (env->CallIntMethod (skuDetailsList, JavaList.size) > 0) + if (env->CallIntMethod (productDetailsList, JavaList.size) > 0) { - LocalRef skuDetails (env->CallObjectMethod (skuDetailsList, JavaList.get, 0)); + GlobalRef productDetails (LocalRef { env->CallObjectMethod (productDetailsList, JavaList.get, 0) }); - if (subscriptionIdentifier.isNotEmpty()) - changeExistingSubscription (skuDetails, subscriptionIdentifier, creditForUnusedSubscription); - else - purchaseProductWithSkuDetails (skuDetails); + callMemberOnMainThread ([this, productDetails, subscriptionIdentifier, creditForUnusedSubscription] + { + if (subscriptionIdentifier.isNotEmpty()) + changeExistingSubscription (productDetails, subscriptionIdentifier, creditForUnusedSubscription); + else + purchaseProductWithProductDetails (productDetails); + }); } } }); - querySkuDetailsAsync (convertToLowerCase ({ productIdentifier })); + queryProductDetailsAsync (convertToLowerCase ({ productIdentifier })); } void restoreProductsBoughtList (bool, const juce::String&) @@ -139,15 +226,21 @@ struct InAppPurchases::Pimpl for (int i = 0; i < env->CallIntMethod (purchasesList, JavaArrayList.size); ++i) { - LocalRef purchase (env->CallObjectMethod (purchasesList, JavaArrayList.get, i)); + const LocalRef purchase { env->CallObjectMethod (purchasesList, JavaArrayList.get, i) }; purchases.add ({ buildPurchase (purchase), {} }); } - owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored (purchases, true, NEEDS_TRANS ("Success")); }); + callMemberOnMainThread ([this, purchases] + { + owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored (purchases, true, NEEDS_TRANS ("Success")); }); + }); } else { - owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored ({}, false, NEEDS_TRANS ("Failure")); }); + callMemberOnMainThread ([this] + { + owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored ({}, false, NEEDS_TRANS ("Failure")); }); + }); } }); @@ -158,17 +251,17 @@ struct InAppPurchases::Pimpl { if (purchaseToken.isEmpty()) { - skuDetailsQueryCallbackQueue.emplace ([=] (LocalRef skuDetailsList) + productDetailsQueryCallbackQueue.emplace ([=] (LocalRef productDetailsList) { - if (skuDetailsList != nullptr) + if (productDetailsList != nullptr) { auto* env = getEnv(); - if (env->CallIntMethod (skuDetailsList, JavaList.size) > 0) + if (env->CallIntMethod (productDetailsList, JavaList.size) > 0) { - LocalRef sku (env->CallObjectMethod (skuDetailsList, JavaList.get, 0)); + const LocalRef product { env->CallObjectMethod (productDetailsList, JavaList.get, 0) }; - auto token = juceString (LocalRef ((jstring) env->CallObjectMethod (sku, AndroidPurchase.getSku))); + auto token = juceString (LocalRef { (jstring) env->CallObjectMethod (product, ProductDetails.getProductId) }); if (token.isNotEmpty()) { @@ -178,10 +271,13 @@ struct InAppPurchases::Pimpl } } - notifyListenersAboutConsume (productIdentifier, false, NEEDS_TRANS ("Item unavailable")); + callMemberOnMainThread ([this, productIdentifier] + { + notifyListenersAboutConsume (productIdentifier, false, NEEDS_TRANS ("Item unavailable")); + }); }); - querySkuDetailsAsync (convertToLowerCase ({ productIdentifier })); + queryProductDetailsAsync (convertToLowerCase ({ productIdentifier })); } consumePurchaseWithToken (productIdentifier, purchaseToken); @@ -218,27 +314,27 @@ struct InAppPurchases::Pimpl private: #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ - METHOD (constructor, "", "(Landroid/content/Context;J)V") \ - METHOD (endConnection, "endConnection", "()V") \ - METHOD (isReady, "isReady", "()Z") \ - METHOD (isBillingSupported, "isBillingSupported", "()Z") \ - METHOD (querySkuDetails, "querySkuDetails", "([Ljava/lang/String;)V") \ - METHOD (launchBillingFlow, "launchBillingFlow", "(Landroid/app/Activity;Lcom/android/billingclient/api/BillingFlowParams;)V") \ - METHOD (queryPurchases, "queryPurchases", "()V") \ - METHOD (consumePurchase, "consumePurchase", "(Ljava/lang/String;Ljava/lang/String;)V") \ - \ - CALLBACK (skuDetailsQueryCallback, "skuDetailsQueryCallback", "(JLjava/util/List;)V") \ - CALLBACK (purchasesListQueryCallback, "purchasesListQueryCallback", "(JLjava/util/List;)V") \ - CALLBACK (purchaseCompletedCallback, "purchaseCompletedCallback", "(JLcom/android/billingclient/api/Purchase;I)V") \ - CALLBACK (purchaseConsumedCallback, "purchaseConsumedCallback", "(JLjava/lang/String;I)V") + METHOD (constructor, "", "(Landroid/content/Context;J)V") \ + METHOD (endConnection, "endConnection", "()V") \ + METHOD (isReady, "isReady", "()Z") \ + METHOD (isBillingSupported, "isBillingSupported", "()Z") \ + METHOD (queryProductDetails, "queryProductDetails", "([Ljava/lang/String;)V") \ + METHOD (launchBillingFlow, "launchBillingFlow", "(Landroid/app/Activity;Lcom/android/billingclient/api/BillingFlowParams;)V") \ + METHOD (queryPurchases, "queryPurchases", "()V") \ + METHOD (consumePurchase, "consumePurchase", "(Ljava/lang/String;Ljava/lang/String;)V") \ + \ + CALLBACK (productDetailsQueryCallback, "productDetailsQueryCallback", "(JLjava/util/List;)V") \ + CALLBACK (purchasesListQueryCallback, "purchasesListQueryCallback", "(JLjava/util/List;)V") \ + CALLBACK (purchaseCompletedCallback, "purchaseCompletedCallback", "(JLcom/android/billingclient/api/Purchase;I)V") \ + CALLBACK (purchaseConsumedCallback, "purchaseConsumedCallback", "(JLjava/lang/String;I)V") DECLARE_JNI_CLASS (JuceBillingClient, "com/rmsl/juce/JuceBillingClient") #undef JNI_CLASS_MEMBERS - static void JNICALL skuDetailsQueryCallback (JNIEnv*, jobject, jlong host, jobject skuDetailsList) + static void JNICALL productDetailsQueryCallback (JNIEnv*, jobject, jlong host, jobject productDetailsList) { if (auto* myself = reinterpret_cast (host)) - myself->updateSkuDetails (skuDetailsList); + myself->updateProductDetails (productDetailsList); } static void JNICALL purchasesListQueryCallback (JNIEnv*, jobject, jlong host, jobject purchasesList) @@ -289,7 +385,7 @@ private: return lowerCase; } - void querySkuDetailsAsync (const StringArray& productIdentifiers) + void queryProductDetailsAsync (const StringArray& productIdentifiers) { Thread::launch ([=] { @@ -299,7 +395,7 @@ private: MessageManager::callAsync ([=] { getEnv()->CallVoidMethod (billingClient, - JuceBillingClient.querySkuDetails, + JuceBillingClient.queryProductDetails, juceStringArrayToJava (productIdentifiers).get()); }); }); @@ -331,23 +427,15 @@ private: owner.listeners.call ([&] (Listener& l) { l.productConsumed (productIdentifier, success, statusDescription); }); } - LocalRef createBillingFlowParamsBuilder (LocalRef skuDetails) - { - auto* env = getEnv(); - - auto builder = LocalRef (env->CallStaticObjectMethod (BillingFlowParams, BillingFlowParams.newBuilder)); - - return LocalRef (env->CallObjectMethod (builder.get(), - BillingFlowParamsBuilder.setSkuDetails, - skuDetails.get())); - } - void launchBillingFlowWithParameters (LocalRef params) { - LocalRef activity (getCurrentActivity()); + const auto activity = [] + { + if (auto current = getCurrentActivity()) + return current; - if (activity == nullptr) - activity = getMainActivity(); + return getMainActivity(); + }(); getEnv()->CallVoidMethod (billingClient, JuceBillingClient.launchBillingFlow, @@ -355,7 +443,7 @@ private: params.get()); } - void changeExistingSubscription (LocalRef skuDetails, const String& subscriptionIdentifier, bool creditForUnusedSubscription) + void changeExistingSubscription (GlobalRef productDetails, const String& subscriptionIdentifier, bool creditForUnusedSubscription) { if (! isReady()) { @@ -371,35 +459,47 @@ private: for (int i = 0; i < env->CallIntMethod (purchasesList, JavaArrayList.size); ++i) { - auto purchase = buildPurchase (LocalRef (env->CallObjectMethod (purchasesList.get(), JavaArrayList.get, i))); + auto purchase = buildPurchase (LocalRef { env->CallObjectMethod (purchasesList.get(), JavaArrayList.get, i) }); - if (purchase.productId == subscriptionIdentifier) + if (purchase.productIds.contains (subscriptionIdentifier)) { - auto builder = createBillingFlowParamsBuilder (skuDetails); - - builder = LocalRef (env->CallObjectMethod (builder.get(), - BillingFlowParamsBuilder.setOldSku, - javaString (subscriptionIdentifier).get(), - javaString (purchase.purchaseToken).get())); + const LocalRef subscriptionBuilder { getEnv()->CallStaticObjectMethod (BillingFlowParamsSubscriptionUpdateParams, + BillingFlowParamsSubscriptionUpdateParams.newBuilder) }; + env->CallObjectMethod (subscriptionBuilder.get(), + BillingFlowParamsSubscriptionUpdateParamsBuilder.setOldPurchaseToken, + javaString (purchase.purchaseToken).get()); if (! creditForUnusedSubscription) - builder = LocalRef (env->CallObjectMethod (builder.get(), - BillingFlowParamsBuilder.setReplaceSkusProrationMode, - 3 /*IMMEDIATE_WITHOUT_PRORATION*/)); + { + env->CallObjectMethod (subscriptionBuilder.get(), + BillingFlowParamsSubscriptionUpdateParamsBuilder.setReplaceProrationMode, + 3 /*IMMEDIATE_WITHOUT_PRORATION*/); + } + + const LocalRef subscriptionParams { env->CallObjectMethod (subscriptionBuilder.get(), + BillingFlowParamsSubscriptionUpdateParamsBuilder.build) }; - launchBillingFlowWithParameters (LocalRef (env->CallObjectMethod (builder.get(), - BillingFlowParamsBuilder.build))); + const LocalRef builder { env->CallStaticObjectMethod (BillingFlowParams, BillingFlowParams.newBuilder) }; + env->CallObjectMethod (builder.get(), + BillingFlowParamsBuilder.setSubscriptionUpdateParams, + subscriptionParams.get()); + const LocalRef params { env->CallObjectMethod (builder.get(), BillingFlowParamsBuilder.build) }; + + launchBillingFlowWithParameters (params); } } } - notifyListenersAboutPurchase ({}, false, NEEDS_TRANS ("Unable to get subscription details")); + callMemberOnMainThread ([this] + { + notifyListenersAboutPurchase ({}, false, NEEDS_TRANS ("Unable to get subscription details")); + }); }); getProductsBoughtAsync(); } - void purchaseProductWithSkuDetails (LocalRef skuDetails) + void purchaseProductWithProductDetails (GlobalRef productDetails) { if (! isReady()) { @@ -407,22 +507,48 @@ private: return; } - launchBillingFlowWithParameters (LocalRef (getEnv()->CallObjectMethod (createBillingFlowParamsBuilder (skuDetails).get(), - BillingFlowParamsBuilder.build))); + auto* env = getEnv(); + const LocalRef billingFlowParamsProductDetailsParamsBuilder { env->CallStaticObjectMethod (BillingFlowParamsProductDetailsParams, BillingFlowParamsProductDetailsParams.newBuilder) }; + env->CallObjectMethod (billingFlowParamsProductDetailsParamsBuilder, BillingFlowParamsProductDetailsParamsBuilder.setProductDetails, productDetails.get()); + + if (const LocalRef subscriptionDetailsList { env->CallObjectMethod (productDetails, ProductDetails.getSubscriptionOfferDetails) }) + { + if (env->CallIntMethod (subscriptionDetailsList, JavaList.size) > 0) + { + const LocalRef subscriptionDetails { env->CallObjectMethod (subscriptionDetailsList, JavaList.get, 0) }; + const LocalRef offerToken { env->CallObjectMethod (subscriptionDetails, SubscriptionOfferDetails.getOfferToken) }; + env->CallObjectMethod (billingFlowParamsProductDetailsParamsBuilder, BillingFlowParamsProductDetailsParamsBuilder.setOfferToken, offerToken.get()); + } + } + + const LocalRef billingFlowParamsProductDetailsParams { env->CallObjectMethod (billingFlowParamsProductDetailsParamsBuilder, BillingFlowParamsProductDetailsParamsBuilder.build) }; + + const LocalRef list { env->NewObject (JavaArrayList, JavaArrayList.constructor, 0) }; + env->CallBooleanMethod (list, JavaArrayList.add, billingFlowParamsProductDetailsParams.get()); + + const LocalRef billingFlowParamsBuilder { env->CallStaticObjectMethod (BillingFlowParams, BillingFlowParams.newBuilder) }; + env->CallObjectMethod (billingFlowParamsBuilder, BillingFlowParamsBuilder.setProductDetailsParamsList, list.get()); + const LocalRef params { env->CallObjectMethod (billingFlowParamsBuilder, BillingFlowParamsBuilder.build) }; + + launchBillingFlowWithParameters (params); } void consumePurchaseWithToken (const String& productIdentifier, const String& purchaseToken) { if (! isReady()) { - notifyListenersAboutConsume (productIdentifier, false, NEEDS_TRANS ("In-App purchases unavailable")); + callMemberOnMainThread ([this, productIdentifier] + { + notifyListenersAboutConsume (productIdentifier, false, NEEDS_TRANS ("In-App purchases unavailable")); + }); + return; } getEnv()->CallObjectMethod (billingClient, JuceBillingClient.consumePurchase, - LocalRef (javaString (productIdentifier)).get(), - LocalRef (javaString (purchaseToken)).get()); + LocalRef { javaString (productIdentifier) }.get(), + LocalRef { javaString (purchaseToken) }.get()); } //============================================================================== @@ -433,25 +559,59 @@ private: auto* env = getEnv(); - return { juceString (LocalRef ((jstring) env->CallObjectMethod (purchase, AndroidPurchase.getOrderId))), - juceString (LocalRef ((jstring) env->CallObjectMethod (purchase, AndroidPurchase.getSku))), - juceString (LocalRef ((jstring) env->CallObjectMethod (purchase, AndroidPurchase.getPackageName))), + if (env->CallIntMethod(purchase, AndroidPurchase.getPurchaseState) != 1 /* PURCHASED */) + return {}; + + return { juceString (LocalRef { (jstring) env->CallObjectMethod (purchase, AndroidPurchase.getOrderId) }), + javaListOfStringToJuceStringArray (LocalRef { env->CallObjectMethod (purchase, AndroidPurchase.getProducts) }), + juceString (LocalRef { (jstring) env->CallObjectMethod (purchase, AndroidPurchase.getPackageName) }), Time (env->CallLongMethod (purchase, AndroidPurchase.getPurchaseTime)).toString (true, true, true, true), - juceString (LocalRef ((jstring) env->CallObjectMethod (purchase, AndroidPurchase.getPurchaseToken))) }; + juceString (LocalRef { (jstring) env->CallObjectMethod (purchase, AndroidPurchase.getPurchaseToken) }) }; } - static InAppPurchases::Product buildProduct (LocalRef productSkuDetails) + static InAppPurchases::Product buildProduct (LocalRef productDetails) { - if (productSkuDetails == nullptr) + if (productDetails == nullptr) return {}; auto* env = getEnv(); - return { juceString (LocalRef ((jstring) env->CallObjectMethod (productSkuDetails, SkuDetails.getSku))), - juceString (LocalRef ((jstring) env->CallObjectMethod (productSkuDetails, SkuDetails.getTitle))), - juceString (LocalRef ((jstring) env->CallObjectMethod (productSkuDetails, SkuDetails.getDescription))), - juceString (LocalRef ((jstring) env->CallObjectMethod (productSkuDetails, SkuDetails.getPrice))), - juceString (LocalRef ((jstring) env->CallObjectMethod (productSkuDetails, SkuDetails.getPriceCurrencyCode))) }; + if (LocalRef oneTimePurchase { env->CallObjectMethod (productDetails, ProductDetails.getOneTimePurchaseOfferDetails) }) + { + return { juceString (LocalRef { (jstring) env->CallObjectMethod (productDetails, ProductDetails.getProductId) }), + juceString (LocalRef { (jstring) env->CallObjectMethod (productDetails, ProductDetails.getTitle) }), + juceString (LocalRef { (jstring) env->CallObjectMethod (productDetails, ProductDetails.getDescription) }), + juceString (LocalRef { (jstring) env->CallObjectMethod (oneTimePurchase, OneTimePurchaseOfferDetails.getFormattedPrice) }), + juceString (LocalRef { (jstring) env->CallObjectMethod (oneTimePurchase, OneTimePurchaseOfferDetails.getPriceCurrencyCode) }) }; + } + + LocalRef subscription { env->CallObjectMethod (productDetails, ProductDetails.getSubscriptionOfferDetails) }; + + if (env->CallIntMethod (subscription, JavaList.size) == 0) + return {}; + + // We can only return a single subscription price for this subscription, + // but the subscription has more than one pricing scheme. + jassert (env->CallIntMethod (subscription, JavaList.size) == 1); + + const LocalRef offerDetails { env->CallObjectMethod (subscription, JavaList.get, 0) }; + const LocalRef pricingPhases { env->CallObjectMethod (offerDetails, SubscriptionOfferDetails.getPricingPhases) }; + const LocalRef phaseList { env->CallObjectMethod (pricingPhases, PricingPhases.getPricingPhaseList) }; + + if (env->CallIntMethod (phaseList, JavaList.size) == 0) + return {}; + + // We can only return a single subscription price for this subscription, + // but the pricing scheme for this subscription has more than one phase. + jassert (env->CallIntMethod (phaseList, JavaList.size) == 1); + + const LocalRef phase { env->CallObjectMethod (phaseList, JavaList.get, 0) }; + + return { juceString (LocalRef { (jstring) env->CallObjectMethod (productDetails, ProductDetails.getProductId) }), + juceString (LocalRef { (jstring) env->CallObjectMethod (productDetails, ProductDetails.getTitle) }), + juceString (LocalRef { (jstring) env->CallObjectMethod (productDetails, ProductDetails.getDescription) }), + juceString (LocalRef { (jstring) env->CallObjectMethod (phase, PricingPhase.getFormattedPrice) }), + juceString (LocalRef { (jstring) env->CallObjectMethod (phase, PricingPhase.getPriceCurrencyCode) }) }; } static String getStatusDescriptionFromResponseCode (int responseCode) @@ -478,29 +638,29 @@ private: void purchaseCompleted (jobject purchase, int responseCode) { - notifyListenersAboutPurchase (buildPurchase (LocalRef (purchase)), + notifyListenersAboutPurchase (buildPurchase (LocalRef { purchase }), wasSuccessful (responseCode), getStatusDescriptionFromResponseCode (responseCode)); } void purchaseConsumed (jstring productIdentifier, int responseCode) { - notifyListenersAboutConsume (juceString (LocalRef (productIdentifier)), + notifyListenersAboutConsume (juceString (LocalRef { productIdentifier }), wasSuccessful (responseCode), getStatusDescriptionFromResponseCode (responseCode)); } - void updateSkuDetails (jobject skuDetailsList) + void updateProductDetails (jobject productDetailsList) { - jassert (! skuDetailsQueryCallbackQueue.empty()); - skuDetailsQueryCallbackQueue.front() (LocalRef (skuDetailsList)); - skuDetailsQueryCallbackQueue.pop(); + jassert (! productDetailsQueryCallbackQueue.empty()); + productDetailsQueryCallbackQueue.front() (LocalRef { productDetailsList }); + productDetailsQueryCallbackQueue.pop(); } void updatePurchasesList (jobject purchasesList) { jassert (! purchasesListQueryCallbackQueue.empty()); - purchasesListQueryCallbackQueue.front() (LocalRef (purchasesList)); + purchasesListQueryCallbackQueue.front() (LocalRef { purchasesList }); purchasesListQueryCallbackQueue.pop(); } @@ -508,13 +668,32 @@ private: InAppPurchases& owner; GlobalRef billingClient; - std::queue)>> skuDetailsQueryCallbackQueue, + std::queue)>> productDetailsQueryCallbackQueue, purchasesListQueryCallbackQueue; //============================================================================== + void callMemberOnMainThread (std::function callback) + { + callOnMainThread ([ref = WeakReference (this), callback] + { + if (ref != nullptr) + callback(); + }); + } + + //============================================================================== + JUCE_DECLARE_WEAK_REFERENCEABLE(Pimpl) JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Pimpl) }; +void juce_handleOnResume() +{ + callOnMainThread ([] + { + InAppPurchases::getInstance()->restoreProductsBoughtList (false); + }); +} + InAppPurchases::Pimpl::JuceBillingClient_Class InAppPurchases::Pimpl::JuceBillingClient;