| @@ -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(); | |||
| } | |||
| @@ -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()) | |||
| @@ -58,6 +58,7 @@ | |||
| #include <unordered_set> | |||
| #include <mutex> | |||
| #include <condition_variable> | |||
| #include <queue> | |||
| //============================================================================== | |||
| #include "juce_CompilerSupport.h" | |||
| @@ -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); | |||
| //============================================================================== | |||
| @@ -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, {}, {}, {} }, {} }; | |||
| @@ -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<Download*>& 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 | |||
| @@ -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 <juce_core/juce_core.h> | |||
| #include <juce_cryptography/juce_cryptography.h> | |||
| #include <juce_events/juce_events.h> | |||
| #if JUCE_MODULE_AVAILABLE_juce_data_structures | |||
| #include <juce_data_structures/juce_data_structures.h> | |||
| @@ -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> skuDetails); | |||
| private native void purchasesListQueryCallback(long host, List<Purchase> 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<String> 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<SkuDetails> 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<SkuDetails> 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<Purchase> 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<Purchase> 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; | |||
| } | |||
| @@ -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]) | |||
| { | |||