diff --git a/examples/Utilities/InAppPurchasesDemo.h b/examples/Utilities/InAppPurchasesDemo.h index f726acbef8..7937857d6f 100644 --- a/examples/Utilities/InAppPurchasesDemo.h +++ b/examples/Utilities/InAppPurchasesDemo.h @@ -52,6 +52,19 @@ #include "../Assets/DemoUtilities.h" +/* + To finish the setup of this demo, do the following in the Projucer project: + + 1. In the project settings, set the "Bundle Identifier" to com.roli.juceInAppPurchaseSample + 2. In the Android exporter settings, change the following settings: + - "In-App Billing" - Enabled + - "Key Signing: key.store" - path to InAppPurchase.keystore file in examples/Assets/Signing + - "Key Signing: key.store.password" - amazingvoices + - "Key Signing: key-alias" - InAppPurchase + - "Key Signing: key.alias.password" - amazingvoices + 3. Re-save the project +*/ + //============================================================================== class VoicePurchases : private InAppPurchases::Listener { @@ -108,7 +121,7 @@ public: purchaseInProgress = true; product.purchaseInProgress = true; - InAppPurchases::getInstance()->purchaseProduct (product.identifier, false); + InAppPurchases::getInstance()->purchaseProduct (product.identifier); guiUpdater.triggerAsyncUpdate(); } diff --git a/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h b/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h index 1fdfac6e4c..ddc2402b25 100644 --- a/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h +++ b/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h @@ -140,9 +140,9 @@ public: androidKeyStorePass (settings, Ids::androidKeyStorePass, getUndoManager(), "android"), androidKeyAlias (settings, Ids::androidKeyAlias, getUndoManager(), "androiddebugkey"), androidKeyAliasPass (settings, Ids::androidKeyAliasPass, getUndoManager(), "android"), - gradleVersion (settings, Ids::gradleVersion, getUndoManager(), "4.10"), + gradleVersion (settings, Ids::gradleVersion, getUndoManager(), "5.4.1"), gradleToolchain (settings, Ids::gradleToolchain, getUndoManager(), "clang"), - androidPluginVersion (settings, Ids::androidPluginVersion, getUndoManager(), "3.2.1"), + androidPluginVersion (settings, Ids::androidPluginVersion, getUndoManager(), "3.5.3"), AndroidExecutable (getAppSettings().getStoredPath (Ids::androidStudioExePath, TargetOS::getThisOS()).get().toString()) { name = getName(); @@ -840,6 +840,9 @@ private: for (auto& d : StringArray::fromLines (androidJavaLibs.get().toString())) mo << " implementation files('libs/" << File (d).getFileName() << "')" << newLine; + if (isInAppBillingEnabled()) + mo << " implementation 'com.android.billingclient:billing:2.1.0'" << newLine; + if (areRemoteNotificationsEnabled()) { mo << " implementation 'com.google.firebase:firebase-core:16.0.1'" << newLine; @@ -1215,6 +1218,8 @@ private: bool arePushNotificationsEnabled() const { return androidPushNotifications.get(); } bool areRemoteNotificationsEnabled() const { return arePushNotificationsEnabled() && androidEnableRemoteNotifications.get(); } + bool isInAppBillingEnabled() const { return androidInAppBillingPermission.get(); } + String getJNIActivityClassName() const { return getActivityClassString().replaceCharacter ('.', '/'); @@ -1436,7 +1441,7 @@ private: defines.set ("JUCE_PUSH_NOTIFICATIONS_ACTIVITY", getJNIActivityClassName().quoted()); } - if (androidInAppBillingPermission.get()) + if (isInAppBillingEnabled()) defines.set ("JUCE_IN_APP_PURCHASES", "1"); if (supportsGLv3()) @@ -1820,7 +1825,7 @@ private: if (androidExternalWritePermission.get()) s.add ("android.permission.WRITE_EXTERNAL_STORAGE"); - if (androidInAppBillingPermission.get()) + if (isInAppBillingEnabled()) s.add ("com.android.vending.BILLING"); if (androidVibratePermission.get()) diff --git a/modules/juce_core/system/juce_StandardHeader.h b/modules/juce_core/system/juce_StandardHeader.h index e320efc780..9239d6b9b5 100644 --- a/modules/juce_core/system/juce_StandardHeader.h +++ b/modules/juce_core/system/juce_StandardHeader.h @@ -58,6 +58,7 @@ #include #include #include +#include //============================================================================== #include "juce_CompilerSupport.h" diff --git a/modules/juce_gui_basics/native/juce_android_Windowing.cpp b/modules/juce_gui_basics/native/juce_android_Windowing.cpp index 18be24d45d..7b1d1ec598 100644 --- a/modules/juce_gui_basics/native/juce_android_Windowing.cpp +++ b/modules/juce_gui_basics/native/juce_android_Windowing.cpp @@ -196,10 +196,6 @@ static const uint8 javaComponentPeerView[] = extern void juce_firebaseRemoteMessageSendError (void*, void*); #endif -#if JUCE_IN_APP_PURCHASES && JUCE_MODULE_AVAILABLE_juce_product_unlocking - extern void juce_inAppPurchaseCompleted (void*); -#endif - extern void juce_contentSharingCompleted (int); //============================================================================== diff --git a/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.cpp b/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.cpp index ca53510a07..8609cd58ee 100644 --- a/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.cpp +++ b/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.cpp @@ -61,13 +61,11 @@ void InAppPurchases::getProductsInformation (const StringArray& productIdentifie } void InAppPurchases::purchaseProduct (const String& productIdentifier, - bool isSubscription, - const StringArray& upgradeProductIdentifiers, + const String& upgradeProductIdentifier, bool creditForUnusedSubscription) { #if JUCE_ANDROID || JUCE_IOS || JUCE_MAC - pimpl->purchaseProduct (productIdentifier, isSubscription, - upgradeProductIdentifiers, creditForUnusedSubscription); + pimpl->purchaseProduct (productIdentifier, upgradeProductIdentifier, creditForUnusedSubscription); #else Listener::PurchaseInfo purchaseInfo { Purchase { "", productIdentifier, {}, {}, {} }, {} }; 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 e2bd100c2f..38d2176768 100644 --- a/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.h +++ b/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.h @@ -200,19 +200,15 @@ public: @param productIdentifier The product identifier. - @param isSubscription (Android only) defines if a product a user wants to buy is a subscription or a one-time purchase. - On iOS, type of the product is derived implicitly. - - @param upgradeOrDowngradeFromSubscriptionsWithProductIdentifiers (Android only) specifies subscriptions that will be replaced by the - one being purchased now. Used only when buying a subscription - that is an upgrade or downgrade from other ones. + @param upgradeOrDowngradeFromSubscriptionsWithProductIdentifier (Android only) specifies the subscription that will be replaced by + the one being purchased now. Used only when buying a subscription + that is an upgrade or downgrade from another. @param creditForUnusedSubscription (Android only) controls whether a user should be credited for any unused subscription time on - the products that are being upgraded or downgraded. + the product that is being upgraded or downgraded. */ void purchaseProduct (const String& productIdentifier, - bool isSubscription, - const StringArray& upgradeOrDowngradeFromSubscriptionsWithProductIdentifiers = {}, + const String& upgradeOrDowngradeFromSubscriptionWithProductIdentifier = {}, bool creditForUnusedSubscription = true); /** Asynchronously asks about a list of products that a user has already bought. Upon completion, Listener::purchasesListReceived() @@ -260,6 +256,22 @@ public: /** iOS only: Cancels downloads of hosted content from the store. */ void cancelDownloads (const Array& downloads); + //============================================================================== + // 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. + JUCE_DEPRECATED_WITH_BODY (void purchaseProduct (const String& productIdentifier, + bool isSubscription, + const StringArray& upgradeOrDowngradeFromSubscriptionsWithProductIdentifiers = {}, + bool creditForUnusedSubscription = true), + { + + ignoreUnused (isSubscription); + purchaseProduct (productIdentifier, + upgradeOrDowngradeFromSubscriptionsWithProductIdentifiers[0], + creditForUnusedSubscription); + }) + private: //============================================================================== #ifndef DOXYGEN diff --git a/modules/juce_product_unlocking/juce_product_unlocking.h b/modules/juce_product_unlocking/juce_product_unlocking.h index 35033642d8..6f6875078d 100644 --- a/modules/juce_product_unlocking/juce_product_unlocking.h +++ b/modules/juce_product_unlocking/juce_product_unlocking.h @@ -41,7 +41,7 @@ website: http://www.juce.com/juce license: GPL/Commercial - dependencies: juce_cryptography juce_core + dependencies: juce_cryptography juce_core, juce_events END_JUCE_MODULE_DECLARATION @@ -68,6 +68,7 @@ //============================================================================== #include #include +#include #if JUCE_MODULE_AVAILABLE_juce_data_structures #include diff --git a/modules/juce_product_unlocking/native/javacore/app/com/roli/juce/JuceBillingClient.java b/modules/juce_product_unlocking/native/javacore/app/com/roli/juce/JuceBillingClient.java new file mode 100644 index 0000000000..713655752b --- /dev/null +++ b/modules/juce_product_unlocking/native/javacore/app/com/roli/juce/JuceBillingClient.java @@ -0,0 +1,197 @@ +package com.roli.juce; + + +import android.content.Context; +import android.app.Activity; +import android.util.Log; + +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.BillingClientStateListener; +import com.android.billingclient.api.ConsumeResponseListener; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.PurchasesUpdatedListener; +import com.android.billingclient.api.SkuDetails; +import com.android.billingclient.api.SkuDetailsParams; +import com.android.billingclient.api.SkuDetailsResponseListener; +import com.android.billingclient.api.AcknowledgePurchaseParams; +import com.android.billingclient.api.AcknowledgePurchaseResponseListener; +import com.android.billingclient.api.ConsumeParams; + +import java.util.Arrays; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + + +public class JuceBillingClient implements PurchasesUpdatedListener { + private native void skuDetailsQueryCallback(long host, List skuDetails); + private native void purchasesListQueryCallback(long host, List purchases); + private native void purchaseCompletedCallback(long host, Purchase purchase, int responseCode); + private native void purchaseConsumedCallback(long host, String productIdentifier, int responseCode); + + public JuceBillingClient(Context context, long hostToUse) { + host = hostToUse; + + billingClient = BillingClient.newBuilder(context) + .enablePendingPurchases() + .setListener(this) + .build(); + + billingClient.startConnection(null); + } + + public void endConnection() { + billingClient.endConnection(); + } + + public boolean isReady() { + return billingClient.isReady(); + } + + public boolean isBillingSupported() { + return billingClient.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS).getResponseCode() + == BillingClient.BillingResponseCode.OK; + } + + public void querySkuDetails(final String[] skusToQuery) { + executeOnBillingClientConnection(new Runnable() { + @Override + public void run() { + final List skuList = Arrays.asList(skusToQuery); + + SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder() + .setSkusList(skuList) + .setType(BillingClient.SkuType.INAPP); + + billingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() { + @Override + public void onSkuDetailsResponse(BillingResult billingResult, final 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, List subsSkuDetails) { + if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { + subsSkuDetails.addAll(inAppSkuDetails); + skuDetailsQueryCallback(host, subsSkuDetails); + } + } + }); + } + } + }); + } + }); + } + + public void launchBillingFlow(final Activity activity, final BillingFlowParams params) { + executeOnBillingClientConnection(new Runnable() { + @Override + public void run() { + BillingResult r = billingClient.launchBillingFlow(activity, params); + } + }); + } + + 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) { + List purchaseList = inAppPurchases.getPurchasesList(); + purchaseList.addAll(subsPurchases.getPurchasesList()); + + purchasesListQueryCallback(host, purchaseList); + return; + } + + purchasesListQueryCallback(host, null); + } + }); + } + + public void consumePurchase(final String productIdentifier, final String purchaseToken) { + executeOnBillingClientConnection(new Runnable() { + @Override + public void run() { + ConsumeParams consumeParams = ConsumeParams.newBuilder() + .setPurchaseToken(purchaseToken) + .build(); + + billingClient.consumeAsync(consumeParams, new ConsumeResponseListener() { + @Override + public void onConsumeResponse(BillingResult billingResult, String purchaseToken) { + purchaseConsumedCallback(host, productIdentifier, billingResult.getResponseCode()); + } + }); + } + }); + } + + @Override + public void onPurchasesUpdated(BillingResult result, List purchases) { + int responseCode = result.getResponseCode(); + + if (purchases != null) { + for (Purchase purchase : purchases) { + handlePurchase(purchase, responseCode); + } + } else { + purchaseCompletedCallback(host, null, responseCode); + } + } + + private void executeOnBillingClientConnection(Runnable runnable) { + if (billingClient.isReady()) { + runnable.run(); + } else { + connectAndExecute(runnable); + } + } + + private void connectAndExecute(final Runnable executeOnSuccess) { + billingClient.startConnection(new BillingClientStateListener() { + @Override + public void onBillingSetupFinished(BillingResult billingResponse) { + if (billingResponse.getResponseCode() == BillingClient.BillingResponseCode.OK) { + if (executeOnSuccess != null) { + executeOnSuccess.run(); + } + } + } + + @Override + public void onBillingServiceDisconnected() { + } + }); + } + + private void handlePurchase(final Purchase purchase, final int responseCode) { + purchaseCompletedCallback(host, purchase, responseCode); + + if (responseCode == BillingClient.BillingResponseCode.OK + && purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED + && !purchase.isAcknowledged()) { + executeOnBillingClientConnection(new Runnable() { + @Override + public void run() { + AcknowledgePurchaseParams acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.getPurchaseToken()).build(); + billingClient.acknowledgePurchase(acknowledgePurchaseParams, null); + } + }); + } + } + + private long host = 0; + private 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 9dbb5d032d..1d0eb4af56 100644 --- a/modules/juce_product_unlocking/native/juce_android_InAppPurchases.cpp +++ b/modules/juce_product_unlocking/native/juce_android_InAppPurchases.cpp @@ -28,830 +28,495 @@ namespace juce { #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ - METHOD (isBillingSupported, "isBillingSupported", "(ILjava/lang/String;Ljava/lang/String;)I") \ - METHOD (getSkuDetails, "getSkuDetails", "(ILjava/lang/String;Ljava/lang/String;Landroid/os/Bundle;)Landroid/os/Bundle;") \ - METHOD (getBuyIntent, "getBuyIntent", "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Landroid/os/Bundle;") \ - METHOD (getBuyIntentExtraParams, "getBuyIntentExtraParams", "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Landroid/os/Bundle;)Landroid/os/Bundle;") \ - METHOD (getPurchases, "getPurchases", "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Landroid/os/Bundle;") \ - METHOD (consumePurchase, "consumePurchase", "(ILjava/lang/String;Ljava/lang/String;)I") \ - METHOD (getPurchaseHistory, "getPurchaseHistory", "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Landroid/os/Bundle;)Landroid/os/Bundle;") - -DECLARE_JNI_CLASS (IInAppBillingService, "com/android/vending/billing/IInAppBillingService") + 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;") + +DECLARE_JNI_CLASS (SkuDetails, "com/android/billingclient/api/SkuDetails") #undef JNI_CLASS_MEMBERS #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ - STATICMETHOD (asInterface, "asInterface", "(Landroid/os/IBinder;)Lcom/android/vending/billing/IInAppBillingService;") \ + STATICMETHOD (newBuilder, "newBuilder", "()Lcom/android/billingclient/api/BillingFlowParams$Builder;") -DECLARE_JNI_CLASS (IInAppBillingServiceStub, "com/android/vending/billing/IInAppBillingService$Stub") +DECLARE_JNI_CLASS (BillingFlowParams, "com/android/billingclient/api/BillingFlowParams") #undef JNI_CLASS_MEMBERS -//============================================================================== -struct ServiceConnection : public AndroidInterfaceImplementer -{ - virtual void onServiceConnected (jobject component, jobject iBinder) = 0; - virtual void onServiceDisconnected (jobject component) = 0; - - jobject invoke (jobject proxy, jobject method, jobjectArray args) override - { - auto* env = getEnv(); - auto methodName = juceString ((jstring) env->CallObjectMethod (method, JavaMethod.getName)); +#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;") - if (methodName == "onServiceConnected") - { - onServiceConnected (env->GetObjectArrayElement (args, 0), - env->GetObjectArrayElement (args, 1)); - return nullptr; - } +DECLARE_JNI_CLASS (BillingFlowParamsBuilder, "com/android/billingclient/api/BillingFlowParams$Builder") +#undef JNI_CLASS_MEMBERS - if (methodName == "onServiceDisconnected") - { - onServiceDisconnected (env->GetObjectArrayElement (args, 0)); - return nullptr; - } +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ + METHOD (getOrderId, "getOrderId", "()Ljava/lang/String;") \ + METHOD (getSku, "getSku", "()Ljava/lang/String;") \ + METHOD (getPackageName, "getPackageName", "()Ljava/lang/String;") \ + METHOD (getPurchaseTime, "getPurchaseTime", "()J") \ + METHOD (getPurchaseToken, "getPurchaseToken", "()Ljava/lang/String;") - return AndroidInterfaceImplementer::invoke (proxy, method, args); - } -}; +DECLARE_JNI_CLASS (AndroidPurchase, "com/android/billingclient/api/Purchase") +#undef JNI_CLASS_MEMBERS //============================================================================== -struct InAppPurchases::Pimpl : private AsyncUpdater, - private ServiceConnection +struct InAppPurchases::Pimpl { - Pimpl (InAppPurchases& parent) : owner (parent) + Pimpl (InAppPurchases& parent) + : owner (parent), + billingClient (LocalRef (getEnv()->NewObject (JuceBillingClient, + JuceBillingClient.constructor, + getAppContext().get(), + (jlong) this))) { - auto* env = getEnv(); - auto intent = env->NewObject (AndroidIntent, AndroidIntent.constructWithString, - javaString ("com.android.vending.billing.InAppBillingService.BIND").get()); - env->CallObjectMethod (intent, AndroidIntent.setPackage, javaString ("com.android.vending").get()); - - serviceConnection = GlobalRef (CreateJavaInterface (this, "android/content/ServiceConnection")); - - env->CallBooleanMethod (getCurrentActivity().get(), AndroidContext.bindService, intent, - serviceConnection.get(), 1 /*BIND_AUTO_CREATE*/); - - if (threadPool == nullptr) - threadPool.reset (new ThreadPool (1)); } ~Pimpl() { - threadPool = nullptr; - - if (serviceConnection != nullptr) - { - getEnv()->CallVoidMethod (getCurrentActivity().get(), AndroidContext.unbindService, serviceConnection.get()); - serviceConnection.clear(); - } + getEnv()->CallVoidMethod (billingClient, JuceBillingClient.endConnection); } //============================================================================== - bool isInAppPurchasesSupported() { return isInAppPurchasesSupported (inAppBillingService); } + bool isInAppPurchasesSupported() const + { + return isReady() && getEnv()->CallBooleanMethod (billingClient, JuceBillingClient.isBillingSupported); + } void getProductsInformation (const StringArray& productIdentifiers) { - auto callback = [this](const Array& products) + skuDetailsQueryCallbackQueue.emplace ([this] (LocalRef skuDetailsList) { - const ScopedLock lock (getProductsInformationJobResultsLock); - getProductsInformationJobResults.insert (0, products); - triggerAsyncUpdate(); - }; + if (skuDetailsList != 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)))); + + owner.listeners.call ([&] (Listener& l) { l.productsInfoReturned (products); }); + } + }); - threadPool->addJob (new GetProductsInformationJob (*this, getPackageName(), - productIdentifiers, callback), true); + querySkuDetailsAsync (convertToLowerCase (productIdentifiers)); } - void purchaseProduct (const String& productIdentifier, bool isSubscription, - const StringArray& subscriptionIdentifiers, bool creditForUnusedSubscription) + void purchaseProduct (const String& productIdentifier, + const String& subscriptionIdentifier, + bool creditForUnusedSubscription) { - // Upgrading/downgrading only makes sense for subscriptions! - jassert (subscriptionIdentifiers.isEmpty() || isSubscription); + skuDetailsQueryCallbackQueue.emplace ([=] (LocalRef skuDetailsList) + { + if (skuDetailsList != nullptr) + { + auto* env = getEnv(); - auto buyIntentBundle = getBuyIntentBundle (productIdentifier, isSubscription, - subscriptionIdentifiers, creditForUnusedSubscription); - auto* env = getEnv(); + if (env->CallIntMethod (skuDetailsList, JavaList.size) > 0) + { + LocalRef skuDetails (env->CallObjectMethod (skuDetailsList, JavaList.get, 0)); - auto responseCodeString = javaString ("RESPONSE_CODE"); - auto responseCode = env->CallIntMethod (buyIntentBundle.get(), AndroidBundle.getInt, responseCodeString.get()); + if (subscriptionIdentifier.isNotEmpty()) + changeExistingSubscription (skuDetails, subscriptionIdentifier, creditForUnusedSubscription); + else + purchaseProductWithSkuDetails (skuDetails); + } + } + }); - if (responseCode == 0) - { - auto buyIntentString = javaString ("BUY_INTENT"); - auto pendingIntent = LocalRef (env->CallObjectMethod (buyIntentBundle.get(), AndroidBundle.getParcelable, buyIntentString.get())); - - auto requestCode = 1001; - auto intentSender = LocalRef (env->CallObjectMethod (pendingIntent.get(), AndroidPendingIntent.getIntentSender)); - auto fillInIntent = LocalRef (env->NewObject (AndroidIntent, AndroidIntent.constructor)); - auto flagsMask = LocalRef (env->CallStaticObjectMethod (JavaInteger, JavaInteger.valueOf, 0)); - auto flagsValues = LocalRef (env->CallStaticObjectMethod (JavaInteger, JavaInteger.valueOf, 0)); - auto extraFlags = LocalRef (env->CallStaticObjectMethod (JavaInteger, JavaInteger.valueOf, 0)); - - env->CallVoidMethod (getCurrentActivity().get(), AndroidActivity.startIntentSenderForResult, intentSender.get(), requestCode, - fillInIntent.get(), flagsMask.get(), flagsValues.get(), extraFlags.get()); - } - else if (responseCode == 7) - { - // Item already bought. - notifyAboutPurchaseResult ({ {}, productIdentifier, juceString (getPackageName()), {}, {} }, true, statusCodeToUserString (responseCode)); - } + querySkuDetailsAsync (convertToLowerCase ({ productIdentifier })); } void restoreProductsBoughtList (bool, const juce::String&) { - auto callback = [this](const GetProductsBoughtJob::Result& r) + purchasesListQueryCallbackQueue.emplace ([this] (LocalRef purchasesList) { - const ScopedLock lock (getProductsBoughtJobResultsLock); - getProductsBoughtJobResults.insert (0, r); - triggerAsyncUpdate(); - }; + if (purchasesList != nullptr) + { + auto* env = getEnv(); + Array purchases; + + for (int i = 0; i < env->CallIntMethod (purchasesList, JavaArrayList.size); ++i) + { + LocalRef purchase (env->CallObjectMethod (purchasesList, JavaArrayList.get, i)); + purchases.add ({ buildPurchase (purchase), {} }); + } + + owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored (purchases, true, NEEDS_TRANS ("Success")); }); + } + else + { + owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored ({}, false, NEEDS_TRANS ("Failure")); }); + } + }); - threadPool->addJob (new GetProductsBoughtJob (*this, - getPackageName(), callback), true); + getProductsBoughtAsync(); } void consumePurchase (const String& productIdentifier, const String& purchaseToken) { - auto callback = [this](const ConsumePurchaseJob::Result& r) + if (purchaseToken.isEmpty()) { - const ScopedLock lock (consumePurchaseJobResultsLock); - consumePurchaseJobResults.insert (0, r); - triggerAsyncUpdate(); - }; + skuDetailsQueryCallbackQueue.emplace ([=] (LocalRef skuDetailsList) + { + if (skuDetailsList != nullptr) + { + auto* env = getEnv(); + + if (env->CallIntMethod (skuDetailsList, JavaList.size) > 0) + { + LocalRef sku (env->CallObjectMethod (skuDetailsList, JavaList.get, 0)); + + auto token = juceString (LocalRef ((jstring) env->CallObjectMethod (sku, AndroidPurchase.getSku))); + + if (token.isNotEmpty()) + { + consumePurchaseWithToken (productIdentifier, token); + return; + } + } + } + + notifyListenersAboutConsume (productIdentifier, false, NEEDS_TRANS ("Item unavailable")); + }); + + querySkuDetailsAsync (convertToLowerCase ({ productIdentifier })); + } - threadPool->addJob (new ConsumePurchaseJob (*this, getPackageName(), productIdentifier, - purchaseToken, callback), true); + consumePurchaseWithToken (productIdentifier, purchaseToken); } //============================================================================== - void startDownloads (const Array& downloads) + void startDownloads (const Array& downloads) { // Not available on this platform. ignoreUnused (downloads); jassertfalse; } - void pauseDownloads (const Array& downloads) + void pauseDownloads (const Array& downloads) { // Not available on this platform. ignoreUnused (downloads); jassertfalse; } - void resumeDownloads (const Array& downloads) + void resumeDownloads (const Array& downloads) { // Not available on this platform. ignoreUnused (downloads); jassertfalse; } - void cancelDownloads (const Array& downloads) + void cancelDownloads (const Array& downloads) { // Not available on this platform. ignoreUnused (downloads); jassertfalse; } - //============================================================================== - LocalRef getBuyIntentBundle (const String& productIdentifier, bool isSubscription, - const StringArray& subscriptionIdentifiers, bool creditForUnusedSubscription) +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 (InAppPurchases::Pimpl::skuDetailsQueryCallback, "skuDetailsQueryCallback", "(JLjava/util/List;)V") \ + CALLBACK (InAppPurchases::Pimpl::purchasesListQueryCallback, "purchasesListQueryCallback", "(JLjava/util/List;)V") \ + CALLBACK (InAppPurchases::Pimpl::purchaseCompletedCallback, "purchaseCompletedCallback", "(JLcom/android/billingclient/api/Purchase;I)V") \ + CALLBACK (InAppPurchases::Pimpl::purchaseConsumedCallback, "purchaseConsumedCallback", "(JLjava/lang/String;I)V") + + DECLARE_JNI_CLASS (JuceBillingClient, "com/roli/juce/JuceBillingClient") + #undef JNI_CLASS_MEMBERS + + static void JNICALL skuDetailsQueryCallback (JNIEnv*, jobject, jlong host, jobject skuDetailsList) { - auto* env = getEnv(); - - auto skuString = javaString (productIdentifier); - auto productTypeString = javaString (isSubscription ? "subs" : "inapp"); - auto devString = javaString (""); - - if (subscriptionIdentifiers.isEmpty()) - return LocalRef (inAppBillingService.callObjectMethod (IInAppBillingService.getBuyIntent, 3, - getPackageName().get(), skuString.get(), - productTypeString.get(), devString.get())); - - auto skuList = LocalRef (env->NewObject (JavaArrayList, JavaArrayList.constructor, - (int) subscriptionIdentifiers.size())); - - if (skuList.get() == 0) - { - jassertfalse; - return LocalRef (0); - } - - for (const auto& identifier : subscriptionIdentifiers) - env->CallBooleanMethod (skuList.get(), JavaArrayList.add, javaString (identifier).get()); - - auto extraParams = LocalRef (env->NewObject (AndroidBundle, AndroidBundle.constructor)); - - if (extraParams.get() == 0) - { - jassertfalse; - return LocalRef (0); - } - - auto skusToReplaceString = javaString ("skusToReplace"); - auto replaceSkusProrationString = javaString ("replaceSkusProration"); + if (auto* myself = reinterpret_cast (host)) + myself->updateSkuDetails (skuDetailsList); + } - env->CallVoidMethod (extraParams.get(), AndroidBundle.putStringArrayList, skusToReplaceString.get(), skuList.get()); - env->CallVoidMethod (extraParams.get(), AndroidBundle.putBoolean, replaceSkusProrationString.get(), creditForUnusedSubscription); + static void JNICALL purchasesListQueryCallback (JNIEnv*, jobject, jlong host, jobject purchasesList) + { + if (auto* myself = reinterpret_cast (host)) + myself->updatePurchasesList (purchasesList); + } - return LocalRef (inAppBillingService.callObjectMethod (IInAppBillingService.getBuyIntentExtraParams, 6, - getPackageName().get(), skuString.get(), - productTypeString.get(), devString.get(), - extraParams.get())); + static void JNICALL purchaseCompletedCallback (JNIEnv*, jobject, jlong host, jobject purchase, int responseCode) + { + if (auto* myself = reinterpret_cast (host)) + myself->purchaseCompleted (purchase, responseCode); } - //============================================================================== - void notifyAboutPurchaseResult (const InAppPurchases::Purchase& purchase, bool success, const String& statusDescription) + static void JNICALL purchaseConsumedCallback (JNIEnv*, jobject, jlong host, jstring productIdentifier, int responseCode) { - owner.listeners.call ([&] (Listener& l) { l.productPurchaseFinished ({ purchase, {} }, success, statusDescription); }); + if (auto* myself = reinterpret_cast (host)) + myself->purchaseConsumed (productIdentifier, responseCode); } //============================================================================== - bool checkIsReady() + bool isReady() const { - // It may take a few seconds for the in-app purchase service to connect - for (auto retries = 0; retries < 10 && inAppBillingService.get() == 0; ++retries) - Thread::sleep (500); - - return (inAppBillingService.get() != 0); + return getEnv()->CallBooleanMethod (billingClient, JuceBillingClient.isReady); } - static bool isInAppPurchasesSupported (jobject iapService) + bool checkIsReady() const { - if (iapService != nullptr) + for (int i = 0; i < 10; ++i) { - auto* env = getEnv(); - - auto inAppString = javaString ("inapp"); - auto subsString = javaString ("subs"); - - if (env->CallIntMethod (iapService, IInAppBillingService.isBillingSupported, 3, - getPackageName().get(), inAppString.get()) != 0) - return false; + if (isReady()) + return true; - if (env->CallIntMethod (iapService, IInAppBillingService.isBillingSupported, 3, - getPackageName().get(), subsString.get()) != 0) - return false; - - return true; + Thread::sleep (500); } - // Connecting to the in-app purchase server failed! This could have multiple reasons: - // 1) Your phone/emulator must support the google play store - // 2) Your phone must be logged into the google play store and be able to receive updates - // 3) It can take a few seconds after instantiation of the InAppPurchase class for - // in-app purchases to be avaialable on Android. return false; } //============================================================================== - void onServiceConnected (jobject, jobject iBinder) override + static StringArray convertToLowerCase (const StringArray& stringsToConvert) { - auto* env = getEnv(); - - LocalRef iapService (env->CallStaticObjectMethod (IInAppBillingServiceStub, - IInAppBillingServiceStub.asInterface, - iBinder)); + StringArray lowerCase; - if (isInAppPurchasesSupported (iapService)) - inAppBillingService = GlobalRef (iapService); + for (auto& s : stringsToConvert) + lowerCase.add (s.toLowerCase()); - // If you hit this assert, then in-app purchases is not available on your device, - // most likely due to too old version of Google Play API (hint: update Google Play on the device). - jassert (isInAppPurchasesSupported()); + return lowerCase; } - void onServiceDisconnected (jobject) override - { - inAppBillingService.clear(); - } - - //============================================================================== - static LocalRef getPackageName() + void querySkuDetailsAsync (const StringArray& productIdentifiers) { - return LocalRef ((jstring) (getEnv()->CallObjectMethod (getAppContext().get(), AndroidContext.getPackageName))); - } - - //============================================================================== - struct GetProductsInformationJob : public ThreadPoolJob - { - using Callback = std::function&)>; - - GetProductsInformationJob (Pimpl& parent, - const LocalRef& packageNameToUse, - const StringArray& productIdentifiersToUse, - const Callback& callbackToUse) - : ThreadPoolJob ("GetProductsInformationJob"), - owner (parent), - packageName (LocalRef (getEnv()->NewLocalRef (packageNameToUse.get()))), - productIdentifiers (productIdentifiersToUse), - callback (callbackToUse) - {} - - ThreadPoolJob::JobStatus runJob() override + Thread::launch ([=] { - jassert (callback); - - if (owner.checkIsReady()) - { - // Google's Billing API limitation - auto maxQuerySize = 20; - auto pi = 0; - - Array results; - StringArray identifiersToUse; - - for (auto i = 0; i < productIdentifiers.size(); ++i) - { - identifiersToUse.add (productIdentifiers[i].toLowerCase()); - ++pi; + if (! checkIsReady()) + return; - if (pi == maxQuerySize || i == productIdentifiers.size() - 1) - { - auto inAppProducts = processRetrievedProducts (queryProductsInformationFromService (identifiersToUse, "inapp")); - auto subsProducts = processRetrievedProducts (queryProductsInformationFromService (identifiersToUse, "subs")); - - results.addArray (inAppProducts); - results.addArray (subsProducts); - identifiersToUse.clear(); - pi = 0; - } - } - - if (callback) - callback (results); - } - else + MessageManager::callAsync ([=] { - if (callback) - callback ({}); - } - - return jobHasFinished; - } - - private: - LocalRef queryProductsInformationFromService (const StringArray& productIdentifiersToQuery, const String& productType) - { - auto* env = getEnv(); - - auto skuList = LocalRef (env->NewObject (JavaArrayList, JavaArrayList.constructor, productIdentifiersToQuery.size())); - - if (skuList.get() == 0) - return LocalRef (0); - - for (const auto& pi : productIdentifiersToQuery) - env->CallBooleanMethod (skuList.get(), JavaArrayList.add, javaString (pi).get()); - - auto querySkus = LocalRef (env->NewObject (AndroidBundle, AndroidBundle.constructor)); - - if (querySkus.get() == 0) - return LocalRef (0); - - auto itemIdListString = javaString ("ITEM_ID_LIST"); - - env->CallVoidMethod (querySkus.get(), AndroidBundle.putStringArrayList, itemIdListString.get(), skuList.get()); - - auto productTypeString = javaString (productType); - - auto productDetails = LocalRef (owner.inAppBillingService.callObjectMethod (IInAppBillingService.getSkuDetails, - 3, (jstring) packageName.get(), - productTypeString.get(), querySkus.get())); - - return productDetails; - } + getEnv()->CallVoidMethod (billingClient, + JuceBillingClient.querySkuDetails, + juceStringArrayToJava (productIdentifiers).get()); + }); + }); + } - Array processRetrievedProducts (LocalRef retrievedProducts) + void getProductsBoughtAsync() + { + Thread::launch ([=] { - Array products; + if (! checkIsReady()) + return; - if (owner.checkIsReady()) + MessageManager::callAsync ([=] { - auto* env = getEnv(); - - auto responseCodeString = javaString ("RESPONSE_CODE"); - - auto responseCode = env->CallIntMethod (retrievedProducts.get(), AndroidBundle.getInt, responseCodeString.get()); - - if (responseCode == 0) - { - auto detailsListString = javaString ("DETAILS_LIST"); - - auto responseList = LocalRef (env->CallObjectMethod (retrievedProducts.get(), AndroidBundle.getStringArrayList, - detailsListString.get())); - - if (responseList != 0) - { - auto iterator = LocalRef (env->CallObjectMethod (responseList.get(), JavaArrayList.iterator)); - - if (iterator.get() != 0) - { - for (;;) - { - if (! env->CallBooleanMethod (iterator, JavaIterator.hasNext)) - break; - - auto response = juce::LocalRef ((jstring)env->CallObjectMethod (iterator, JavaIterator.next)); - - if (response.get() != 0) - { - var responseData = JSON::parse (juceString (response.get())); - - if (DynamicObject* object = responseData.getDynamicObject()) - { - NamedValueSet& props = object->getProperties(); - - static Identifier productIdIdentifier ("productId"); - static Identifier titleIdentifier ("title"); - static Identifier descriptionIdentifier ("description"); - static Identifier priceIdentifier ("price"); - static Identifier priceCurrencyCodeIdentifier ("price_currency_code"); - - var productId = props[productIdIdentifier]; - var title = props[titleIdentifier]; - var description = props[descriptionIdentifier]; - var price = props[priceIdentifier]; - var priceCurrencyCode = props[priceCurrencyCodeIdentifier]; - - products.add ( { productId.toString(), - title.toString(), - description.toString(), - price.toString(), - priceCurrencyCode.toString() } ); - } - - } - } - } - } - } - } - - return products; - } - - Pimpl& owner; - GlobalRef packageName; - const StringArray productIdentifiers; - Callback callback; - }; + getEnv()->CallVoidMethod (billingClient, + JuceBillingClient.queryPurchases); + }); + }); + } //============================================================================== - struct GetProductsBoughtJob : public ThreadPoolJob + void notifyListenersAboutPurchase (const InAppPurchases::Purchase& purchase, bool success, const String& statusDescription) { - struct Result - { - bool success = false; - Array purchases; - String statusDescription; - }; - - using Callback = std::function; - - GetProductsBoughtJob (Pimpl& parent, - const LocalRef& packageNameToUse, - const Callback& callbackToUse) - : ThreadPoolJob ("GetProductsBoughtJob"), - owner (parent), - packageName (LocalRef (getEnv()->NewLocalRef (packageNameToUse.get()))), - callback (callbackToUse) - {} - - ThreadPoolJob::JobStatus runJob() override - { - jassert (callback); - - if (owner.checkIsReady()) - { - auto inAppPurchases = getProductsBought ("inapp", 0); - auto subsPurchases = getProductsBought ("subs", 0); - - inAppPurchases.addArray (subsPurchases); - - Array purchases; - - for (const auto& purchase : inAppPurchases) - purchases.add ({ purchase, {} }); - - if (callback) - callback ({true, purchases, "Success"}); - } - else - { - if (callback) - callback ({false, {}, "In-App purchases unavailable"}); - } - - return jobHasFinished; - } - - private: - Array getProductsBought (const String& productType, jstring continuationToken) - { - Array purchases; - auto* env = getEnv(); - - auto productTypeString = javaString (productType); - auto ownedItems = LocalRef (owner.inAppBillingService.callObjectMethod (IInAppBillingService.getPurchases, 3, - (jstring) packageName.get(), productTypeString.get(), - continuationToken)); - - if (ownedItems.get() != 0) - { - auto responseCodeString = javaString ("RESPONSE_CODE"); - auto responseCode = env->CallIntMethod (ownedItems.get(), AndroidBundle.getInt, responseCodeString.get()); - - if (responseCode == 0) - { - auto itemListString = javaString ("INAPP_PURCHASE_ITEM_LIST"); - auto dataListString = javaString ("INAPP_PURCHASE_DATA_LIST"); - auto signatureListString = javaString ("INAPP_DATA_SIGNATURE_LIST"); - auto continuationTokenString = javaString ("INAPP_CONTINUATION_TOKEN"); + owner.listeners.call ([&] (Listener& l) { l.productPurchaseFinished ({ purchase, {} }, success, statusDescription); }); + } - auto ownedSkus = LocalRef (env->CallObjectMethod (ownedItems.get(), AndroidBundle.getStringArrayList, itemListString.get())); - auto purchaseDataList = LocalRef (env->CallObjectMethod (ownedItems.get(), AndroidBundle.getStringArrayList, dataListString.get())); - auto signatureList = LocalRef (env->CallObjectMethod (ownedItems.get(), AndroidBundle.getStringArrayList, signatureListString.get())); - auto newContinuationToken = LocalRef ((jstring) env->CallObjectMethod (ownedItems.get(), AndroidBundle.getString, continuationTokenString.get())); + void notifyListenersAboutConsume (const String& productIdentifier, bool success, const String& statusDescription) + { + owner.listeners.call ([&] (Listener& l) { l.productConsumed (productIdentifier, success, statusDescription); }); + } - for (auto i = 0; i < env->CallIntMethod (purchaseDataList.get(), JavaArrayList.size); ++i) - { - auto sku = juceString ((jstring) (env->CallObjectMethod (ownedSkus.get(), JavaArrayList.get, i))); - auto purchaseData = juceString ((jstring) (env->CallObjectMethod (purchaseDataList.get(), JavaArrayList.get, i))); - auto signature = juceString ((jstring) (env->CallObjectMethod (signatureList.get(), JavaArrayList.get, i))); + LocalRef createBillingFlowParamsBuilder (LocalRef skuDetails) + { + auto* env = getEnv(); - var responseData = JSON::parse (purchaseData); + auto builder = LocalRef (env->CallStaticObjectMethod (BillingFlowParams, BillingFlowParams.newBuilder)); - if (auto* object = responseData.getDynamicObject()) - { - auto& props = object->getProperties(); - - static const Identifier orderIdIdentifier ("orderId"), - packageNameIdentifier ("packageName"), - productIdIdentifier ("productId"), - purchaseTimeIdentifier ("purchaseTime"), - purchaseTokenIdentifier ("purchaseToken"); - - var orderId = props[orderIdIdentifier]; - var appPackageName = props[packageNameIdentifier]; - var productId = props[productIdIdentifier]; - var purchaseTime = props[purchaseTimeIdentifier]; - var purchaseToken = props[purchaseTokenIdentifier]; - - String purchaseTimeString = Time (purchaseTime.toString().getLargeIntValue()).toString (true, true, true, true); - purchases.add ({ orderId.toString(), productId.toString(), appPackageName.toString(), purchaseTimeString, purchaseToken.toString() }); - } - } + return LocalRef (env->CallObjectMethod (builder.get(), + BillingFlowParamsBuilder.setSkuDetails, + skuDetails.get())); + } - if (newContinuationToken.get() != 0) - getProductsBought (productType, newContinuationToken.get()); - } - } + void launchBillingFlowWithParameters (LocalRef params) + { + LocalRef activity (getCurrentActivity()); - return purchases; - } + if (activity == nullptr) + activity = getMainActivity(); - Pimpl& owner; - GlobalRef packageName; - Callback callback; - }; + getEnv()->CallVoidMethod (billingClient, + JuceBillingClient.launchBillingFlow, + activity.get(), + params.get()); + } - //============================================================================== - class ConsumePurchaseJob : public ThreadPoolJob + void changeExistingSubscription (LocalRef skuDetails, const String& subscriptionIdentifier, bool creditForUnusedSubscription) { - public: - struct Result - { - bool success = false; - String productIdentifier; - String statusDescription; - }; - - using Callback = std::function; - - ConsumePurchaseJob (Pimpl& parent, - const LocalRef& packageNameToUse, - const String& productIdentifierToUse, - const String& purchaseTokenToUse, - const Callback& callbackToUse) - : ThreadPoolJob ("ConsumePurchaseJob"), - owner (parent), - packageName (LocalRef (getEnv()->NewLocalRef (packageNameToUse.get()))), - productIdentifier (productIdentifierToUse), - purchaseToken (purchaseTokenToUse), - callback (callbackToUse) - {} - - ThreadPoolJob::JobStatus runJob() override + if (! isReady()) { - jassert (callback); - - if (owner.checkIsReady()) - { - auto token = (! purchaseToken.isEmpty() ? purchaseToken : getPurchaseTokenForProductId (productIdentifier, false, 0)); - - if (token.isEmpty()) - { - if (callback) - callback ({ false, productIdentifier, NEEDS_TRANS ("Item not owned") }); - - return jobHasFinished; - } - - auto responseCode = owner.inAppBillingService.callIntMethod (IInAppBillingService.consumePurchase, 3, - (jstring)packageName.get(), javaString (token).get()); - - if (callback) - callback ({ responseCode == 0, productIdentifier, statusCodeToUserString (responseCode) }); - } - else - { - if (callback) - callback ({false, {}, "In-App purchases unavailable"}); - } - - return jobHasFinished; + notifyListenersAboutPurchase ({}, false, NEEDS_TRANS ("In-App purchases unavailable")); + return; } - private: - String getPurchaseTokenForProductId (const String productIdToLookFor, bool isSubscription, jstring continuationToken) + purchasesListQueryCallbackQueue.emplace ([=] (LocalRef purchasesList) { - auto productTypeString = javaString (isSubscription ? "subs" : "inapp"); - auto ownedItems = LocalRef (owner.inAppBillingService.callObjectMethod (IInAppBillingService.getPurchases, 3, - (jstring) packageName.get(), productTypeString.get(), - continuationToken)); - - if (ownedItems.get() != 0) + if (purchasesList != nullptr) { auto* env = getEnv(); - auto responseCodeString = javaString ("RESPONSE_CODE"); - auto responseCode = env->CallIntMethod (ownedItems.get(), AndroidBundle.getInt, responseCodeString.get()); - - if (responseCode == 0) + for (int i = 0; i < env->CallIntMethod (purchasesList, JavaArrayList.size); ++i) { - auto dataListString = javaString ("INAPP_PURCHASE_DATA_LIST"); - auto continuationTokenString = javaString ("INAPP_CONTINUATION_TOKEN"); + auto purchase = buildPurchase (LocalRef (env->CallObjectMethod (purchasesList.get(), JavaArrayList.get, i))); - auto purchaseDataList = LocalRef (env->CallObjectMethod (ownedItems.get(), AndroidBundle.getStringArrayList, dataListString.get())); - auto newContinuationToken = LocalRef ((jstring) env->CallObjectMethod (ownedItems.get(), AndroidBundle.getString, continuationTokenString.get())); - - for (auto i = 0; i < env->CallIntMethod (purchaseDataList.get(), JavaArrayList.size); ++i) + if (purchase.productId == subscriptionIdentifier) { - auto purchaseData = juceString ((jstring) (env->CallObjectMethod (purchaseDataList.get(), JavaArrayList.get, i))); - - var responseData = JSON::parse (purchaseData); + auto builder = createBillingFlowParamsBuilder (skuDetails); - if (auto* object = responseData.getDynamicObject()) - { - static const Identifier productIdIdentifier ("productId"), - purchaseTokenIdentifier ("purchaseToken"); + builder = LocalRef (env->CallObjectMethod (builder.get(), + BillingFlowParamsBuilder.setOldSku, + javaString (subscriptionIdentifier).get(), + javaString (purchase.purchaseToken).get())); - auto& props = object->getProperties(); - var productId = props[productIdIdentifier]; + if (! creditForUnusedSubscription) + builder = LocalRef (env->CallObjectMethod (builder.get(), + BillingFlowParamsBuilder.setReplaceSkusProrationMode, + 3 /*IMMEDIATE_WITHOUT_PRORATION*/)); - if (productId.toString() == productIdToLookFor) - return props[purchaseTokenIdentifier].toString(); - } + launchBillingFlowWithParameters (LocalRef (env->CallObjectMethod (builder.get(), + BillingFlowParamsBuilder.build))); } - - if (newContinuationToken.get() != 0) - return getPurchaseTokenForProductId (productIdToLookFor, isSubscription, newContinuationToken.get()); } } - return {}; - } + notifyListenersAboutPurchase ({}, false, NEEDS_TRANS ("Unable to get subscription details")); + }); - Pimpl& owner; - GlobalRef packageName; - const String productIdentifier, purchaseToken; - Callback callback; - }; + getProductsBoughtAsync(); + } - //============================================================================== - void handleAsyncUpdate() override + void purchaseProductWithSkuDetails (LocalRef skuDetails) { + if (! isReady()) { - const ScopedLock lock (getProductsInformationJobResultsLock); - - for (int i = getProductsInformationJobResults.size(); --i >= 0;) - { - const auto& result = getProductsInformationJobResults.getReference (i); - - owner.listeners.call ([&] (Listener& l) { l.productsInfoReturned (result); }); - getProductsInformationJobResults.remove (i); - } + notifyListenersAboutPurchase ({}, false, NEEDS_TRANS ("In-App purchases unavailable")); + return; } - { - const ScopedLock lock (getProductsBoughtJobResultsLock); - - for (int i = getProductsBoughtJobResults.size(); --i >= 0;) - { - const auto& result = getProductsBoughtJobResults.getReference (i); - - owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored (result.purchases, result.success, result.statusDescription); }); - getProductsBoughtJobResults.remove (i); - } - } + launchBillingFlowWithParameters (LocalRef (getEnv()->CallObjectMethod (createBillingFlowParamsBuilder (skuDetails).get(), + BillingFlowParamsBuilder.build))); + } + void consumePurchaseWithToken (const String& productIdentifier, const String& purchaseToken) + { + if (! isReady()) { - const ScopedLock lock (consumePurchaseJobResultsLock); - - for (int i = consumePurchaseJobResults.size(); --i >= 0;) - { - const auto& result = consumePurchaseJobResults.getReference (i); - - owner.listeners.call ([&] (Listener& l) { l.productConsumed (result.productIdentifier, result.success, result.statusDescription); }); - consumePurchaseJobResults.remove (i); - } + notifyListenersAboutConsume (productIdentifier, false, NEEDS_TRANS ("In-App purchases unavailable")); + return; } + + getEnv()->CallObjectMethod (billingClient, + JuceBillingClient.consumePurchase, + LocalRef (javaString (productIdentifier)).get(), + LocalRef (javaString (purchaseToken)).get()); } //============================================================================== - void inAppPurchaseCompleted (jobject intentData) + static InAppPurchases::Purchase buildPurchase (LocalRef purchase) { + if (purchase == nullptr) + return {}; + auto* env = getEnv(); - auto inAppPurchaseDataString = javaString ("INAPP_PURCHASE_DATA"); - auto inAppDataSignatureString = javaString ("INAPP_DATA_SIGNATURE"); - auto responseCodeString = javaString ("RESPONSE_CODE"); + 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))), + Time (env->CallLongMethod (purchase, AndroidPurchase.getPurchaseTime)).toString (true, true, true, true), + juceString (LocalRef ((jstring) env->CallObjectMethod (purchase, AndroidPurchase.getPurchaseToken))) }; + } - auto pd = LocalRef ((jstring) env->CallObjectMethod (intentData, AndroidIntent.getStringExtra, inAppPurchaseDataString.get())); - auto sig = LocalRef ((jstring) env->CallObjectMethod (intentData, AndroidIntent.getStringExtra, inAppDataSignatureString.get())); - auto purchaseDataString = pd.get() != 0 ? juceString (pd.get()) : String(); - auto dataSignatureString = sig.get() != 0 ? juceString (sig.get()) : String(); + static InAppPurchases::Product buildProduct (LocalRef productSkuDetails) + { + if (productSkuDetails == nullptr) + return {}; - var responseData = JSON::parse (purchaseDataString); + auto* env = getEnv(); - auto responseCode = env->CallIntMethod (intentData, AndroidIntent.getIntExtra, responseCodeString.get()); - auto statusCodeUserString = statusCodeToUserString (responseCode); + 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 (auto* object = responseData.getDynamicObject()) + static String getStatusDescriptionFromResponseCode (int responseCode) + { + switch (responseCode) { - auto& props = object->getProperties(); - - static const Identifier orderIdIdentifier ("orderId"), - packageNameIdentifier ("packageName"), - productIdIdentifier ("productId"), - purchaseTimeIdentifier ("purchaseTime"), - purchaseTokenIdentifier ("purchaseToken"), - developerPayloadIdentifier ("developerPayload"); - - var orderId = props[orderIdIdentifier]; - var packageName = props[packageNameIdentifier]; - var productId = props[productIdIdentifier]; - var purchaseTime = props[purchaseTimeIdentifier]; - var purchaseToken = props[purchaseTokenIdentifier]; - var developerPayload = props[developerPayloadIdentifier]; - - auto purchaseTimeString = Time (purchaseTime.toString().getLargeIntValue()) - .toString (true, true, true, true); - - notifyAboutPurchaseResult ({ orderId.toString(), productId.toString(), packageName.toString(), - purchaseTimeString, purchaseToken.toString() }, - true, statusCodeUserString); - return; + case 0: return NEEDS_TRANS ("Success"); + case 1: return NEEDS_TRANS ("Cancelled by user"); + case 2: return NEEDS_TRANS ("Service unavailable"); + case 3: return NEEDS_TRANS ("Billing unavailable"); + case 4: return NEEDS_TRANS ("Item unavailable"); + case 5: return NEEDS_TRANS ("Internal error"); + case 6: return NEEDS_TRANS ("Generic error"); + case 7: return NEEDS_TRANS ("Item already owned"); + case 8: return NEEDS_TRANS ("Item not owned"); + default: return NEEDS_TRANS ("Unknown status"); } + } - notifyAboutPurchaseResult ({}, false, statusCodeUserString); + static bool wasSuccessful (int responseCode) + { + return responseCode == 0; } - //============================================================================== - static String statusCodeToUserString (int statusCode) + void purchaseCompleted (jobject purchase, int responseCode) { - switch (statusCode) - { - case 0: return NEEDS_TRANS ("Success"); - case 1: return NEEDS_TRANS ("Cancelled by user"); - case 2: return NEEDS_TRANS ("Service unavailable"); - case 3: return NEEDS_TRANS ("Billing unavailable"); - case 4: return NEEDS_TRANS ("Item unavailable"); - case 5: return NEEDS_TRANS ("Internal error"); - case 6: return NEEDS_TRANS ("Generic error"); - case 7: return NEEDS_TRANS ("Item already owned"); - case 8: return NEEDS_TRANS ("Item not owned"); - default: jassertfalse; return NEEDS_TRANS ("Unknown status"); - } + notifyListenersAboutPurchase (buildPurchase (LocalRef (purchase)), + wasSuccessful (responseCode), + getStatusDescriptionFromResponseCode (responseCode)); + } + + void purchaseConsumed (jstring productIdentifier, int responseCode) + { + notifyListenersAboutConsume (juceString (LocalRef (productIdentifier)), + wasSuccessful (responseCode), + getStatusDescriptionFromResponseCode (responseCode)); + } + + void updateSkuDetails (jobject skuDetailsList) + { + jassert (! skuDetailsQueryCallbackQueue.empty()); + skuDetailsQueryCallbackQueue.front() (LocalRef (skuDetailsList)); + skuDetailsQueryCallbackQueue.pop(); + } + + void updatePurchasesList (jobject purchasesList) + { + jassert (! purchasesListQueryCallbackQueue.empty()); + purchasesListQueryCallbackQueue.front() (LocalRef (purchasesList)); + purchasesListQueryCallbackQueue.pop(); } //============================================================================== InAppPurchases& owner; - GlobalRef inAppBillingService, serviceConnection; - std::unique_ptr threadPool; + GlobalRef billingClient; - CriticalSection getProductsInformationJobResultsLock, - getProductsBoughtJobResultsLock, - consumePurchaseJobResultsLock; + std::queue)>> skuDetailsQueryCallbackQueue, + purchasesListQueryCallbackQueue; - Array> getProductsInformationJobResults; - Array getProductsBoughtJobResults; - Array consumePurchaseJobResults; + //============================================================================== + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Pimpl) }; -//============================================================================== -void juce_inAppPurchaseCompleted (void* intentData) -{ - if (auto* instance = InAppPurchases::getInstance()) - instance->pimpl->inAppPurchaseCompleted (static_cast (intentData)); -} +InAppPurchases::Pimpl::JuceBillingClient_Class InAppPurchases::Pimpl::JuceBillingClient; } // namespace juce diff --git a/modules/juce_product_unlocking/native/juce_ios_InAppPurchases.cpp b/modules/juce_product_unlocking/native/juce_ios_InAppPurchases.cpp index cc25f5ec51..3d66bd0600 100644 --- a/modules/juce_product_unlocking/native/juce_ios_InAppPurchases.cpp +++ b/modules/juce_product_unlocking/native/juce_ios_InAppPurchases.cpp @@ -196,7 +196,7 @@ struct InAppPurchases::Pimpl : public SKDelegateAndPaymentObserver [productsRequest start]; } - void purchaseProduct (const String& productIdentifier, bool, const StringArray&, bool) + void purchaseProduct (const String& productIdentifier, const String&, bool) { if (! [SKPaymentQueue canMakePayments]) {