@@ -52,6 +52,19 @@ | |||||
#include "../Assets/DemoUtilities.h" | #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 | class VoicePurchases : private InAppPurchases::Listener | ||||
{ | { | ||||
@@ -108,7 +121,7 @@ public: | |||||
purchaseInProgress = true; | purchaseInProgress = true; | ||||
product.purchaseInProgress = true; | product.purchaseInProgress = true; | ||||
InAppPurchases::getInstance()->purchaseProduct (product.identifier, false); | |||||
InAppPurchases::getInstance()->purchaseProduct (product.identifier); | |||||
guiUpdater.triggerAsyncUpdate(); | guiUpdater.triggerAsyncUpdate(); | ||||
} | } | ||||
@@ -140,9 +140,9 @@ public: | |||||
androidKeyStorePass (settings, Ids::androidKeyStorePass, getUndoManager(), "android"), | androidKeyStorePass (settings, Ids::androidKeyStorePass, getUndoManager(), "android"), | ||||
androidKeyAlias (settings, Ids::androidKeyAlias, getUndoManager(), "androiddebugkey"), | androidKeyAlias (settings, Ids::androidKeyAlias, getUndoManager(), "androiddebugkey"), | ||||
androidKeyAliasPass (settings, Ids::androidKeyAliasPass, getUndoManager(), "android"), | 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"), | 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()) | AndroidExecutable (getAppSettings().getStoredPath (Ids::androidStudioExePath, TargetOS::getThisOS()).get().toString()) | ||||
{ | { | ||||
name = getName(); | name = getName(); | ||||
@@ -840,6 +840,9 @@ private: | |||||
for (auto& d : StringArray::fromLines (androidJavaLibs.get().toString())) | for (auto& d : StringArray::fromLines (androidJavaLibs.get().toString())) | ||||
mo << " implementation files('libs/" << File (d).getFileName() << "')" << newLine; | mo << " implementation files('libs/" << File (d).getFileName() << "')" << newLine; | ||||
if (isInAppBillingEnabled()) | |||||
mo << " implementation 'com.android.billingclient:billing:2.1.0'" << newLine; | |||||
if (areRemoteNotificationsEnabled()) | if (areRemoteNotificationsEnabled()) | ||||
{ | { | ||||
mo << " implementation 'com.google.firebase:firebase-core:16.0.1'" << newLine; | mo << " implementation 'com.google.firebase:firebase-core:16.0.1'" << newLine; | ||||
@@ -1215,6 +1218,8 @@ private: | |||||
bool arePushNotificationsEnabled() const { return androidPushNotifications.get(); } | bool arePushNotificationsEnabled() const { return androidPushNotifications.get(); } | ||||
bool areRemoteNotificationsEnabled() const { return arePushNotificationsEnabled() && androidEnableRemoteNotifications.get(); } | bool areRemoteNotificationsEnabled() const { return arePushNotificationsEnabled() && androidEnableRemoteNotifications.get(); } | ||||
bool isInAppBillingEnabled() const { return androidInAppBillingPermission.get(); } | |||||
String getJNIActivityClassName() const | String getJNIActivityClassName() const | ||||
{ | { | ||||
return getActivityClassString().replaceCharacter ('.', '/'); | return getActivityClassString().replaceCharacter ('.', '/'); | ||||
@@ -1436,7 +1441,7 @@ private: | |||||
defines.set ("JUCE_PUSH_NOTIFICATIONS_ACTIVITY", getJNIActivityClassName().quoted()); | defines.set ("JUCE_PUSH_NOTIFICATIONS_ACTIVITY", getJNIActivityClassName().quoted()); | ||||
} | } | ||||
if (androidInAppBillingPermission.get()) | |||||
if (isInAppBillingEnabled()) | |||||
defines.set ("JUCE_IN_APP_PURCHASES", "1"); | defines.set ("JUCE_IN_APP_PURCHASES", "1"); | ||||
if (supportsGLv3()) | if (supportsGLv3()) | ||||
@@ -1820,7 +1825,7 @@ private: | |||||
if (androidExternalWritePermission.get()) | if (androidExternalWritePermission.get()) | ||||
s.add ("android.permission.WRITE_EXTERNAL_STORAGE"); | s.add ("android.permission.WRITE_EXTERNAL_STORAGE"); | ||||
if (androidInAppBillingPermission.get()) | |||||
if (isInAppBillingEnabled()) | |||||
s.add ("com.android.vending.BILLING"); | s.add ("com.android.vending.BILLING"); | ||||
if (androidVibratePermission.get()) | if (androidVibratePermission.get()) | ||||
@@ -58,6 +58,7 @@ | |||||
#include <unordered_set> | #include <unordered_set> | ||||
#include <mutex> | #include <mutex> | ||||
#include <condition_variable> | #include <condition_variable> | ||||
#include <queue> | |||||
//============================================================================== | //============================================================================== | ||||
#include "juce_CompilerSupport.h" | #include "juce_CompilerSupport.h" | ||||
@@ -196,10 +196,6 @@ static const uint8 javaComponentPeerView[] = | |||||
extern void juce_firebaseRemoteMessageSendError (void*, void*); | extern void juce_firebaseRemoteMessageSendError (void*, void*); | ||||
#endif | #endif | ||||
#if JUCE_IN_APP_PURCHASES && JUCE_MODULE_AVAILABLE_juce_product_unlocking | |||||
extern void juce_inAppPurchaseCompleted (void*); | |||||
#endif | |||||
extern void juce_contentSharingCompleted (int); | extern void juce_contentSharingCompleted (int); | ||||
//============================================================================== | //============================================================================== | ||||
@@ -61,13 +61,11 @@ void InAppPurchases::getProductsInformation (const StringArray& productIdentifie | |||||
} | } | ||||
void InAppPurchases::purchaseProduct (const String& productIdentifier, | void InAppPurchases::purchaseProduct (const String& productIdentifier, | ||||
bool isSubscription, | |||||
const StringArray& upgradeProductIdentifiers, | |||||
const String& upgradeProductIdentifier, | |||||
bool creditForUnusedSubscription) | bool creditForUnusedSubscription) | ||||
{ | { | ||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC | #if JUCE_ANDROID || JUCE_IOS || JUCE_MAC | ||||
pimpl->purchaseProduct (productIdentifier, isSubscription, | |||||
upgradeProductIdentifiers, creditForUnusedSubscription); | |||||
pimpl->purchaseProduct (productIdentifier, upgradeProductIdentifier, creditForUnusedSubscription); | |||||
#else | #else | ||||
Listener::PurchaseInfo purchaseInfo { Purchase { "", productIdentifier, {}, {}, {} }, {} }; | Listener::PurchaseInfo purchaseInfo { Purchase { "", productIdentifier, {}, {}, {} }, {} }; | ||||
@@ -200,19 +200,15 @@ public: | |||||
@param productIdentifier The product identifier. | @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 | @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, | void purchaseProduct (const String& productIdentifier, | ||||
bool isSubscription, | |||||
const StringArray& upgradeOrDowngradeFromSubscriptionsWithProductIdentifiers = {}, | |||||
const String& upgradeOrDowngradeFromSubscriptionWithProductIdentifier = {}, | |||||
bool creditForUnusedSubscription = true); | 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::purchasesListReceived() | ||||
@@ -260,6 +256,22 @@ public: | |||||
/** iOS only: Cancels downloads of hosted content from the store. */ | /** iOS only: Cancels downloads of hosted content from the store. */ | ||||
void cancelDownloads (const Array<Download*>& downloads); | 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: | private: | ||||
//============================================================================== | //============================================================================== | ||||
#ifndef DOXYGEN | #ifndef DOXYGEN | ||||
@@ -41,7 +41,7 @@ | |||||
website: http://www.juce.com/juce | website: http://www.juce.com/juce | ||||
license: GPL/Commercial | license: GPL/Commercial | ||||
dependencies: juce_cryptography juce_core | |||||
dependencies: juce_cryptography juce_core, juce_events | |||||
END_JUCE_MODULE_DECLARATION | END_JUCE_MODULE_DECLARATION | ||||
@@ -68,6 +68,7 @@ | |||||
//============================================================================== | //============================================================================== | ||||
#include <juce_core/juce_core.h> | #include <juce_core/juce_core.h> | ||||
#include <juce_cryptography/juce_cryptography.h> | #include <juce_cryptography/juce_cryptography.h> | ||||
#include <juce_events/juce_events.h> | |||||
#if JUCE_MODULE_AVAILABLE_juce_data_structures | #if JUCE_MODULE_AVAILABLE_juce_data_structures | ||||
#include <juce_data_structures/juce_data_structures.h> | #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]; | [productsRequest start]; | ||||
} | } | ||||
void purchaseProduct (const String& productIdentifier, bool, const StringArray&, bool) | |||||
void purchaseProduct (const String& productIdentifier, const String&, bool) | |||||
{ | { | ||||
if (! [SKPaymentQueue canMakePayments]) | if (! [SKPaymentQueue canMakePayments]) | ||||
{ | { | ||||