@@ -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]) | |||
{ | |||