diff --git a/extras/Projucer/Source/Project Saving/jucer_ProjectExport_Android.h b/extras/Projucer/Source/Project Saving/jucer_ProjectExport_Android.h index 63445916dc..50d4fc9e31 100644 --- a/extras/Projucer/Source/Project Saving/jucer_ProjectExport_Android.h +++ b/extras/Projucer/Source/Project Saving/jucer_ProjectExport_Android.h @@ -101,7 +101,8 @@ public: androidSharedLibraries, androidStaticLibraries, androidExtraAssetsFolder; CachedValue androidInternetNeeded, androidMicNeeded, androidBluetoothNeeded, - androidExternalReadPermission, androidExternalWritePermission; + androidExternalReadPermission, androidExternalWritePermission, + androidInAppBillingPermission; CachedValue androidOtherPermissions; CachedValue androidKeyStore, androidKeyStorePass, androidKeyAlias, androidKeyAliasPass; @@ -128,6 +129,7 @@ public: androidBluetoothNeeded (settings, Ids::androidBluetoothNeeded, nullptr, true), androidExternalReadPermission (settings, Ids::androidExternalReadNeeded, nullptr, true), androidExternalWritePermission (settings, Ids::androidExternalWriteNeeded, nullptr, true), + androidInAppBillingPermission (settings, Ids::androidInAppBilling, nullptr, false), androidOtherPermissions (settings, Ids::androidOtherPermissions, nullptr), androidKeyStore (settings, Ids::androidKeyStore, nullptr, "${user.home}/.android/debug.keystore"), androidKeyStorePass (settings, Ids::androidKeyStorePass, nullptr, "android"), @@ -201,14 +203,8 @@ public: removeOldFiles (targetFolder); - { - const String package (getActivityClassPackage()); - const String path (package.replaceCharacter ('.', File::separator)); - const File javaTarget (targetFolder.getChildFile ("app/src/main/java").getChildFile (path)); - - if (! isLibrary()) - copyActivityJavaFiles (modules, javaTarget, package); - } + if (! isLibrary()) + copyJavaFiles (modules); copyExtraResourceFiles(); @@ -827,6 +823,9 @@ private: props.add (new BooleanPropertyComponent (androidExternalWritePermission.getPropertyAsValue(), "Write to external storage", "Specify permissions to write to external storage"), "If enabled, this will set the android.permission.WRITE_EXTERNAL_STORAGE flag in the manifest."); + props.add (new BooleanPropertyComponent (androidInAppBillingPermission.getPropertyAsValue(), "In-App Billing", "Specify In-App Billing permission in the manifest"), + "If enabled, this will set the com.android.vending.BILLING flag in the manifest."); + props.add (new TextPropertyComponent (androidOtherPermissions.getPropertyAsValue(), "Custom permissions", 2048, false), "A space-separated list of other permission flags that should be added to the manifest."); @@ -879,78 +878,106 @@ private: } //============================================================================== - void copyActivityJavaFiles (const OwnedArray& modules, const File& targetFolder, const String& package) const + void copyJavaFiles (const OwnedArray& modules) const + { + if (auto* coreModule = getCoreModule (modules)) + { + auto package = getActivityClassPackage(); + auto targetFolder = getTargetFolder(); + + auto inAppBillingPath = String ("com.android.vending.billing").replaceCharacter ('.', File::separator); + auto javaSourceFolder = coreModule->getFolder().getChildFile ("native").getChildFile ("java"); + auto javaInAppBillingTarget = targetFolder.getChildFile ("app/src/main/java").getChildFile (inAppBillingPath); + auto javaActivityTarget = targetFolder.getChildFile ("app/src/main/java") + .getChildFile (package.replaceCharacter ('.', File::separator)); + + copyActivityJavaFiles (javaSourceFolder, javaActivityTarget, package); + copyAdditionalJavaFiles (javaSourceFolder, javaInAppBillingTarget); + } + } + + void copyActivityJavaFiles (const File& javaSourceFolder, const File& targetFolder, const String& package) const { if (androidActivityClass.get().contains ("_")) throw SaveError ("Your Android activity class name or path may not contain any underscores! Try a project name without underscores."); - const String className (getActivityName()); + auto className = getActivityName(); if (className.isEmpty()) throw SaveError ("Invalid Android Activity class name: " + androidActivityClass.get()); createDirectoryOrThrow (targetFolder); - if (auto* coreModule = getCoreModule (modules)) - { - File javaDestFile (targetFolder.getChildFile (className + ".java")); + auto javaDestFile = targetFolder.getChildFile (className + ".java"); - File javaSourceFolder (coreModule->getFolder().getChildFile ("native") - .getChildFile ("java")); - String juceMidiCode, juceMidiImports, juceRuntimePermissionsCode; + String juceMidiCode, juceMidiImports, juceRuntimePermissionsCode; - juceMidiImports << newLine; + juceMidiImports << newLine; - if (androidMinimumSDK.get().getIntValue() >= 23) - { - File javaAndroidMidi (javaSourceFolder.getChildFile ("AndroidMidi.java")); - File javaRuntimePermissions (javaSourceFolder.getChildFile ("AndroidRuntimePermissions.java")); + if (androidMinimumSDK.get().getIntValue() >= 23) + { + auto javaAndroidMidi = javaSourceFolder.getChildFile ("AndroidMidi.java"); + auto javaRuntimePermissions = javaSourceFolder.getChildFile ("AndroidRuntimePermissions.java"); - juceMidiImports << "import android.media.midi.*;" << newLine - << "import android.bluetooth.*;" << newLine - << "import android.bluetooth.le.*;" << newLine; + juceMidiImports << "import android.media.midi.*;" << newLine + << "import android.bluetooth.*;" << newLine + << "import android.bluetooth.le.*;" << newLine; - juceMidiCode = javaAndroidMidi.loadFileAsString().replace ("JuceAppActivity", className); + juceMidiCode = javaAndroidMidi.loadFileAsString().replace ("JuceAppActivity", className); - juceRuntimePermissionsCode = javaRuntimePermissions.loadFileAsString().replace ("JuceAppActivity", className); - } - else + juceRuntimePermissionsCode = javaRuntimePermissions.loadFileAsString().replace ("JuceAppActivity", className); + } + else + { + juceMidiCode = javaSourceFolder.getChildFile ("AndroidMidiFallback.java") + .loadFileAsString() + .replace ("JuceAppActivity", className); + } + + auto javaSourceFile = javaSourceFolder.getChildFile ("JuceAppActivity.java"); + auto javaSourceLines = StringArray::fromLines (javaSourceFile.loadFileAsString()); + + { + MemoryOutputStream newFile; + + for (const auto& line : javaSourceLines) { - juceMidiCode = javaSourceFolder.getChildFile ("AndroidMidiFallback.java") - .loadFileAsString() - .replace ("JuceAppActivity", className); + if (line.contains ("$$JuceAndroidMidiImports$$")) + newFile << juceMidiImports; + else if (line.contains ("$$JuceAndroidMidiCode$$")) + newFile << juceMidiCode; + else if (line.contains ("$$JuceAndroidRuntimePermissionsCode$$")) + newFile << juceRuntimePermissionsCode; + else + newFile << line.replace ("JuceAppActivity", className) + .replace ("package com.juce;", "package " + package + ";") << newLine; } - auto javaSourceFile = javaSourceFolder.getChildFile ("JuceAppActivity.java"); - auto javaSourceLines = StringArray::fromLines (javaSourceFile.loadFileAsString()); + javaSourceLines = StringArray::fromLines (newFile.toString()); + } - { - MemoryOutputStream newFile; + while (javaSourceLines.size() > 2 + && javaSourceLines[javaSourceLines.size() - 1].trim().isEmpty() + && javaSourceLines[javaSourceLines.size() - 2].trim().isEmpty()) + javaSourceLines.remove (javaSourceLines.size() - 1); - for (const auto& line : javaSourceLines) - { - if (line.contains ("$$JuceAndroidMidiImports$$")) - newFile << juceMidiImports; - else if (line.contains ("$$JuceAndroidMidiCode$$")) - newFile << juceMidiCode; - else if (line.contains ("$$JuceAndroidRuntimePermissionsCode$$")) - newFile << juceRuntimePermissionsCode; - else - newFile << line.replace ("JuceAppActivity", className) - .replace ("package com.juce;", "package " + package + ";") << newLine; - } + overwriteFileIfDifferentOrThrow (javaDestFile, javaSourceLines.joinIntoString (newLine)); + } - javaSourceLines = StringArray::fromLines (newFile.toString()); - } + void copyAdditionalJavaFiles (const File& sourceFolder, const File& targetFolder) const + { + auto inAppBillingJavaFileName = String ("IInAppBillingService.java"); - while (javaSourceLines.size() > 2 - && javaSourceLines[javaSourceLines.size() - 1].trim().isEmpty() - && javaSourceLines[javaSourceLines.size() - 2].trim().isEmpty()) - javaSourceLines.remove (javaSourceLines.size() - 1); + auto inAppBillingJavaSrcFile = sourceFolder.getChildFile (inAppBillingJavaFileName); + auto inAppBillingJavaDestFile = targetFolder.getChildFile (inAppBillingJavaFileName); - overwriteFileIfDifferentOrThrow (javaDestFile, javaSourceLines.joinIntoString (newLine)); - } + createDirectoryOrThrow (targetFolder); + + jassert (inAppBillingJavaSrcFile.existsAsFile()); + + if (inAppBillingJavaSrcFile.existsAsFile()) + inAppBillingJavaSrcFile.copyFileTo (inAppBillingJavaDestFile); } void copyExtraResourceFiles() const @@ -1422,6 +1449,9 @@ private: if (androidExternalWritePermission.get()) s.add ("android.permission.WRITE_EXTERNAL_STORAGE"); + if (androidInAppBillingPermission.get()) + s.add ("com.android.vending.BILLING"); + return getCleanedStringArray (s); } diff --git a/extras/Projucer/Source/Utility/jucer_PresetIDs.h b/extras/Projucer/Source/Utility/jucer_PresetIDs.h index ba76993b29..562ea33567 100644 --- a/extras/Projucer/Source/Utility/jucer_PresetIDs.h +++ b/extras/Projucer/Source/Utility/jucer_PresetIDs.h @@ -180,6 +180,7 @@ namespace Ids DECLARE_ID (androidBluetoothNeeded); DECLARE_ID (androidExternalReadNeeded); DECLARE_ID (androidExternalWriteNeeded); + DECLARE_ID (androidInAppBilling); DECLARE_ID (androidMinimumSDK); DECLARE_ID (androidOtherPermissions); DECLARE_ID (androidKeyStore); diff --git a/modules/juce_core/native/java/IInAppBillingService.java b/modules/juce_core/native/java/IInAppBillingService.java new file mode 100644 index 0000000000..0bb31cb5d3 --- /dev/null +++ b/modules/juce_core/native/java/IInAppBillingService.java @@ -0,0 +1,971 @@ +/* + ============================================================================== + + This file is part of the JUCE library. + Copyright (c) 2017 - ROLI Ltd. + + JUCE is an open source library subject to commercial or open-source + licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + To use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +package com.android.vending.billing; +/** + * InAppBillingService is the service that provides in-app billing version 3 and beyond. + * This service provides the following features: + * 1. Provides a new API to get details of in-app items published for the app including + * price, type, title and description. + * 2. The purchase flow is synchronous and purchase information is available immediately + * after it completes. + * 3. Purchase information of in-app purchases is maintained within the Google Play system + * till the purchase is consumed. + * 4. An API to consume a purchase of an inapp item. All purchases of one-time + * in-app items are consumable and thereafter can be purchased again. + * 5. An API to get current purchases of the user immediately. This will not contain any + * consumed purchases. + * + * All calls will give a response code with the following possible values + * RESULT_OK = 0 - success + * RESULT_USER_CANCELED = 1 - User pressed back or canceled a dialog + * RESULT_SERVICE_UNAVAILABLE = 2 - The network connection is down + * RESULT_BILLING_UNAVAILABLE = 3 - This billing API version is not supported for the type requested + * RESULT_ITEM_UNAVAILABLE = 4 - Requested SKU is not available for purchase + * RESULT_DEVELOPER_ERROR = 5 - Invalid arguments provided to the API + * RESULT_ERROR = 6 - Fatal error during the API action + * RESULT_ITEM_ALREADY_OWNED = 7 - Failure to purchase since item is already owned + * RESULT_ITEM_NOT_OWNED = 8 - Failure to consume since item is not owned + */ +public interface IInAppBillingService extends android.os.IInterface + { + /** Local-side IPC implementation stub class. */ + public static abstract class Stub extends android.os.Binder implements com.android.vending.billing.IInAppBillingService + { + private static final java.lang.String DESCRIPTOR = "com.android.vending.billing.IInAppBillingService"; + /** Construct the stub at attach it to the interface. */ + public Stub() + { + this.attachInterface(this, DESCRIPTOR); + } + /** + * Cast an IBinder object into an com.android.vending.billing.IInAppBillingService interface, + * generating a proxy if needed. + */ + public static com.android.vending.billing.IInAppBillingService asInterface(android.os.IBinder obj) + { + if ((obj==null)) { + return null; + } + android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR); + if (((iin!=null)&&(iin instanceof com.android.vending.billing.IInAppBillingService))) { + return ((com.android.vending.billing.IInAppBillingService)iin); + } + return new com.android.vending.billing.IInAppBillingService.Stub.Proxy(obj); + } + @Override public android.os.IBinder asBinder() + { + return this; + } + @Override public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException + { + switch (code) + { + case INTERFACE_TRANSACTION: + { + reply.writeString(DESCRIPTOR); + return true; + } + case TRANSACTION_isBillingSupported: + { + data.enforceInterface(DESCRIPTOR); + int _arg0; + _arg0 = data.readInt(); + java.lang.String _arg1; + _arg1 = data.readString(); + java.lang.String _arg2; + _arg2 = data.readString(); + int _result = this.isBillingSupported(_arg0, _arg1, _arg2); + reply.writeNoException(); + reply.writeInt(_result); + return true; + } + case TRANSACTION_getSkuDetails: + { + data.enforceInterface(DESCRIPTOR); + int _arg0; + _arg0 = data.readInt(); + java.lang.String _arg1; + _arg1 = data.readString(); + java.lang.String _arg2; + _arg2 = data.readString(); + android.os.Bundle _arg3; + if ((0!=data.readInt())) { + _arg3 = android.os.Bundle.CREATOR.createFromParcel(data); + } + else { + _arg3 = null; + } + android.os.Bundle _result = this.getSkuDetails(_arg0, _arg1, _arg2, _arg3); + reply.writeNoException(); + if ((_result!=null)) { + reply.writeInt(1); + _result.writeToParcel(reply, android.os.Parcelable.PARCELABLE_WRITE_RETURN_VALUE); + } + else { + reply.writeInt(0); + } + return true; + } + case TRANSACTION_getBuyIntent: + { + data.enforceInterface(DESCRIPTOR); + int _arg0; + _arg0 = data.readInt(); + java.lang.String _arg1; + _arg1 = data.readString(); + java.lang.String _arg2; + _arg2 = data.readString(); + java.lang.String _arg3; + _arg3 = data.readString(); + java.lang.String _arg4; + _arg4 = data.readString(); + android.os.Bundle _result = this.getBuyIntent(_arg0, _arg1, _arg2, _arg3, _arg4); + reply.writeNoException(); + if ((_result!=null)) { + reply.writeInt(1); + _result.writeToParcel(reply, android.os.Parcelable.PARCELABLE_WRITE_RETURN_VALUE); + } + else { + reply.writeInt(0); + } + return true; + } + case TRANSACTION_getPurchases: + { + data.enforceInterface(DESCRIPTOR); + int _arg0; + _arg0 = data.readInt(); + java.lang.String _arg1; + _arg1 = data.readString(); + java.lang.String _arg2; + _arg2 = data.readString(); + java.lang.String _arg3; + _arg3 = data.readString(); + android.os.Bundle _result = this.getPurchases(_arg0, _arg1, _arg2, _arg3); + reply.writeNoException(); + if ((_result!=null)) { + reply.writeInt(1); + _result.writeToParcel(reply, android.os.Parcelable.PARCELABLE_WRITE_RETURN_VALUE); + } + else { + reply.writeInt(0); + } + return true; + } + case TRANSACTION_consumePurchase: + { + data.enforceInterface(DESCRIPTOR); + int _arg0; + _arg0 = data.readInt(); + java.lang.String _arg1; + _arg1 = data.readString(); + java.lang.String _arg2; + _arg2 = data.readString(); + int _result = this.consumePurchase(_arg0, _arg1, _arg2); + reply.writeNoException(); + reply.writeInt(_result); + return true; + } + case TRANSACTION_stub: + { + data.enforceInterface(DESCRIPTOR); + int _arg0; + _arg0 = data.readInt(); + java.lang.String _arg1; + _arg1 = data.readString(); + java.lang.String _arg2; + _arg2 = data.readString(); + int _result = this.stub(_arg0, _arg1, _arg2); + reply.writeNoException(); + reply.writeInt(_result); + return true; + } + case TRANSACTION_getBuyIntentToReplaceSkus: + { + data.enforceInterface(DESCRIPTOR); + int _arg0; + _arg0 = data.readInt(); + java.lang.String _arg1; + _arg1 = data.readString(); + java.util.List _arg2; + _arg2 = data.createStringArrayList(); + java.lang.String _arg3; + _arg3 = data.readString(); + java.lang.String _arg4; + _arg4 = data.readString(); + java.lang.String _arg5; + _arg5 = data.readString(); + android.os.Bundle _result = this.getBuyIntentToReplaceSkus(_arg0, _arg1, _arg2, _arg3, _arg4, _arg5); + reply.writeNoException(); + if ((_result!=null)) { + reply.writeInt(1); + _result.writeToParcel(reply, android.os.Parcelable.PARCELABLE_WRITE_RETURN_VALUE); + } + else { + reply.writeInt(0); + } + return true; + } + case TRANSACTION_getBuyIntentExtraParams: + { + data.enforceInterface(DESCRIPTOR); + int _arg0; + _arg0 = data.readInt(); + java.lang.String _arg1; + _arg1 = data.readString(); + java.lang.String _arg2; + _arg2 = data.readString(); + java.lang.String _arg3; + _arg3 = data.readString(); + java.lang.String _arg4; + _arg4 = data.readString(); + android.os.Bundle _arg5; + if ((0!=data.readInt())) { + _arg5 = android.os.Bundle.CREATOR.createFromParcel(data); + } + else { + _arg5 = null; + } + android.os.Bundle _result = this.getBuyIntentExtraParams(_arg0, _arg1, _arg2, _arg3, _arg4, _arg5); + reply.writeNoException(); + if ((_result!=null)) { + reply.writeInt(1); + _result.writeToParcel(reply, android.os.Parcelable.PARCELABLE_WRITE_RETURN_VALUE); + } + else { + reply.writeInt(0); + } + return true; + } + case TRANSACTION_getPurchaseHistory: + { + data.enforceInterface(DESCRIPTOR); + int _arg0; + _arg0 = data.readInt(); + java.lang.String _arg1; + _arg1 = data.readString(); + java.lang.String _arg2; + _arg2 = data.readString(); + java.lang.String _arg3; + _arg3 = data.readString(); + android.os.Bundle _arg4; + if ((0!=data.readInt())) { + _arg4 = android.os.Bundle.CREATOR.createFromParcel(data); + } + else { + _arg4 = null; + } + android.os.Bundle _result = this.getPurchaseHistory(_arg0, _arg1, _arg2, _arg3, _arg4); + reply.writeNoException(); + if ((_result!=null)) { + reply.writeInt(1); + _result.writeToParcel(reply, android.os.Parcelable.PARCELABLE_WRITE_RETURN_VALUE); + } + else { + reply.writeInt(0); + } + return true; + } + case TRANSACTION_isBillingSupportedExtraParams: + { + data.enforceInterface(DESCRIPTOR); + int _arg0; + _arg0 = data.readInt(); + java.lang.String _arg1; + _arg1 = data.readString(); + java.lang.String _arg2; + _arg2 = data.readString(); + android.os.Bundle _arg3; + if ((0!=data.readInt())) { + _arg3 = android.os.Bundle.CREATOR.createFromParcel(data); + } + else { + _arg3 = null; + } + int _result = this.isBillingSupportedExtraParams(_arg0, _arg1, _arg2, _arg3); + reply.writeNoException(); + reply.writeInt(_result); + return true; + } + } + return super.onTransact(code, data, reply, flags); + } + private static class Proxy implements com.android.vending.billing.IInAppBillingService + { + private android.os.IBinder mRemote; + Proxy(android.os.IBinder remote) + { + mRemote = remote; + } + @Override public android.os.IBinder asBinder() + { + return mRemote; + } + public java.lang.String getInterfaceDescriptor() + { + return DESCRIPTOR; + } + @Override public int isBillingSupported(int apiVersion, java.lang.String packageName, java.lang.String type) throws android.os.RemoteException + { + android.os.Parcel _data = android.os.Parcel.obtain(); + android.os.Parcel _reply = android.os.Parcel.obtain(); + int _result; + try { + _data.writeInterfaceToken(DESCRIPTOR); + _data.writeInt(apiVersion); + _data.writeString(packageName); + _data.writeString(type); + mRemote.transact(Stub.TRANSACTION_isBillingSupported, _data, _reply, 0); + _reply.readException(); + _result = _reply.readInt(); + } + finally { + _reply.recycle(); + _data.recycle(); + } + return _result; + } + /** + * Provides details of a list of SKUs + * Given a list of SKUs of a valid type in the skusBundle, this returns a bundle + * with a list JSON strings containing the productId, price, title and description. + * This API can be called with a maximum of 20 SKUs. + * @param apiVersion billing API version that the app is using + * @param packageName the package name of the calling app + * @param type of the in-app items ("inapp" for one-time purchases + * and "subs" for subscriptions) + * @param skusBundle bundle containing a StringArrayList of SKUs with key "ITEM_ID_LIST" + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes + * on failures. + * "DETAILS_LIST" with a StringArrayList containing purchase information + * in JSON format similar to: + * '{ "productId" : "exampleSku", + * "type" : "inapp", + * "price" : "$5.00", + * "price_currency": "USD", + * "price_amount_micros": 5000000, + * "title : "Example Title", + * "description" : "This is an example description" }' + */ + @Override public android.os.Bundle getSkuDetails(int apiVersion, java.lang.String packageName, java.lang.String type, android.os.Bundle skusBundle) throws android.os.RemoteException + { + android.os.Parcel _data = android.os.Parcel.obtain(); + android.os.Parcel _reply = android.os.Parcel.obtain(); + android.os.Bundle _result; + try { + _data.writeInterfaceToken(DESCRIPTOR); + _data.writeInt(apiVersion); + _data.writeString(packageName); + _data.writeString(type); + if ((skusBundle!=null)) { + _data.writeInt(1); + skusBundle.writeToParcel(_data, 0); + } + else { + _data.writeInt(0); + } + mRemote.transact(Stub.TRANSACTION_getSkuDetails, _data, _reply, 0); + _reply.readException(); + if ((0!=_reply.readInt())) { + _result = android.os.Bundle.CREATOR.createFromParcel(_reply); + } + else { + _result = null; + } + } + finally { + _reply.recycle(); + _data.recycle(); + } + return _result; + } + /** + * Returns a pending intent to launch the purchase flow for an in-app item by providing a SKU, + * the type, a unique purchase token and an optional developer payload. + * @param apiVersion billing API version that the app is using + * @param packageName package name of the calling app + * @param sku the SKU of the in-app item as published in the developer console + * @param type of the in-app item being purchased ("inapp" for one-time purchases + * and "subs" for subscriptions) + * @param developerPayload optional argument to be sent back with the purchase information + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes + * on failures. + * "BUY_INTENT" - PendingIntent to start the purchase flow + * + * The Pending intent should be launched with startIntentSenderForResult. When purchase flow + * has completed, the onActivityResult() will give a resultCode of OK or CANCELED. + * If the purchase is successful, the result data will contain the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response + * codes on failures. + * "INAPP_PURCHASE_DATA" - String in JSON format similar to + * '{"orderId":"12999763169054705758.1371079406387615", + * "packageName":"com.example.app", + * "productId":"exampleSku", + * "purchaseTime":1345678900000, + * "purchaseToken" : "122333444455555", + * "developerPayload":"example developer payload" }' + * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that + * was signed with the private key of the developer + */ + @Override public android.os.Bundle getBuyIntent(int apiVersion, java.lang.String packageName, java.lang.String sku, java.lang.String type, java.lang.String developerPayload) throws android.os.RemoteException + { + android.os.Parcel _data = android.os.Parcel.obtain(); + android.os.Parcel _reply = android.os.Parcel.obtain(); + android.os.Bundle _result; + try { + _data.writeInterfaceToken(DESCRIPTOR); + _data.writeInt(apiVersion); + _data.writeString(packageName); + _data.writeString(sku); + _data.writeString(type); + _data.writeString(developerPayload); + mRemote.transact(Stub.TRANSACTION_getBuyIntent, _data, _reply, 0); + _reply.readException(); + if ((0!=_reply.readInt())) { + _result = android.os.Bundle.CREATOR.createFromParcel(_reply); + } + else { + _result = null; + } + } + finally { + _reply.recycle(); + _data.recycle(); + } + return _result; + } + /** + * Returns the current SKUs owned by the user of the type and package name specified along with + * purchase information and a signature of the data to be validated. + * This will return all SKUs that have been purchased in V3 and managed items purchased using + * V1 and V2 that have not been consumed. + * @param apiVersion billing API version that the app is using + * @param packageName package name of the calling app + * @param type of the in-app items being requested ("inapp" for one-time purchases + * and "subs" for subscriptions) + * @param continuationToken to be set as null for the first call, if the number of owned + * skus are too many, a continuationToken is returned in the response bundle. + * This method can be called again with the continuation token to get the next set of + * owned skus. + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes + on failures. + * "INAPP_PURCHASE_ITEM_LIST" - StringArrayList containing the list of SKUs + * "INAPP_PURCHASE_DATA_LIST" - StringArrayList containing the purchase information + * "INAPP_DATA_SIGNATURE_LIST"- StringArrayList containing the signatures + * of the purchase information + * "INAPP_CONTINUATION_TOKEN" - String containing a continuation token for the + * next set of in-app purchases. Only set if the + * user has more owned skus than the current list. + */ + @Override public android.os.Bundle getPurchases(int apiVersion, java.lang.String packageName, java.lang.String type, java.lang.String continuationToken) throws android.os.RemoteException + { + android.os.Parcel _data = android.os.Parcel.obtain(); + android.os.Parcel _reply = android.os.Parcel.obtain(); + android.os.Bundle _result; + try { + _data.writeInterfaceToken(DESCRIPTOR); + _data.writeInt(apiVersion); + _data.writeString(packageName); + _data.writeString(type); + _data.writeString(continuationToken); + mRemote.transact(Stub.TRANSACTION_getPurchases, _data, _reply, 0); + _reply.readException(); + if ((0!=_reply.readInt())) { + _result = android.os.Bundle.CREATOR.createFromParcel(_reply); + } + else { + _result = null; + } + } + finally { + _reply.recycle(); + _data.recycle(); + } + return _result; + } + @Override public int consumePurchase(int apiVersion, java.lang.String packageName, java.lang.String purchaseToken) throws android.os.RemoteException + { + android.os.Parcel _data = android.os.Parcel.obtain(); + android.os.Parcel _reply = android.os.Parcel.obtain(); + int _result; + try { + _data.writeInterfaceToken(DESCRIPTOR); + _data.writeInt(apiVersion); + _data.writeString(packageName); + _data.writeString(purchaseToken); + mRemote.transact(Stub.TRANSACTION_consumePurchase, _data, _reply, 0); + _reply.readException(); + _result = _reply.readInt(); + } + finally { + _reply.recycle(); + _data.recycle(); + } + return _result; + } + @Override public int stub(int apiVersion, java.lang.String packageName, java.lang.String type) throws android.os.RemoteException + { + android.os.Parcel _data = android.os.Parcel.obtain(); + android.os.Parcel _reply = android.os.Parcel.obtain(); + int _result; + try { + _data.writeInterfaceToken(DESCRIPTOR); + _data.writeInt(apiVersion); + _data.writeString(packageName); + _data.writeString(type); + mRemote.transact(Stub.TRANSACTION_stub, _data, _reply, 0); + _reply.readException(); + _result = _reply.readInt(); + } + finally { + _reply.recycle(); + _data.recycle(); + } + return _result; + } + /** + * Returns a pending intent to launch the purchase flow for upgrading or downgrading a + * subscription. The existing owned SKU(s) should be provided along with the new SKU that + * the user is upgrading or downgrading to. + * @param apiVersion billing API version that the app is using, must be 5 or later + * @param packageName package name of the calling app + * @param oldSkus the SKU(s) that the user is upgrading or downgrading from, + * if null or empty this method will behave like {@link #getBuyIntent} + * @param newSku the SKU that the user is upgrading or downgrading to + * @param type of the item being purchased, currently must be "subs" + * @param developerPayload optional argument to be sent back with the purchase information + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes + * on failures. + * "BUY_INTENT" - PendingIntent to start the purchase flow + * + * The Pending intent should be launched with startIntentSenderForResult. When purchase flow + * has completed, the onActivityResult() will give a resultCode of OK or CANCELED. + * If the purchase is successful, the result data will contain the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response + * codes on failures. + * "INAPP_PURCHASE_DATA" - String in JSON format similar to + * '{"orderId":"12999763169054705758.1371079406387615", + * "packageName":"com.example.app", + * "productId":"exampleSku", + * "purchaseTime":1345678900000, + * "purchaseToken" : "122333444455555", + * "developerPayload":"example developer payload" }' + * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that + * was signed with the private key of the developer + */ + @Override public android.os.Bundle getBuyIntentToReplaceSkus(int apiVersion, java.lang.String packageName, java.util.List oldSkus, java.lang.String newSku, java.lang.String type, java.lang.String developerPayload) throws android.os.RemoteException + { + android.os.Parcel _data = android.os.Parcel.obtain(); + android.os.Parcel _reply = android.os.Parcel.obtain(); + android.os.Bundle _result; + try { + _data.writeInterfaceToken(DESCRIPTOR); + _data.writeInt(apiVersion); + _data.writeString(packageName); + _data.writeStringList(oldSkus); + _data.writeString(newSku); + _data.writeString(type); + _data.writeString(developerPayload); + mRemote.transact(Stub.TRANSACTION_getBuyIntentToReplaceSkus, _data, _reply, 0); + _reply.readException(); + if ((0!=_reply.readInt())) { + _result = android.os.Bundle.CREATOR.createFromParcel(_reply); + } + else { + _result = null; + } + } + finally { + _reply.recycle(); + _data.recycle(); + } + return _result; + } + /** + * Returns a pending intent to launch the purchase flow for an in-app item. This method is + * a variant of the {@link #getBuyIntent} method and takes an additional {@code extraParams} + * parameter. This parameter is a Bundle of optional keys and values that affect the + * operation of the method. + * @param apiVersion billing API version that the app is using, must be 6 or later + * @param packageName package name of the calling app + * @param sku the SKU of the in-app item as published in the developer console + * @param type of the in-app item being purchased ("inapp" for one-time purchases + * and "subs" for subscriptions) + * @param developerPayload optional argument to be sent back with the purchase information + * @extraParams a Bundle with the following optional keys: + * "skusToReplace" - List - an optional list of SKUs that the user is + * upgrading or downgrading from. + * Pass this field if the purchase is upgrading or downgrading + * existing subscriptions. + * The specified SKUs are replaced with the SKUs that the user is + * purchasing. Google Play replaces the specified SKUs at the start of + * the next billing cycle. + * "replaceSkusProration" - Boolean - whether the user should be credited for any unused + * subscription time on the SKUs they are upgrading or downgrading. + * If you set this field to true, Google Play swaps out the old SKUs + * and credits the user with the unused value of their subscription + * time on a pro-rated basis. + * Google Play applies this credit to the new subscription, and does + * not begin billing the user for the new subscription until after + * the credit is used up. + * If you set this field to false, the user does not receive credit for + * any unused subscription time and the recurrence date does not + * change. + * Default value is true. Ignored if you do not pass skusToReplace. + * "accountId" - String - an optional obfuscated string that is uniquely + * associated with the user's account in your app. + * If you pass this value, Google Play can use it to detect irregular + * activity, such as many devices making purchases on the same + * account in a short period of time. + * Do not use the developer ID or the user's Google ID for this field. + * In addition, this field should not contain the user's ID in + * cleartext. + * We recommend that you use a one-way hash to generate a string from + * the user's ID, and store the hashed string in this field. + * "vr" - Boolean - an optional flag indicating whether the returned intent + * should start a VR purchase flow. The apiVersion must also be 7 or + * later to use this flag. + */ + @Override public android.os.Bundle getBuyIntentExtraParams(int apiVersion, java.lang.String packageName, java.lang.String sku, java.lang.String type, java.lang.String developerPayload, android.os.Bundle extraParams) throws android.os.RemoteException + { + android.os.Parcel _data = android.os.Parcel.obtain(); + android.os.Parcel _reply = android.os.Parcel.obtain(); + android.os.Bundle _result; + try { + _data.writeInterfaceToken(DESCRIPTOR); + _data.writeInt(apiVersion); + _data.writeString(packageName); + _data.writeString(sku); + _data.writeString(type); + _data.writeString(developerPayload); + if ((extraParams!=null)) { + _data.writeInt(1); + extraParams.writeToParcel(_data, 0); + } + else { + _data.writeInt(0); + } + mRemote.transact(Stub.TRANSACTION_getBuyIntentExtraParams, _data, _reply, 0); + _reply.readException(); + if ((0!=_reply.readInt())) { + _result = android.os.Bundle.CREATOR.createFromParcel(_reply); + } + else { + _result = null; + } + } + finally { + _reply.recycle(); + _data.recycle(); + } + return _result; + } + /** + * Returns the most recent purchase made by the user for each SKU, even if that purchase is + * expired, canceled, or consumed. + * @param apiVersion billing API version that the app is using, must be 6 or later + * @param packageName package name of the calling app + * @param type of the in-app items being requested ("inapp" for one-time purchases + * and "subs" for subscriptions) + * @param continuationToken to be set as null for the first call, if the number of owned + * skus is too large, a continuationToken is returned in the response bundle. + * This method can be called again with the continuation token to get the next set of + * owned skus. + * @param extraParams a Bundle with extra params that would be appended into http request + * query string. Not used at this moment. Reserved for future functionality. + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value: RESULT_OK(0) if success, + * {@link IabHelper#BILLING_RESPONSE_RESULT_*} response codes on failures. + * + * "INAPP_PURCHASE_ITEM_LIST" - ArrayList containing the list of SKUs + * "INAPP_PURCHASE_DATA_LIST" - ArrayList containing the purchase information + * "INAPP_DATA_SIGNATURE_LIST"- ArrayList containing the signatures + * of the purchase information + * "INAPP_CONTINUATION_TOKEN" - String containing a continuation token for the + * next set of in-app purchases. Only set if the + * user has more owned skus than the current list. + */ + @Override public android.os.Bundle getPurchaseHistory(int apiVersion, java.lang.String packageName, java.lang.String type, java.lang.String continuationToken, android.os.Bundle extraParams) throws android.os.RemoteException + { + android.os.Parcel _data = android.os.Parcel.obtain(); + android.os.Parcel _reply = android.os.Parcel.obtain(); + android.os.Bundle _result; + try { + _data.writeInterfaceToken(DESCRIPTOR); + _data.writeInt(apiVersion); + _data.writeString(packageName); + _data.writeString(type); + _data.writeString(continuationToken); + if ((extraParams!=null)) { + _data.writeInt(1); + extraParams.writeToParcel(_data, 0); + } + else { + _data.writeInt(0); + } + mRemote.transact(Stub.TRANSACTION_getPurchaseHistory, _data, _reply, 0); + _reply.readException(); + if ((0!=_reply.readInt())) { + _result = android.os.Bundle.CREATOR.createFromParcel(_reply); + } + else { + _result = null; + } + } + finally { + _reply.recycle(); + _data.recycle(); + } + return _result; + } + @Override public int isBillingSupportedExtraParams(int apiVersion, java.lang.String packageName, java.lang.String type, android.os.Bundle extraParams) throws android.os.RemoteException + { + android.os.Parcel _data = android.os.Parcel.obtain(); + android.os.Parcel _reply = android.os.Parcel.obtain(); + int _result; + try { + _data.writeInterfaceToken(DESCRIPTOR); + _data.writeInt(apiVersion); + _data.writeString(packageName); + _data.writeString(type); + if ((extraParams!=null)) { + _data.writeInt(1); + extraParams.writeToParcel(_data, 0); + } + else { + _data.writeInt(0); + } + mRemote.transact(Stub.TRANSACTION_isBillingSupportedExtraParams, _data, _reply, 0); + _reply.readException(); + _result = _reply.readInt(); + } + finally { + _reply.recycle(); + _data.recycle(); + } + return _result; + } + } + static final int TRANSACTION_isBillingSupported = (android.os.IBinder.FIRST_CALL_TRANSACTION + 0); + static final int TRANSACTION_getSkuDetails = (android.os.IBinder.FIRST_CALL_TRANSACTION + 1); + static final int TRANSACTION_getBuyIntent = (android.os.IBinder.FIRST_CALL_TRANSACTION + 2); + static final int TRANSACTION_getPurchases = (android.os.IBinder.FIRST_CALL_TRANSACTION + 3); + static final int TRANSACTION_consumePurchase = (android.os.IBinder.FIRST_CALL_TRANSACTION + 4); + static final int TRANSACTION_stub = (android.os.IBinder.FIRST_CALL_TRANSACTION + 5); + static final int TRANSACTION_getBuyIntentToReplaceSkus = (android.os.IBinder.FIRST_CALL_TRANSACTION + 6); + static final int TRANSACTION_getBuyIntentExtraParams = (android.os.IBinder.FIRST_CALL_TRANSACTION + 7); + static final int TRANSACTION_getPurchaseHistory = (android.os.IBinder.FIRST_CALL_TRANSACTION + 8); + static final int TRANSACTION_isBillingSupportedExtraParams = (android.os.IBinder.FIRST_CALL_TRANSACTION + 9); + } + public int isBillingSupported(int apiVersion, java.lang.String packageName, java.lang.String type) throws android.os.RemoteException; + /** + * Provides details of a list of SKUs + * Given a list of SKUs of a valid type in the skusBundle, this returns a bundle + * with a list JSON strings containing the productId, price, title and description. + * This API can be called with a maximum of 20 SKUs. + * @param apiVersion billing API version that the app is using + * @param packageName the package name of the calling app + * @param type of the in-app items ("inapp" for one-time purchases + * and "subs" for subscriptions) + * @param skusBundle bundle containing a StringArrayList of SKUs with key "ITEM_ID_LIST" + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes + * on failures. + * "DETAILS_LIST" with a StringArrayList containing purchase information + * in JSON format similar to: + * '{ "productId" : "exampleSku", + * "type" : "inapp", + * "price" : "$5.00", + * "price_currency": "USD", + * "price_amount_micros": 5000000, + * "title : "Example Title", + * "description" : "This is an example description" }' + */ + public android.os.Bundle getSkuDetails(int apiVersion, java.lang.String packageName, java.lang.String type, android.os.Bundle skusBundle) throws android.os.RemoteException; + /** + * Returns a pending intent to launch the purchase flow for an in-app item by providing a SKU, + * the type, a unique purchase token and an optional developer payload. + * @param apiVersion billing API version that the app is using + * @param packageName package name of the calling app + * @param sku the SKU of the in-app item as published in the developer console + * @param type of the in-app item being purchased ("inapp" for one-time purchases + * and "subs" for subscriptions) + * @param developerPayload optional argument to be sent back with the purchase information + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes + * on failures. + * "BUY_INTENT" - PendingIntent to start the purchase flow + * + * The Pending intent should be launched with startIntentSenderForResult. When purchase flow + * has completed, the onActivityResult() will give a resultCode of OK or CANCELED. + * If the purchase is successful, the result data will contain the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response + * codes on failures. + * "INAPP_PURCHASE_DATA" - String in JSON format similar to + * '{"orderId":"12999763169054705758.1371079406387615", + * "packageName":"com.example.app", + * "productId":"exampleSku", + * "purchaseTime":1345678900000, + * "purchaseToken" : "122333444455555", + * "developerPayload":"example developer payload" }' + * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that + * was signed with the private key of the developer + */ + public android.os.Bundle getBuyIntent(int apiVersion, java.lang.String packageName, java.lang.String sku, java.lang.String type, java.lang.String developerPayload) throws android.os.RemoteException; + /** + * Returns the current SKUs owned by the user of the type and package name specified along with + * purchase information and a signature of the data to be validated. + * This will return all SKUs that have been purchased in V3 and managed items purchased using + * V1 and V2 that have not been consumed. + * @param apiVersion billing API version that the app is using + * @param packageName package name of the calling app + * @param type of the in-app items being requested ("inapp" for one-time purchases + * and "subs" for subscriptions) + * @param continuationToken to be set as null for the first call, if the number of owned + * skus are too many, a continuationToken is returned in the response bundle. + * This method can be called again with the continuation token to get the next set of + * owned skus. + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes + on failures. + * "INAPP_PURCHASE_ITEM_LIST" - StringArrayList containing the list of SKUs + * "INAPP_PURCHASE_DATA_LIST" - StringArrayList containing the purchase information + * "INAPP_DATA_SIGNATURE_LIST"- StringArrayList containing the signatures + * of the purchase information + * "INAPP_CONTINUATION_TOKEN" - String containing a continuation token for the + * next set of in-app purchases. Only set if the + * user has more owned skus than the current list. + */ + public android.os.Bundle getPurchases(int apiVersion, java.lang.String packageName, java.lang.String type, java.lang.String continuationToken) throws android.os.RemoteException; + public int consumePurchase(int apiVersion, java.lang.String packageName, java.lang.String purchaseToken) throws android.os.RemoteException; + public int stub(int apiVersion, java.lang.String packageName, java.lang.String type) throws android.os.RemoteException; + /** + * Returns a pending intent to launch the purchase flow for upgrading or downgrading a + * subscription. The existing owned SKU(s) should be provided along with the new SKU that + * the user is upgrading or downgrading to. + * @param apiVersion billing API version that the app is using, must be 5 or later + * @param packageName package name of the calling app + * @param oldSkus the SKU(s) that the user is upgrading or downgrading from, + * if null or empty this method will behave like {@link #getBuyIntent} + * @param newSku the SKU that the user is upgrading or downgrading to + * @param type of the item being purchased, currently must be "subs" + * @param developerPayload optional argument to be sent back with the purchase information + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes + * on failures. + * "BUY_INTENT" - PendingIntent to start the purchase flow + * + * The Pending intent should be launched with startIntentSenderForResult. When purchase flow + * has completed, the onActivityResult() will give a resultCode of OK or CANCELED. + * If the purchase is successful, the result data will contain the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response + * codes on failures. + * "INAPP_PURCHASE_DATA" - String in JSON format similar to + * '{"orderId":"12999763169054705758.1371079406387615", + * "packageName":"com.example.app", + * "productId":"exampleSku", + * "purchaseTime":1345678900000, + * "purchaseToken" : "122333444455555", + * "developerPayload":"example developer payload" }' + * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that + * was signed with the private key of the developer + */ + public android.os.Bundle getBuyIntentToReplaceSkus(int apiVersion, java.lang.String packageName, java.util.List oldSkus, java.lang.String newSku, java.lang.String type, java.lang.String developerPayload) throws android.os.RemoteException; + /** + * Returns a pending intent to launch the purchase flow for an in-app item. This method is + * a variant of the {@link #getBuyIntent} method and takes an additional {@code extraParams} + * parameter. This parameter is a Bundle of optional keys and values that affect the + * operation of the method. + * @param apiVersion billing API version that the app is using, must be 6 or later + * @param packageName package name of the calling app + * @param sku the SKU of the in-app item as published in the developer console + * @param type of the in-app item being purchased ("inapp" for one-time purchases + * and "subs" for subscriptions) + * @param developerPayload optional argument to be sent back with the purchase information + * @extraParams a Bundle with the following optional keys: + * "skusToReplace" - List - an optional list of SKUs that the user is + * upgrading or downgrading from. + * Pass this field if the purchase is upgrading or downgrading + * existing subscriptions. + * The specified SKUs are replaced with the SKUs that the user is + * purchasing. Google Play replaces the specified SKUs at the start of + * the next billing cycle. + * "replaceSkusProration" - Boolean - whether the user should be credited for any unused + * subscription time on the SKUs they are upgrading or downgrading. + * If you set this field to true, Google Play swaps out the old SKUs + * and credits the user with the unused value of their subscription + * time on a pro-rated basis. + * Google Play applies this credit to the new subscription, and does + * not begin billing the user for the new subscription until after + * the credit is used up. + * If you set this field to false, the user does not receive credit for + * any unused subscription time and the recurrence date does not + * change. + * Default value is true. Ignored if you do not pass skusToReplace. + * "accountId" - String - an optional obfuscated string that is uniquely + * associated with the user's account in your app. + * If you pass this value, Google Play can use it to detect irregular + * activity, such as many devices making purchases on the same + * account in a short period of time. + * Do not use the developer ID or the user's Google ID for this field. + * In addition, this field should not contain the user's ID in + * cleartext. + * We recommend that you use a one-way hash to generate a string from + * the user's ID, and store the hashed string in this field. + * "vr" - Boolean - an optional flag indicating whether the returned intent + * should start a VR purchase flow. The apiVersion must also be 7 or + * later to use this flag. + */ + public android.os.Bundle getBuyIntentExtraParams(int apiVersion, java.lang.String packageName, java.lang.String sku, java.lang.String type, java.lang.String developerPayload, android.os.Bundle extraParams) throws android.os.RemoteException; + /** + * Returns the most recent purchase made by the user for each SKU, even if that purchase is + * expired, canceled, or consumed. + * @param apiVersion billing API version that the app is using, must be 6 or later + * @param packageName package name of the calling app + * @param type of the in-app items being requested ("inapp" for one-time purchases + * and "subs" for subscriptions) + * @param continuationToken to be set as null for the first call, if the number of owned + * skus is too large, a continuationToken is returned in the response bundle. + * This method can be called again with the continuation token to get the next set of + * owned skus. + * @param extraParams a Bundle with extra params that would be appended into http request + * query string. Not used at this moment. Reserved for future functionality. + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value: RESULT_OK(0) if success, + * {@link IabHelper#BILLING_RESPONSE_RESULT_*} response codes on failures. + * + * "INAPP_PURCHASE_ITEM_LIST" - ArrayList containing the list of SKUs + * "INAPP_PURCHASE_DATA_LIST" - ArrayList containing the purchase information + * "INAPP_DATA_SIGNATURE_LIST"- ArrayList containing the signatures + * of the purchase information + * "INAPP_CONTINUATION_TOKEN" - String containing a continuation token for the + * next set of in-app purchases. Only set if the + * user has more owned skus than the current list. + */ + public android.os.Bundle getPurchaseHistory(int apiVersion, java.lang.String packageName, java.lang.String type, java.lang.String continuationToken, android.os.Bundle extraParams) throws android.os.RemoteException; + public int isBillingSupportedExtraParams(int apiVersion, java.lang.String packageName, java.lang.String type, android.os.Bundle extraParams) throws android.os.RemoteException; + } diff --git a/modules/juce_core/native/java/JuceAppActivity.java b/modules/juce_core/native/java/JuceAppActivity.java index 1b806d9198..ee32b4947b 100644 --- a/modules/juce_core/native/java/JuceAppActivity.java +++ b/modules/juce_core/native/java/JuceAppActivity.java @@ -66,7 +66,7 @@ import java.util.concurrent.Callable; import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.atomic.*; -$$JuceAndroidMidiImports$$ // If you get an error here, you need to re-save your project with the Projucer! +$$JuceAndroidMidiImports$$ // If you get an error here, you need to re-save your project with the Projucer! //============================================================================== @@ -299,6 +299,7 @@ public class JuceAppActivity extends Activity private native void suspendApp(); private native void resumeApp(); private native void setScreenSize (int screenWidth, int screenHeight, int dpi); + private native void appActivityResult (int requestCode, int resultCode, Intent data); //============================================================================== private ViewHolder viewHolder; @@ -1197,6 +1198,13 @@ public class JuceAppActivity extends Activity public static final String getMoviesFolder() { return getFileLocation (Environment.DIRECTORY_MOVIES); } public static final String getDownloadsFolder() { return getFileLocation (Environment.DIRECTORY_DOWNLOADS); } + //============================================================================== + @Override + protected void onActivityResult (int requestCode, int resultCode, Intent data) + { + appActivityResult (requestCode, resultCode, data); + } + //============================================================================== public final Typeface getTypeFaceFromAsset (String assetName) { diff --git a/modules/juce_core/native/juce_android_JNIHelpers.h b/modules/juce_core/native/juce_android_JNIHelpers.h index 676353cfb5..a0e17e347e 100644 --- a/modules/juce_core/native/juce_android_JNIHelpers.h +++ b/modules/juce_core/native/juce_android_JNIHelpers.h @@ -279,27 +279,27 @@ extern AndroidSystem android; //============================================================================== #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \ - METHOD (createNewView, "createNewView", "(ZJ)L" JUCE_ANDROID_ACTIVITY_CLASSPATH "$ComponentPeerView;") \ - METHOD (deleteView, "deleteView", "(L" JUCE_ANDROID_ACTIVITY_CLASSPATH "$ComponentPeerView;)V") \ + METHOD (createNewView, "createNewView", "(ZJ)L" JUCE_ANDROID_ACTIVITY_CLASSPATH "$ComponentPeerView;") \ + METHOD (deleteView, "deleteView", "(L" JUCE_ANDROID_ACTIVITY_CLASSPATH "$ComponentPeerView;)V") \ METHOD (createNativeSurfaceView, "createNativeSurfaceView", "(J)L" JUCE_ANDROID_ACTIVITY_CLASSPATH "$NativeSurfaceView;") \ - METHOD (finish, "finish", "()V") \ + METHOD (finish, "finish", "()V") \ METHOD (setRequestedOrientation,"setRequestedOrientation", "(I)V") \ - METHOD (getClipboardContent, "getClipboardContent", "()Ljava/lang/String;") \ - METHOD (setClipboardContent, "setClipboardContent", "(Ljava/lang/String;)V") \ - METHOD (excludeClipRegion, "excludeClipRegion", "(Landroid/graphics/Canvas;FFFF)V") \ - METHOD (renderGlyph, "renderGlyph", "(CCLandroid/graphics/Paint;Landroid/graphics/Matrix;Landroid/graphics/Rect;)[I") \ - STATICMETHOD (createHTTPStream, "createHTTPStream", "(Ljava/lang/String;Z[BLjava/lang/String;I[ILjava/lang/StringBuffer;ILjava/lang/String;)L" JUCE_ANDROID_ACTIVITY_CLASSPATH "$HTTPStream;") \ - METHOD (launchURL, "launchURL", "(Ljava/lang/String;)V") \ - METHOD (showMessageBox, "showMessageBox", "(Ljava/lang/String;Ljava/lang/String;J)V") \ - METHOD (showOkCancelBox, "showOkCancelBox", "(Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;)V") \ - METHOD (showYesNoCancelBox, "showYesNoCancelBox", "(Ljava/lang/String;Ljava/lang/String;J)V") \ - STATICMETHOD (getLocaleValue, "getLocaleValue", "(Z)Ljava/lang/String;") \ - STATICMETHOD (getDocumentsFolder, "getDocumentsFolder", "()Ljava/lang/String;") \ - STATICMETHOD (getPicturesFolder, "getPicturesFolder", "()Ljava/lang/String;") \ - STATICMETHOD (getMusicFolder, "getMusicFolder", "()Ljava/lang/String;") \ - STATICMETHOD (getDownloadsFolder, "getDownloadsFolder", "()Ljava/lang/String;") \ - STATICMETHOD (getMoviesFolder, "getMoviesFolder", "()Ljava/lang/String;") \ - METHOD (getTypeFaceFromAsset, "getTypeFaceFromAsset", "(Ljava/lang/String;)Landroid/graphics/Typeface;") \ + METHOD (getClipboardContent, "getClipboardContent", "()Ljava/lang/String;") \ + METHOD (setClipboardContent, "setClipboardContent", "(Ljava/lang/String;)V") \ + METHOD (excludeClipRegion, "excludeClipRegion", "(Landroid/graphics/Canvas;FFFF)V") \ + METHOD (renderGlyph, "renderGlyph", "(CCLandroid/graphics/Paint;Landroid/graphics/Matrix;Landroid/graphics/Rect;)[I") \ + STATICMETHOD (createHTTPStream, "createHTTPStream", "(Ljava/lang/String;Z[BLjava/lang/String;I[ILjava/lang/StringBuffer;ILjava/lang/String;)L" JUCE_ANDROID_ACTIVITY_CLASSPATH "$HTTPStream;") \ + METHOD (launchURL, "launchURL", "(Ljava/lang/String;)V") \ + METHOD (showMessageBox, "showMessageBox", "(Ljava/lang/String;Ljava/lang/String;J)V") \ + METHOD (showOkCancelBox, "showOkCancelBox", "(Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;)V") \ + METHOD (showYesNoCancelBox, "showYesNoCancelBox", "(Ljava/lang/String;Ljava/lang/String;J)V") \ + STATICMETHOD (getLocaleValue, "getLocaleValue", "(Z)Ljava/lang/String;") \ + STATICMETHOD (getDocumentsFolder, "getDocumentsFolder", "()Ljava/lang/String;") \ + STATICMETHOD (getPicturesFolder, "getPicturesFolder", "()Ljava/lang/String;") \ + STATICMETHOD (getMusicFolder, "getMusicFolder", "()Ljava/lang/String;") \ + STATICMETHOD (getDownloadsFolder, "getDownloadsFolder", "()Ljava/lang/String;") \ + STATICMETHOD (getMoviesFolder, "getMoviesFolder", "()Ljava/lang/String;") \ + METHOD (getTypeFaceFromAsset, "getTypeFaceFromAsset", "(Ljava/lang/String;)Landroid/graphics/Typeface;") \ METHOD (getTypeFaceFromByteArray,"getTypeFaceFromByteArray","([B)Landroid/graphics/Typeface;") \ METHOD (setScreenSaver, "setScreenSaver", "(Z)V") \ METHOD (getScreenSaver, "getScreenSaver", "()Z") \ @@ -307,12 +307,16 @@ extern AndroidSystem android; METHOD (getAndroidBluetoothManager, "getAndroidBluetoothManager", "()L" JUCE_ANDROID_ACTIVITY_CLASSPATH "$BluetoothManager;") \ METHOD (getAndroidSDKVersion, "getAndroidSDKVersion", "()I") \ METHOD (audioManagerGetProperty, "audioManagerGetProperty", "(Ljava/lang/String;)Ljava/lang/String;") \ - METHOD (hasSystemFeature, "hasSystemFeature", "(Ljava/lang/String;)Z" ) \ + METHOD (hasSystemFeature, "hasSystemFeature", "(Ljava/lang/String;)Z" ) \ METHOD (requestRuntimePermission, "requestRuntimePermission", "(IJ)V" ) \ - METHOD (isPermissionGranted, "isPermissionGranted", "(I)Z" ) \ + METHOD (isPermissionGranted, "isPermissionGranted", "(I)Z" ) \ METHOD (isPermissionDeclaredInManifest, "isPermissionDeclaredInManifest", "(I)Z" ) \ METHOD (getSystemService, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;") \ STATICMETHOD (createInvocationHandler, "createInvocationHandler", "(J)Ljava/lang/reflect/InvocationHandler;") \ + METHOD (bindService, "bindService", "(Landroid/content/Intent;Landroid/content/ServiceConnection;I)Z") \ + METHOD (unbindService, "unbindService", "(Landroid/content/ServiceConnection;)V") \ + METHOD (startIntentSenderForResult, "startIntentSenderForResult", "(Landroid/content/IntentSender;ILandroid/content/Intent;III)V") \ + METHOD (getPackageName, "getPackageName", "()Ljava/lang/String;") \ DECLARE_JNI_CLASS (JuceAppActivity, JUCE_ANDROID_ACTIVITY_CLASSPATH); #undef JNI_CLASS_MEMBERS diff --git a/modules/juce_core/native/juce_osx_ObjCHelpers.h b/modules/juce_core/native/juce_osx_ObjCHelpers.h index 767bcf49de..760803edfc 100644 --- a/modules/juce_core/native/juce_osx_ObjCHelpers.h +++ b/modules/juce_core/native/juce_osx_ObjCHelpers.h @@ -59,6 +59,16 @@ namespace return createNSURLFromFile (f.getFullPathName()); } + static inline NSArray* createNSArrayFromStringArray (const StringArray& strings) + { + auto* array = [[NSMutableArray alloc] init]; + + for (auto string: strings) + [array addObject:juceStringToNS (string)]; + + return [array autorelease]; + } + #if JUCE_MAC template static NSRect makeNSRect (const RectangleType& r) noexcept diff --git a/modules/juce_gui_basics/native/juce_android_Windowing.cpp b/modules/juce_gui_basics/native/juce_android_Windowing.cpp index c6f92403ac..9fbe590209 100644 --- a/modules/juce_gui_basics/native/juce_android_Windowing.cpp +++ b/modules/juce_gui_basics/native/juce_android_Windowing.cpp @@ -31,6 +31,11 @@ extern juce::JUCEApplicationBase* juce_CreateApplication(); // (from START_JUCE_ namespace juce { +//============================================================================== +#if JUCE_MODULE_AVAILABLE_juce_product_unlocking + extern void juce_inAppPurchaseCompleted (jobject intentData); +#endif + //============================================================================== JUCE_JNI_CALLBACK (JUCE_ANDROID_ACTIVITY_CLASSNAME, launchApp, void, (JNIEnv* env, jobject activity, jstring appFile, jstring appDataDir)) @@ -83,6 +88,18 @@ JUCE_JNI_CALLBACK (JUCE_ANDROID_ACTIVITY_CLASSNAME, quitApp, void, (JNIEnv* env, android.shutdown (env); } +JUCE_JNI_CALLBACK (JUCE_ANDROID_ACTIVITY_CLASSNAME, appActivityResult, void, (JNIEnv* env, jobject, jint requestCode, jint /*resultCode*/, jobject intentData)) +{ + setEnv (env); + + #if JUCE_MODULE_AVAILABLE_juce_product_unlocking + if (requestCode == 1001) + juce_inAppPurchaseCompleted (intentData); + #else + ignoreUnused (intentData, requestCode); + #endif +} + //============================================================================== #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \ METHOD (drawBitmap, "drawBitmap", "([IIIFFIIZLandroid/graphics/Paint;)V") \ diff --git a/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.cpp b/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.cpp new file mode 100644 index 0000000000..3af1a701f0 --- /dev/null +++ b/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.cpp @@ -0,0 +1,66 @@ +/* + ============================================================================== + + This file is part of the JUCE library. + Copyright (c) 2017 - ROLI Ltd. + + JUCE is an open source library subject to commercial or open-source + licensing. + + By using JUCE, you agree to the terms of both the JUCE 5 End-User License + Agreement and JUCE 5 Privacy Policy (both updated and effective as of the + 27th April 2017). + + End User License Agreement: www.juce.com/juce-5-licence + Privacy Policy: www.juce.com/juce-5-privacy-policy + + Or: You may also use this code under the terms of the GPL v3 (see + www.gnu.org/licenses). + + JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + + +InAppPurchases::InAppPurchases() : pimpl (new Pimpl (*this)) {} +InAppPurchases::~InAppPurchases() {} + +bool InAppPurchases::isInAppPurchasesSupported() const +{ + return pimpl->isInAppPurchasesSupported(); +} + +void InAppPurchases::getProductsInformation (const StringArray& productIdentifiers) +{ + pimpl->getProductsInformation (productIdentifiers); +} + +void InAppPurchases::purchaseProduct (const String& productIdentifier, + bool isSubscription, + const StringArray& upgradeProductIdentifiers, + bool creditForUnusedSubscription) +{ + pimpl->purchaseProduct (productIdentifier, isSubscription, + upgradeProductIdentifiers, creditForUnusedSubscription); +} + +void InAppPurchases::restoreProductsBoughtList (bool includeDownloadInfo, const String& subscriptionsSharedSecret) +{ + pimpl->restoreProductsBoughtList (includeDownloadInfo, subscriptionsSharedSecret); +} + +void InAppPurchases::consumePurchase (const String& productIdentifier, const String& purchaseToken) +{ + pimpl->consumePurchase (productIdentifier, purchaseToken); +} + +void InAppPurchases::addListener (Listener* l) { listeners.add (l); } +void InAppPurchases::removeListener (Listener* l) { listeners.remove (l); } + +void InAppPurchases::startDownloads (const Array& downloads) { pimpl->startDownloads (downloads); } +void InAppPurchases::pauseDownloads (const Array& downloads) { pimpl->pauseDownloads (downloads); } +void InAppPurchases::resumeDownloads (const Array& downloads) { pimpl->resumeDownloads (downloads); } +void InAppPurchases::cancelDownloads (const Array& downloads) { pimpl->cancelDownloads (downloads); } diff --git a/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.h b/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.h new file mode 100644 index 0000000000..dda6d16ac0 --- /dev/null +++ b/modules/juce_product_unlocking/in_app_purchases/juce_InAppPurchases.h @@ -0,0 +1,269 @@ +/* + ============================================================================== + + This file is part of the JUCE library. + Copyright (c) 2017 - ROLI Ltd. + + JUCE is an open source library subject to commercial or open-source + licensing. + + By using JUCE, you agree to the terms of both the JUCE 5 End-User License + Agreement and JUCE 5 Privacy Policy (both updated and effective as of the + 27th April 2017). + + End User License Agreement: www.juce.com/juce-5-licence + Privacy Policy: www.juce.com/juce-5-privacy-policy + + Or: You may also use this code under the terms of the GPL v3 (see + www.gnu.org/licenses). + + JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +/** + Provides in-app purchase functionality. + + Your app should create a single instance of this class, and on iOS it should + be created as soon as your app starts. This is because on application startup + any previously pending transactions will be resumed. + + Once an InAppPurchases object is created, call addListener() to attach listeners. +*/ +class JUCE_API InAppPurchases +{ +public: + //============================================================================== + /** Represents a product available in the store. */ + struct Product + { + /** Product ID (also known as SKU) that uniquely identifies a product in the store. */ + String identifier; + + /** Title of the product. */ + String title; + + /** Description of the product. */ + String description; + + /** Price of the product in local currency. */ + String price; + + /** Price locale. */ + String priceLocale; + }; + + //============================================================================== + /** Represents a purchase of a product in the store. */ + struct Purchase + { + /** A unique order identifier for the transaction (generated by the store). */ + String orderId; + + /** A unique identifier of in-app product that was purchased. */ + String productId; + + /** This will be bundle ID on iOS and package name on Android, of the application for which this + in-app product was purchased. */ + String applicationBundleName; + + /** Date of the purchase (in ISO8601 format). */ + String purchaseTime; + + /** Android only: purchase token that should be used to consume purchase, provided that In-App product + is consumable. */ + String purchaseToken; + }; + + //============================================================================== + /** iOS only: represents in-app purchase download. Download will be available only + for purchases that are hosted on the AppStore. */ + struct Download + { + enum class Status + { + waiting = 0, /**< The download is waiting to start. Called at the beginning of a download operation. */ + active, /**< The download is in progress. */ + paused, /**< The download was paused and is awaiting resuming or cancelling. */ + finished, /**< The download was finished successfully. */ + failed, /**< The download failed (e.g. because of no internet connection). */ + cancelled, /**< The download was cancelled. */ + }; + + virtual ~Download() {} + + /** A unique identifier for the in-app product to be downloaded. */ + virtual String getProductId() const = 0; + + /** Content length in bytes. */ + virtual int64 getContentLength() const = 0; + + /** Content version. */ + virtual String getContentVersion() const = 0; + + /** Returns current status of the download. */ + virtual Status getStatus() const = 0; + }; + + + //============================================================================== + /** Represents an object that gets notified about events such as product info returned or product purchase + finished. */ + struct Listener + { + virtual ~Listener() {} + + /** Called whenever a product info is returned after a call to InAppPurchases::getProductsInformation(). */ + virtual void productsInfoReturned (const Array& /*products*/) {} + + struct PurchaseInfo + { + Purchase purchase; + Array downloads; + }; + + /** Called whenever a purchase is complete, with additional state whether the purchase completed successfully. + + For hosted content (iOS only), the downloads array within PurchaseInfo will contain all download objects corresponding + with the purchase. For non-hosted content, the downloads array will be empty. + + InAppPurchases class will own downloads and will delete them as soon as they are finished. + + NOTE: it is possible to receive this callback for the same purchase multiple times. If that happens, + only the newest set of downloads and the newest orderId will be valid, the old ones should be not used anymore! + */ + virtual void productPurchaseFinished (const PurchaseInfo&, bool /*success*/, const String& /*statusDescription*/) {} + + /** Called when a list of all purchases is restored. This can be used to figure out to + which products a user is entitled to. + + NOTE: it is possible to receive this callback for the same purchase multiple times. If that happens, + only the newest set of downloads and the newest orderId will be valid, the old ones should be not used anymore! + */ + virtual void purchasesListRestored (const Array&, bool /*success*/, const String& /*statusDescription*/) {} + + /** Called whenever a product consumption finishes. */ + virtual void productConsumed (const String& /*productId*/, bool /*success*/, const String& /*statusDescription*/) {} + + /** iOS only: Called when a product download progress gets updated. If the download was interrupted in the last + application session, this callback may be called after the application starts. + + If the download was in progress and the application was closed, the download may happily continue in the + background by OS. If you open the app and the download is still in progress, you will receive this callback. + If the download finishes in the background before you start the app again, you will receive productDownloadFinished + callback instead. The download will only stop when it is explicitly cancelled or when it is finished. + */ + virtual void productDownloadProgressUpdate (Download&, float /*progress*/, RelativeTime /*timeRemaining*/) {} + + /** iOS only: Called when a product download is paused. This may also be called after the application starts, if + the download was in a paused state and the application was closed before finishing the download. + + Only after the download is finished successfully or cancelled you will stop receiving this callback on startup. + */ + virtual void productDownloadPaused (Download&) {} + + /** iOS only: Called when a product download finishes (successfully or not). Call Download::getStatus() + to check if the downloaded finished successfully. + + It is your responsibility to move the download content into your app directory and to clean up + any files that are no longer needed. + + After the download is finished, the download object is destroyed and should not be accessed anymore. + */ + virtual void productDownloadFinished (Download&, const URL& /*downloadedContentPath*/) {} + }; + + //============================================================================== + /** Checks whether in-app purchases is supported on current platform. On iOS this always returns true. */ + bool isInAppPurchasesSupported() const; + + /** Asynchronously requests information for products with given ids. Upon completion, for each enquired product + there is going to be a corresponding @class Product object. + If there is no information available for the given product identifier, it will be ignored. + */ + void getProductsInformation (const StringArray& productIdentifiers); + + /** Asynchronously requests to buy a product with given id. + + @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 creditForUnusedSubscription (Android only) controls whether a user should be credited for any unused subscription time on + the products that are being upgraded or downgraded. + */ + void purchaseProduct (const String& productIdentifier, + bool isSubscription, + const StringArray& upgradeOrDowngradeFromSubscriptionsWithProductIdentifiers = {}, + bool creditForUnusedSubscription = true); + + /** Asynchronously asks about a list of products that a user has already bought. Upon completion, Listener::purchasesListReceived() + callback will be invoked. The user may be prompted to login first. + + @param includeDownloadInfo (iOS only) if true, then after restoration is successfull, the downloads array passed to + Listener::purchasesListReceived() callback will contain all the download objects corresponding with + the purchase. In the opposite case, the downloads array will be empty. + + @param subscriptionsSharedSecret (iOS only) required when not including download information and when there are + auto-renewable subscription set up with this app. Refer to In-App-Purchase settings in the store. + */ + void restoreProductsBoughtList (bool includeDownloadInfo, const juce::String& subscriptionsSharedSecret = {}); + + /** Android only: asynchronously sends a request to mark a purchase with given identifier as consumed. + To consume a product, provide product identifier as well as a purchase token that was generated when + the product was purchased. The purchase token can also be retrieved by using getProductsInformation(). + In general if it is available on hand, it is better to use it, because otherwise another async + request will be sent to the store, to first retrieve the token. + + After successful consumption, a product will no longer be returned in getProductsBought() and + it will be available for purchase. + + On iOS consumption happens automatically. If the product was set as consumable, this function is a no-op. + */ + void consumePurchase (const String& productIdentifier, const String& purchaseToken = {}); + + //============================================================================== + /** Adds a listener. */ + void addListener (Listener*); + + /** Removes a listener. */ + void removeListener (Listener*); + + //============================================================================== + /** iOS only: Starts downloads of hosted content from the store. */ + void startDownloads (const Array& downloads); + + /** iOS only: Pauses downloads of hosted content from the store. */ + void pauseDownloads (const Array& downloads); + + /** iOS only: Resumes downloads of hosted content from the store. */ + void resumeDownloads (const Array& downloads); + + /** iOS only: Cancels downloads of hosted content from the store. */ + void cancelDownloads (const Array& downloads); + + //============================================================================== + #ifndef DOXYGEN + InAppPurchases(); + ~InAppPurchases(); + #endif + +private: + //============================================================================== + ListenerList listeners; + + #if JUCE_ANDROID + friend void juce_inAppPurchaseCompleted (jobject); + #endif + + struct Pimpl; + friend struct Pimpl; + + ScopedPointer pimpl; +}; diff --git a/modules/juce_product_unlocking/juce_product_unlocking.cpp b/modules/juce_product_unlocking/juce_product_unlocking.cpp index 6c17cdb445..84d63b594d 100644 --- a/modules/juce_product_unlocking/juce_product_unlocking.cpp +++ b/modules/juce_product_unlocking/juce_product_unlocking.cpp @@ -33,10 +33,30 @@ #error "Incorrect use of JUCE cpp file" #endif +#define JUCE_CORE_INCLUDE_JNI_HELPERS 1 +#define JUCE_CORE_INCLUDE_OBJC_HELPERS 1 +#define JUCE_CORE_INCLUDE_NATIVE_HEADERS 1 + +// Set this flag to 1 to use test servers on iOS +#ifndef JUCE_IN_APP_PURCHASES_USE_SANDBOX_ENVIRONMENT + #define JUCE_IN_APP_PURCHASES_USE_SANDBOX_ENVIRONMENT 0 +#endif + #include "juce_product_unlocking.h" +#if JUCE_IOS || JUCE_MAC + #import +#endif + namespace juce { + #if JUCE_ANDROID + #include "native/juce_android_InAppPurchases.cpp" + #elif JUCE_IOS + #include "native/juce_ios_InAppPurchases.cpp" + #endif + #include "in_app_purchases/juce_InAppPurchases.cpp" + #include "marketplace/juce_OnlineUnlockStatus.cpp" #if JUCE_MODULE_AVAILABLE_juce_data_structures diff --git a/modules/juce_product_unlocking/juce_product_unlocking.h b/modules/juce_product_unlocking/juce_product_unlocking.h index d5b7d5be55..68ef5287c6 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 + dependencies: juce_cryptography juce_core END_JUCE_MODULE_DECLARATION @@ -60,9 +60,13 @@ Tracktion Marketplace web-store, the module itself is fully open, and can be used to connect to your own web-store instead, if you implement your own compatible web-server back-end. + + In additional, the module supports in-app purchases both on iOS and Android + platforms. */ //============================================================================== +#include #include #if JUCE_MODULE_AVAILABLE_juce_data_structures @@ -75,6 +79,10 @@ namespace juce { + #if JUCE_ANDROID || JUCE_IOS + #include "in_app_purchases/juce_InAppPurchases.h" + #endif + #if JUCE_MODULE_AVAILABLE_juce_data_structures #include "marketplace/juce_OnlineUnlockStatus.h" #include "marketplace/juce_TracktionMarketplaceStatus.h" diff --git a/modules/juce_product_unlocking/juce_product_unlocking.mm b/modules/juce_product_unlocking/juce_product_unlocking.mm new file mode 100644 index 0000000000..2f1f3c9899 --- /dev/null +++ b/modules/juce_product_unlocking/juce_product_unlocking.mm @@ -0,0 +1,23 @@ +/* + ============================================================================== + + This file is part of the JUCE library. + Copyright (c) 2017 - ROLI Ltd. + + JUCE is an open source library subject to commercial or open-source + licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + To use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "juce_product_unlocking.cpp" diff --git a/modules/juce_product_unlocking/native/juce_android_InAppPurchases.cpp b/modules/juce_product_unlocking/native/juce_android_InAppPurchases.cpp new file mode 100644 index 0000000000..b9f03a8736 --- /dev/null +++ b/modules/juce_product_unlocking/native/juce_android_InAppPurchases.cpp @@ -0,0 +1,930 @@ +/* + ============================================================================== + + This file is part of the JUCE library. + Copyright (c) 2017 - ROLI Ltd. + + JUCE is an open source library subject to commercial or open-source + licensing. + + By using JUCE, you agree to the terms of both the JUCE 5 End-User License + Agreement and JUCE 5 Privacy Policy (both updated and effective as of the + 27th April 2017). + + End User License Agreement: www.juce.com/juce-5-licence + Privacy Policy: www.juce.com/juce-5-privacy-policy + + Or: You may also use this code under the terms of the GPL v3 (see + www.gnu.org/licenses). + + JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \ + 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"); +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \ + STATICMETHOD (asInterface, "asInterface", "(Landroid/os/IBinder;)Lcom/android/vending/billing/IInAppBillingService;") \ + +DECLARE_JNI_CLASS (IInAppBillingServiceStub, "com/android/vending/billing/IInAppBillingService$Stub"); +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \ + METHOD (constructor, "", "(I)V") \ + METHOD (add, "add", "(Ljava/lang/Object;)Z") \ + METHOD (iterator, "iterator", "()Ljava/util/Iterator;") \ + METHOD (get, "get", "(I)Ljava/lang/Object;") \ + METHOD (size, "size", "()I") + +DECLARE_JNI_CLASS (ArrayList, "java/util/ArrayList"); +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \ + METHOD (constructor, "", "()V") \ + METHOD (putBoolean, "putBoolean", "(Ljava/lang/String;Z)V") \ + METHOD (putStringArrayList, "putStringArrayList", "(Ljava/lang/String;Ljava/util/ArrayList;)V") \ + METHOD (getInt, "getInt", "(Ljava/lang/String;)I") \ + METHOD (getStringArrayList, "getStringArrayList", "(Ljava/lang/String;)Ljava/util/ArrayList;") \ + METHOD (getString, "getString", "(Ljava/lang/String;)Ljava/lang/String;") \ + METHOD (getParcelable, "getParcelable", "(Ljava/lang/String;)Landroid/os/Parcelable;") + +DECLARE_JNI_CLASS (Bundle, "android/os/Bundle"); +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \ + STATICMETHOD (valueOf, "valueOf", "(I)Ljava/lang/Integer;") + +DECLARE_JNI_CLASS (Integer, "java/lang/Integer"); +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \ + METHOD (constructor, "", "()V") \ + METHOD (constructWithString, "", "(Ljava/lang/String;)V") \ + METHOD (setPackage, "setPackage", "(Ljava/lang/String;)Landroid/content/Intent;") \ + METHOD (getIntExtra, "getIntExtra", "(Ljava/lang/String;I)I") \ + METHOD (getStringExtra, "getStringExtra", "(Ljava/lang/String;)Ljava/lang/String;") + +DECLARE_JNI_CLASS (Intent, "android/content/Intent"); +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \ + METHOD (hasNext, "hasNext", "()Z") \ + METHOD (next, "next", "()Ljava/lang/Object;") + +DECLARE_JNI_CLASS (Iterator, "java/util/Iterator"); +#undef JNI_CLASS_MEMBERS + +#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \ + METHOD (getIntentSender, "getIntentSender", "()Landroid/content/IntentSender;") + +DECLARE_JNI_CLASS (PendingIntent, "android/app/PendingIntent"); +#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, Method.getName)); + + if (methodName == "onServiceConnected") + { + onServiceConnected (env->GetObjectArrayElement (args, 0), + env->GetObjectArrayElement (args, 1)); + return nullptr; + } + + if (methodName == "onServiceDisconnected") + { + onServiceDisconnected (env->GetObjectArrayElement (args, 0)); + return nullptr; + } + + return AndroidInterfaceImplementer::invoke (proxy, method, args); + } +}; + +//============================================================================== +struct InAppPurchases::Pimpl : private AsyncUpdater, + private ServiceConnection +{ + Pimpl (InAppPurchases& parent) : owner (parent) + { + getInAppPurchaseInstances().add (this); + + auto* env = getEnv(); + auto intent = env->NewObject (Intent, Intent.constructWithString, + javaString ("com.android.vending.billing.InAppBillingService.BIND").get()); + env->CallObjectMethod (intent, Intent.setPackage, javaString ("com.android.vending").get()); + + serviceConnection = GlobalRef (CreateJavaInterface (this, "android/content/ServiceConnection").get()); + android.activity.callBooleanMethod (JuceAppActivity.bindService, intent, + serviceConnection.get(), 1 /*BIND_AUTO_CREATE*/); + } + + ~Pimpl() + { + if (serviceConnection != nullptr) + { + android.activity.callVoidMethod (JuceAppActivity.unbindService, serviceConnection.get()); + serviceConnection.clear(); + } + + getInAppPurchaseInstances().removeFirstMatchingValue (this); + } + + //============================================================================== + bool isInAppPurchasesSupported() { return isInAppPurchasesSupported (inAppBillingService); } + + void getProductsInformation (const StringArray& productIdentifiers) + { + if (! checkIsReady()) + return; + + auto callback = [this](const Array& products) + { + const ScopedLock lock (getProductsInformationJobResultsLock); + getProductsInformationJobResults.insert (0, products); + triggerAsyncUpdate(); + }; + + threadPool->addJob (new GetProductsInformationJob (inAppBillingService, getPackageName(), + productIdentifiers, callback), true); + } + + void purchaseProduct (const String& productIdentifier, bool isSubscription, + const StringArray& subscriptionIdentifiers, bool creditForUnusedSubscription) + { + if (! checkIsReady()) + return; + + // Upgrading/downgrading only makes sense for subscriptions! + jassert (subscriptionIdentifiers.isEmpty() || isSubscription); + + auto buyIntentBundle = getBuyIntentBundle (productIdentifier, isSubscription, + subscriptionIdentifiers, creditForUnusedSubscription); + auto* env = getEnv(); + + auto responseCodeString = javaString ("RESPONSE_CODE"); + auto responseCode = env->CallIntMethod (buyIntentBundle.get(), Bundle.getInt, responseCodeString.get()); + + if (responseCode == 0) + { + auto buyIntentString = javaString ("BUY_INTENT"); + auto pendingIntent = LocalRef (env->CallObjectMethod (buyIntentBundle.get(), Bundle.getParcelable, buyIntentString.get())); + + auto requestCode = 1001; + auto intentSender = LocalRef (env->CallObjectMethod (pendingIntent.get(), PendingIntent.getIntentSender)); + auto fillInIntent = LocalRef (env->NewObject (Intent, Intent.constructor)); + auto flagsMask = LocalRef (env->CallStaticObjectMethod (Integer, Integer.valueOf, 0)); + auto flagsValues = LocalRef (env->CallStaticObjectMethod (Integer, Integer.valueOf, 0)); + auto extraFlags = LocalRef (env->CallStaticObjectMethod (Integer, Integer.valueOf, 0)); + + android.activity.callVoidMethod (JuceAppActivity.startIntentSenderForResult, intentSender.get(), requestCode, + fillInIntent.get(), flagsMask.get(), flagsValues.get(), extraFlags.get()); + } + } + + void restoreProductsBoughtList (bool, const juce::String&) + { + if (! checkIsReady()) + return; + + auto callback = [this](const Array& purchases) + { + const ScopedLock lock (getProductsBoughtJobResultsLock); + getProductsBoughtJobResults.insert (0, purchases); + triggerAsyncUpdate(); + }; + + threadPool->addJob (new GetProductsBoughtJob (inAppBillingService, + getPackageName(), callback), true); + } + + void consumePurchase (const String& productIdentifier, const String& purchaseToken) + { + if (! checkIsReady()) + return; + + auto callback = [this](const ConsumePurchaseJob::Result& r) + { + const ScopedLock lock (consumePurchaseJobResultsLock); + consumePurchaseJobResults.insert (0, r); + triggerAsyncUpdate(); + }; + + threadPool->addJob (new ConsumePurchaseJob (inAppBillingService, getPackageName(), productIdentifier, + purchaseToken, callback), true); + } + + //============================================================================== + void startDownloads (const Array& downloads) + { + // Not available on this platform. + ignoreUnused (downloads); + jassertfalse; + } + + void pauseDownloads (const Array& downloads) + { + // Not available on this platform. + ignoreUnused (downloads); + jassertfalse; + } + + void resumeDownloads (const Array& downloads) + { + // Not available on this platform. + ignoreUnused (downloads); + jassertfalse; + } + + 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) + { + auto* env = getEnv(); + + auto skuString = javaString (productIdentifier); + auto productTypeString = javaString (isSubscription ? "subs" : "inapp"); + auto devString = javaString (getDeveloperExtraData()); + + if (subscriptionIdentifiers.isEmpty()) + return LocalRef (inAppBillingService.callObjectMethod (IInAppBillingService.getBuyIntent, 3, + getPackageName().get(), skuString.get(), + productTypeString.get(), devString.get())); + + auto skuList = LocalRef (env->NewObject (ArrayList, ArrayList.constructor, + (int) subscriptionIdentifiers.size())); + + if (skuList.get() == 0) + { + jassertfalse; + return LocalRef (0); + } + + for (const auto& identifier : subscriptionIdentifiers) + env->CallBooleanMethod (skuList.get(), ArrayList.add, javaString (identifier).get()); + + auto extraParams = LocalRef (env->NewObject (Bundle, Bundle.constructor)); + + if (extraParams.get() == 0) + { + jassertfalse; + return LocalRef (0); + } + + auto skusToReplaceString = javaString ("skusToReplace"); + auto replaceSkusProrationString = javaString ("replaceSkusProration"); + + env->CallVoidMethod (extraParams.get(), Bundle.putStringArrayList, skusToReplaceString.get(), skuList.get()); + env->CallVoidMethod (extraParams.get(), Bundle.putBoolean, replaceSkusProrationString.get(), creditForUnusedSubscription); + + return LocalRef (inAppBillingService.callObjectMethod (IInAppBillingService.getBuyIntentExtraParams, 6, + getPackageName().get(), skuString.get(), + productTypeString.get(), devString.get(), + extraParams.get())); + } + + //============================================================================== + void notifyAboutPurchaseResult (const InAppPurchases::Purchase& purchase, bool success, const String& statusDescription) + { + owner.listeners.call (&Listener::productPurchaseFinished, { purchase, {} }, success, statusDescription); + } + + //============================================================================== + bool checkIsReady() + { + return (inAppBillingService.get() != 0); + } + + static bool isInAppPurchasesSupported (jobject iapService) + { + if (iapService != nullptr) + { + 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 (env->CallIntMethod (iapService, IInAppBillingService.isBillingSupported, 3, + getPackageName().get(), subsString.get()) != 0) + return false; + + return true; + } + + // 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 + return false; + } + + //============================================================================== + void onServiceConnected (jobject, jobject iBinder) override + { + auto* env = getEnv(); + + LocalRef iapService (env->CallStaticObjectMethod (IInAppBillingServiceStub, + IInAppBillingServiceStub.asInterface, + iBinder)); + + if (isInAppPurchasesSupported (iapService)) + { + if (threadPool == nullptr) + threadPool = new ThreadPool (1); + + inAppBillingService = GlobalRef (iapService); + } + + // 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()); + } + + void onServiceDisconnected (jobject) override + { + threadPool = nullptr; + inAppBillingService.clear(); + } + + //============================================================================== + static LocalRef getPackageName() + { + return LocalRef ((jstring) (android.activity.callObjectMethod (JuceAppActivity.getPackageName))); + } + + //============================================================================== + struct GetProductsInformationJob : public ThreadPoolJob + { + using Callback = std::function&)>; + + GetProductsInformationJob (const GlobalRef& inAppBillingServiceToUse, + const LocalRef& packageNameToUse, + const StringArray& productIdentifiersToUse, + const Callback& callbackToUse) + : ThreadPoolJob ("GetProductsInformationJob"), + inAppBillingService (inAppBillingServiceToUse), + packageName (packageNameToUse.get()), + productIdentifiers (productIdentifiersToUse), + callback (callbackToUse) + {} + + ThreadPoolJob::JobStatus runJob() override + { + jassert (callback); + + if (inAppBillingService.get() != 0) + { + // 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 (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 + { + if (callback) + callback ({}); + } + + return jobHasFinished; + } + + private: + LocalRef queryProductsInformationFromService (const StringArray& productIdentifiersToQuery, const String& productType) + { + auto* env = getEnv(); + + auto skuList = LocalRef (env->NewObject (ArrayList, ArrayList.constructor, productIdentifiersToQuery.size())); + + if (skuList.get() == 0) + return LocalRef (0); + + for (const auto& pi : productIdentifiersToQuery) + env->CallBooleanMethod (skuList.get(), ArrayList.add, javaString (pi).get()); + + auto querySkus = LocalRef (env->NewObject (Bundle, Bundle.constructor)); + + if (querySkus.get() == 0) + return LocalRef (0); + + auto itemIdListString = javaString ("ITEM_ID_LIST"); + + env->CallVoidMethod (querySkus.get(), Bundle.putStringArrayList, itemIdListString.get(), skuList.get()); + + auto productTypeString = javaString (productType); + + auto productDetails = LocalRef (inAppBillingService.callObjectMethod (IInAppBillingService.getSkuDetails, + 3, (jstring) packageName.get(), + productTypeString.get(), querySkus.get())); + + return productDetails; + } + + Array processRetrievedProducts (LocalRef retrievedProducts) + { + Array products; + + if (retrievedProducts.get() != 0) + { + auto* env = getEnv(); + + auto responseCodeString = javaString ("RESPONSE_CODE"); + + auto responseCode = env->CallIntMethod (retrievedProducts.get(), Bundle.getInt, responseCodeString.get()); + + if (responseCode == 0) + { + auto detailsListString = javaString ("DETAILS_LIST"); + + auto responseList = LocalRef (env->CallObjectMethod (retrievedProducts.get(), Bundle.getStringArrayList, + detailsListString.get())); + + if (responseList != 0) + { + auto iterator = LocalRef (env->CallObjectMethod (responseList.get(), ArrayList.iterator)); + + if (iterator.get() != 0) + { + for (;;) + { + if (! env->CallBooleanMethod (iterator, Iterator.hasNext)) + break; + + auto response = juce::LocalRef ((jstring)env->CallObjectMethod (iterator, Iterator.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; + } + + + GlobalRef inAppBillingService, packageName; + const StringArray productIdentifiers; + Callback callback; + }; + + //============================================================================== + struct GetProductsBoughtJob : public ThreadPoolJob + { + using Callback = std::function&)>; + + GetProductsBoughtJob (const GlobalRef& inAppBillingServiceToUse, + const LocalRef& packageNameToUse, + const Callback& callbackToUse) + : ThreadPoolJob ("GetProductsBoughtJob"), + inAppBillingService (inAppBillingServiceToUse), + packageName (packageNameToUse.get()), + callback (callbackToUse) + {} + + ThreadPoolJob::JobStatus runJob() override + { + jassert (callback); + + if (inAppBillingService.get() != 0) + { + 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 (purchases); + } + else + { + if (callback) + callback ({}); + } + + return jobHasFinished; + } + + private: + Array getProductsBought (const String& productType, jstring continuationToken) + { + Array purchases; + auto* env = getEnv(); + + auto productTypeString = javaString (productType); + auto ownedItems = LocalRef (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(), Bundle.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"); + + auto ownedSkus = LocalRef (env->CallObjectMethod (ownedItems.get(), Bundle.getStringArrayList, itemListString.get())); + auto purchaseDataList = LocalRef (env->CallObjectMethod (ownedItems.get(), Bundle.getStringArrayList, dataListString.get())); + auto signatureList = LocalRef (env->CallObjectMethod (ownedItems.get(), Bundle.getStringArrayList, signatureListString.get())); + auto newContinuationToken = LocalRef ((jstring) env->CallObjectMethod (ownedItems.get(), Bundle.getString, continuationTokenString.get())); + + for (auto i = 0; i < env->CallIntMethod (purchaseDataList.get(), ArrayList.size); ++i) + { + auto sku = juceString ((jstring) (env->CallObjectMethod (ownedSkus.get(), ArrayList.get, i))); + auto purchaseData = juceString ((jstring) (env->CallObjectMethod (purchaseDataList.get(), ArrayList.get, i))); + auto signature = juceString ((jstring) (env->CallObjectMethod (signatureList.get(), ArrayList.get, i))); + + var responseData = JSON::parse (purchaseData); + + 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() }); + } + } + + if (newContinuationToken.get() != 0) + getProductsBought (productType, newContinuationToken.get()); + } + } + + return purchases; + } + + GlobalRef inAppBillingService, packageName; + Callback callback; + }; + + //============================================================================== + class ConsumePurchaseJob : public ThreadPoolJob + { + public: + struct Result + { + String productIdentifier; + bool success; + String statusDescription; + }; + + using Callback = std::function; + + ConsumePurchaseJob (const GlobalRef& inAppBillingServiceToUse, + const LocalRef& packageNameToUse, + const String& productIdentifierToUse, + const String& purchaseTokenToUse, + const Callback& callbackToUse) + : ThreadPoolJob ("ConsumePurchaseJob"), + inAppBillingService (inAppBillingServiceToUse), + packageName (packageNameToUse.get()), + productIdentifier (productIdentifierToUse), + purchaseToken (purchaseTokenToUse), + callback (callbackToUse) + {} + + ThreadPoolJob::JobStatus runJob() override + { + jassert (callback); + + auto token = (! purchaseToken.isEmpty() ? purchaseToken : getPurchaseTokenForProductId (productIdentifier, false, 0)); + + if (token.isEmpty()) + { + if (callback) + callback ({ productIdentifier, false, NEEDS_TRANS ("Item not owned") }); + + return jobHasFinished; + } + + auto responseCode = inAppBillingService.callIntMethod (IInAppBillingService.consumePurchase, 3, + (jstring)packageName.get(), javaString (token).get()); + + if (callback) + callback ({ productIdentifier, responseCode == 0, statusCodeToUserString (responseCode) }); + + return jobHasFinished; + } + + private: + String getPurchaseTokenForProductId (const String productIdToLookFor, bool isSubscription, jstring continuationToken) + { + auto productTypeString = javaString (isSubscription ? "subs" : "inapp"); + auto ownedItems = LocalRef (inAppBillingService.callObjectMethod (IInAppBillingService.getPurchases, 3, + (jstring) packageName.get(), productTypeString.get(), + continuationToken)); + + if (ownedItems.get() != 0) + { + auto* env = getEnv(); + + auto responseCodeString = javaString ("RESPONSE_CODE"); + auto responseCode = env->CallIntMethod (ownedItems.get(), Bundle.getInt, responseCodeString.get()); + + if (responseCode == 0) + { + auto dataListString = javaString ("INAPP_PURCHASE_DATA_LIST"); + auto continuationTokenString = javaString ("INAPP_CONTINUATION_TOKEN"); + + auto purchaseDataList = LocalRef (env->CallObjectMethod (ownedItems.get(), Bundle.getStringArrayList, dataListString.get())); + auto newContinuationToken = LocalRef ((jstring) env->CallObjectMethod (ownedItems.get(), Bundle.getString, continuationTokenString.get())); + + for (auto i = 0; i < env->CallIntMethod (purchaseDataList.get(), ArrayList.size); ++i) + { + auto purchaseData = juceString ((jstring) (env->CallObjectMethod (purchaseDataList.get(), ArrayList.get, i))); + + var responseData = JSON::parse (purchaseData); + + if (auto* object = responseData.getDynamicObject()) + { + static const Identifier productIdIdentifier ("productId"), + purchaseTokenIdentifier ("purchaseToken"); + + auto& props = object->getProperties(); + var productId = props[productIdIdentifier]; + + if (productId.toString() == productIdToLookFor) + return props[purchaseTokenIdentifier].toString(); + } + } + + if (newContinuationToken.get() != 0) + return getPurchaseTokenForProductId (productIdToLookFor, isSubscription, newContinuationToken.get()); + } + } + + return {}; + } + + GlobalRef inAppBillingService, packageName; + const String productIdentifier, purchaseToken; + Callback callback; + }; + + //============================================================================== + void handleAsyncUpdate() override + { + { + const ScopedLock lock (getProductsInformationJobResultsLock); + + for (int i = getProductsInformationJobResults.size(); --i >= 0;) + { + const auto& result = getProductsInformationJobResults.getReference (i); + + owner.listeners.call (&Listener::productsInfoReturned, result); + getProductsInformationJobResults.remove (i); + } + } + + { + const ScopedLock lock (getProductsBoughtJobResultsLock); + + for (int i = getProductsBoughtJobResults.size(); --i >= 0;) + { + const auto& result = getProductsBoughtJobResults.getReference (i); + + owner.listeners.call (&Listener::purchasesListRestored, result, true, NEEDS_TRANS ("Success")); + getProductsBoughtJobResults.remove (i); + } + } + + { + const ScopedLock lock (consumePurchaseJobResultsLock); + + for (int i = consumePurchaseJobResults.size(); --i >= 0;) + { + const auto& result = consumePurchaseJobResults.getReference (i); + + owner.listeners.call (&Listener::productConsumed, result.productIdentifier, + result.success, result.statusDescription); + consumePurchaseJobResults.remove (i); + } + } + } + + //============================================================================== + static Array& getInAppPurchaseInstances() noexcept + { + static Array instances; + return instances; + } + + static void inAppPurchaseCompleted (jobject intentData) + { + auto* env = getEnv(); + + auto inAppPurchaseDataString = javaString ("INAPP_PURCHASE_DATA"); + auto inAppDataSignatureString = javaString ("INAPP_DATA_SIGNATURE"); + auto responseCodeString = javaString ("RESPONSE_CODE"); + + auto pd = LocalRef ((jstring) env->CallObjectMethod (intentData, Intent.getStringExtra, inAppPurchaseDataString.get())); + auto sig = LocalRef ((jstring) env->CallObjectMethod (intentData, Intent.getStringExtra, inAppDataSignatureString.get())); + auto purchaseDataString = pd.get() != 0 ? juceString (pd.get()) : String(); + auto dataSignatureString = sig.get() != 0 ? juceString (sig.get()) : String(); + + var responseData = JSON::parse (purchaseDataString); + + auto responseCode = env->CallIntMethod (intentData, Intent.getIntExtra, responseCodeString.get()); + auto statusCodeUserString = statusCodeToUserString (responseCode); + + if (auto* object = responseData.getDynamicObject()) + { + 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]; + + if (auto* target = getPimplFromDeveloperExtraData (developerPayload)) + { + auto purchaseTimeString = Time (purchaseTime.toString().getLargeIntValue()) + .toString (true, true, true, true); + + target->notifyAboutPurchaseResult ({ orderId.toString(), productId.toString(), packageName.toString(), + purchaseTimeString, purchaseToken.toString() }, + true, statusCodeUserString); + } + } + } + + //============================================================================== + String getDeveloperExtraData() + { + static const Identifier inAppPurchaseInstance ("inAppPurchaseInstance"); + DynamicObject::Ptr developerString (new DynamicObject()); + + developerString->setProperty (inAppPurchaseInstance, + "0x" + String::toHexString (reinterpret_cast (this))); + return JSON::toString (var (developerString)); + } + + static Pimpl* getPimplFromDeveloperExtraData (const String& developerExtra) + { + static const Identifier inAppPurchaseInstance ("inAppPurchaseInstance"); + + if (DynamicObject::Ptr developerData = JSON::fromString (developerExtra).getDynamicObject()) + { + String hexAddr = developerData->getProperty (inAppPurchaseInstance); + + if (hexAddr.startsWith ("0x")) + hexAddr = hexAddr.fromFirstOccurrenceOf ("0x", false, false); + + auto* target = reinterpret_cast (static_cast (hexAddr.getHexValue64())); + + if (getInAppPurchaseInstances().contains (target)) + return target; + } + + return nullptr; + } + + //============================================================================== + static String statusCodeToUserString (int statusCode) + { + 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"); + } + } + + //============================================================================== + InAppPurchases& owner; + GlobalRef inAppBillingService, serviceConnection; + ScopedPointer threadPool; + + CriticalSection getProductsInformationJobResultsLock, + getProductsBoughtJobResultsLock, + consumePurchaseJobResultsLock; + + Array> getProductsInformationJobResults; + Array> getProductsBoughtJobResults; + Array consumePurchaseJobResults; +}; + + +//============================================================================== +void juce_inAppPurchaseCompleted (jobject intentData) +{ + InAppPurchases::Pimpl::inAppPurchaseCompleted (intentData); +} diff --git a/modules/juce_product_unlocking/native/juce_ios_InAppPurchases.cpp b/modules/juce_product_unlocking/native/juce_ios_InAppPurchases.cpp new file mode 100644 index 0000000000..8ff5b98628 --- /dev/null +++ b/modules/juce_product_unlocking/native/juce_ios_InAppPurchases.cpp @@ -0,0 +1,679 @@ +/* + ============================================================================== + + This file is part of the JUCE library. + Copyright (c) 2017 - ROLI Ltd. + + JUCE is an open source library subject to commercial or open-source + licensing. + + By using JUCE, you agree to the terms of both the JUCE 5 End-User License + Agreement and JUCE 5 Privacy Policy (both updated and effective as of the + 27th April 2017). + + End User License Agreement: www.juce.com/juce-5-licence + Privacy Policy: www.juce.com/juce-5-privacy-policy + + Or: You may also use this code under the terms of the GPL v3 (see + www.gnu.org/licenses). + + JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +template <> struct ContainerDeletePolicy { static void destroy (NSObject* o) { [o release]; } }; +template <> struct ContainerDeletePolicy { static void destroy (NSObject* o) { [o release]; } }; +template <> struct ContainerDeletePolicy> { static void destroy (NSObject* o) { [o release]; } }; + + +//============================================================================== +struct SKDelegateAndPaymentObserver +{ + SKDelegateAndPaymentObserver() : delegate ([getClass().createInstance() init]) + { + Class::setThis (delegate, this); + } + + virtual ~SKDelegateAndPaymentObserver() {} + + virtual void didReceiveResponse (SKProductsRequest*, SKProductsResponse*) = 0; + virtual void requestDidFinish (SKRequest*) = 0; + virtual void updatedTransactions (SKPaymentQueue*, NSArray*) = 0; + virtual void restoreCompletedTransactionsFailedWithError (SKPaymentQueue*, NSError*) = 0; + virtual void restoreCompletedTransactionsFinished (SKPaymentQueue*) = 0; + virtual void updatedDownloads (SKPaymentQueue*, NSArray*) = 0; + +protected: + ScopedPointer> delegate; + +private: + struct Class : public ObjCClass> + { + //============================================================================== + Class() : ObjCClass> ("SKDelegateAndPaymentObserverBase_") + { + addIvar ("self"); + + addMethod (@selector (productsRequest:didReceiveResponse:), didReceiveResponse, "v@:@@"); + addMethod (@selector (requestDidFinish:), requestDidFinish, "v@:@"); + addMethod (@selector (paymentQueue:updatedTransactions:), updatedTransactions, "v@:@@"); + addMethod (@selector (paymentQueue:restoreCompletedTransactionsFailedWithError:), restoreCompletedTransactionsFailedWithError, "v@:@@"); + addMethod (@selector (paymentQueueRestoreCompletedTransactionsFinished:), restoreCompletedTransactionsFinished, "v@:@"); + addMethod (@selector (paymentQueue:updatedDownloads:), updatedDownloads, "v@:@@"); + + registerClass(); + } + + //============================================================================== + static SKDelegateAndPaymentObserver& getThis (id self) { return *getIvar (self, "self"); } + static void setThis (id self, SKDelegateAndPaymentObserver* s) { object_setInstanceVariable (self, "self", s); } + + //============================================================================== + static void didReceiveResponse (id self, SEL, SKProductsRequest* request, SKProductsResponse* response) { getThis (self).didReceiveResponse (request, response); } + static void requestDidFinish (id self, SEL, SKRequest* request) { getThis (self).requestDidFinish (request); } + static void updatedTransactions (id self, SEL, SKPaymentQueue* queue, NSArray* trans) { getThis (self).updatedTransactions (queue, trans); } + static void restoreCompletedTransactionsFailedWithError (id self, SEL, SKPaymentQueue* q, NSError* err) { getThis (self).restoreCompletedTransactionsFailedWithError (q, err); } + static void restoreCompletedTransactionsFinished (id self, SEL, SKPaymentQueue* queue) { getThis (self).restoreCompletedTransactionsFinished (queue); } + static void updatedDownloads (id self, SEL, SKPaymentQueue* queue, NSArray* downloads) { getThis (self).updatedDownloads (queue, downloads); } + }; + + //============================================================================== + static Class& getClass() + { + static Class c; + return c; + } +}; + +//============================================================================== +struct InAppPurchases::Pimpl : public SKDelegateAndPaymentObserver +{ + /** AppStore implementation of hosted content download. */ + struct DownloadImpl : public Download + { + DownloadImpl (SKDownload* downloadToUse) : download (downloadToUse) {} + + String getProductId() const override { return nsStringToJuce (download.contentIdentifier); } + int64 getContentLength() const override { return download.contentLength; } + String getContentVersion() const override { return nsStringToJuce (download.contentVersion); } + Status getStatus() const override { return SKDownloadStateToDownloadStatus (download.downloadState); } + + SKDownload* download; + }; + + /** Represents a pending request initialised with [SKProductRequest start]. */ + struct PendingProductInfoRequest + { + enum class Type + { + query = 0, + purchase + }; + + Type type; + ScopedPointer request; + }; + + /** Represents a pending request started from [SKReceiptRefreshRequest start]. */ + struct PendingReceiptRefreshRequest + { + String subscriptionsSharedSecret; + ScopedPointer request; + }; + + /** Represents a transaction with pending downloads. Only after all downloads + are finished, the transaction is marked as finished. */ + struct PendingDownloadsTransaction + { + PendingDownloadsTransaction (SKPaymentTransaction* t) : transaction (t) + { + addDownloadsFromSKTransaction (transaction); + } + + void addDownloadsFromSKTransaction (SKPaymentTransaction* transactionToUse) + { + for (SKDownload* download in transactionToUse.downloads) + downloads.add (new DownloadImpl (download)); + } + + bool canBeMarkedAsFinished() const + { + for (SKDownload* d in transaction.downloads) + { + if (d.downloadState != SKDownloadStateFinished + && d.downloadState != SKDownloadStateFailed + && d.downloadState != SKDownloadStateCancelled) + { + return false; + } + } + + return true; + } + + OwnedArray downloads; + SKPaymentTransaction* const transaction; + }; + + //============================================================================== + Pimpl (InAppPurchases& p) : owner (p) { [[SKPaymentQueue defaultQueue] addTransactionObserver: delegate]; } + ~Pimpl() noexcept { [[SKPaymentQueue defaultQueue] removeTransactionObserver: delegate]; } + + //============================================================================== + bool isInAppPurchasesSupported() const { return true; } + + void getProductsInformation (const StringArray& productIdentifiers) + { + auto* productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers: [NSSet setWithArray: createNSArrayFromStringArray (productIdentifiers)]]; + + pendingProductInfoRequests.add (new PendingProductInfoRequest {PendingProductInfoRequest::Type::query, productsRequest}); + + productsRequest.delegate = delegate; + [productsRequest start]; + } + + void purchaseProduct (const String& productIdentifier, bool, const StringArray&, bool) + { + if (! [SKPaymentQueue canMakePayments]) + { + owner.listeners.call (&Listener::productPurchaseFinished, {}, false, NEEDS_TRANS ("Payments not allowed")); + return; + } + + auto* productIdentifiers = [NSArray arrayWithObject: juceStringToNS (productIdentifier)]; + auto* productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithArray:productIdentifiers]]; + + pendingProductInfoRequests.add (new PendingProductInfoRequest {PendingProductInfoRequest::Type::purchase, productsRequest}); + + productsRequest.delegate = delegate; + [productsRequest start]; + } + + void restoreProductsBoughtList (bool includeDownloadInfo, const String& subscriptionsSharedSecret) + { + if (includeDownloadInfo) + { + [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; + } + else + { + auto* receiptRequest = [[SKReceiptRefreshRequest alloc] init]; + + pendingReceiptRefreshRequests.add (new PendingReceiptRefreshRequest {subscriptionsSharedSecret, + [receiptRequest retain]}); + receiptRequest.delegate = delegate; + [receiptRequest start]; + } + } + + void consumePurchase (const String&, const String&) {} + + //============================================================================== + void startDownloads (const Array& downloads) + { + [[SKPaymentQueue defaultQueue] startDownloads: downloadsToSKDownloads (removeInvalidDownloads (downloads))]; + } + + void pauseDownloads (const Array& downloads) + { + [[SKPaymentQueue defaultQueue] pauseDownloads: downloadsToSKDownloads (removeInvalidDownloads (downloads))]; + } + + void resumeDownloads (const Array& downloads) + { + [[SKPaymentQueue defaultQueue] resumeDownloads: downloadsToSKDownloads (removeInvalidDownloads (downloads))]; + } + + void cancelDownloads (const Array& downloads) + { + [[SKPaymentQueue defaultQueue] cancelDownloads: downloadsToSKDownloads (removeInvalidDownloads (downloads))]; + } + + //============================================================================== + void didReceiveResponse (SKProductsRequest* request, SKProductsResponse* response) override + { + for (auto i = 0; i < pendingProductInfoRequests.size(); ++i) + { + auto& pendingRequest = *pendingProductInfoRequests[i]; + + if (pendingRequest.request == request) + { + if (pendingRequest.type == PendingProductInfoRequest::Type::query) notifyProductsInfoReceived (response.products); + else if (pendingRequest.type == PendingProductInfoRequest::Type::purchase) startPurchase (response.products); + else break; + + pendingProductInfoRequests.remove (i); + return; + } + } + + // Unknown request received! + jassertfalse; + } + + void requestDidFinish (SKRequest* request) override + { + if (auto receiptRefreshRequest = getAs (request)) + { + for (auto i = 0; i < pendingReceiptRefreshRequests.size(); ++i) + { + auto& pendingRequest = *pendingReceiptRefreshRequests[i]; + + if (pendingRequest.request == receiptRefreshRequest) + { + processReceiptRefreshResponseWithSubscriptionsSharedSecret (pendingRequest.subscriptionsSharedSecret); + pendingReceiptRefreshRequests.remove (i); + return; + } + } + } + } + + void updatedTransactions (SKPaymentQueue*, NSArray* transactions) override + { + for (SKPaymentTransaction* transaction in transactions) + { + switch (transaction.transactionState) + { + case SKPaymentTransactionStatePurchasing: break; + case SKPaymentTransactionStateDeferred: break; + case SKPaymentTransactionStateFailed: processTransactionFinish (transaction, false); break; + case SKPaymentTransactionStatePurchased: processTransactionFinish (transaction, true); break; + case SKPaymentTransactionStateRestored: processTransactionFinish (transaction, true); break; + default: jassertfalse; break; // Unexpected transaction state + } + } + } + + void restoreCompletedTransactionsFailedWithError (SKPaymentQueue*, NSError* error) override + { + owner.listeners.call (&Listener::purchasesListRestored, {}, false, nsStringToJuce (error.localizedDescription)); + } + + void restoreCompletedTransactionsFinished (SKPaymentQueue*) override + { + owner.listeners.call (&Listener::purchasesListRestored, restoredPurchases, true, NEEDS_TRANS ("Success")); + restoredPurchases.clear(); + } + + void updatedDownloads (SKPaymentQueue*, NSArray* downloads) override + { + for (SKDownload* download in downloads) + { + if (auto* pendingDownload = getPendingDownloadFor (download)) + { + switch (download.downloadState) + { + case SKDownloadStateWaiting: break; + case SKDownloadStatePaused: owner.listeners.call (&Listener::productDownloadPaused, *pendingDownload); break; + case SKDownloadStateActive: owner.listeners.call (&Listener::productDownloadProgressUpdate, *pendingDownload, + download.progress, RelativeTime (download.timeRemaining)); break; + case SKDownloadStateFinished: + case SKDownloadStateFailed: + case SKDownloadStateCancelled: processDownloadFinish (pendingDownload, download); break; + + default: jassertfalse; break; // Unexpected download state + } + } + } + } + + //============================================================================== + void notifyProductsInfoReceived (NSArray* products) + { + Array productsToReturn; + + for (SKProduct* skProduct in products) + productsToReturn.add (SKProductToIAPProduct (skProduct)); + + owner.listeners.call (&Listener::productsInfoReturned, productsToReturn); + } + + void startPurchase (NSArray* products) + { + if ([products count] > 0) + { + // Only one product can be bought at once! + jassert ([products count] == 1); + + auto* product = products[0]; + auto* payment = [SKPayment paymentWithProduct: product]; + [[SKPaymentQueue defaultQueue] addPayment: payment]; + } + else + { + owner.listeners.call (&Listener::productPurchaseFinished, {}, false, + NEEDS_TRANS ("Your app is not setup for payments")); + } + } + + //============================================================================== + Array removeInvalidDownloads (const Array& downloadsToUse) + { + Array downloads (downloadsToUse); + + for (int i = downloads.size(); --i >= 0;) + { + auto hasPendingDownload = hasDownloadInPendingDownloadsTransaction (*downloads[i]); + + // Invalid download passed, it does not exist in pending downloads list + jassert (hasPendingDownload); + + if (! hasPendingDownload) + downloads.remove (i); + } + + return downloads; + } + + bool hasDownloadInPendingDownloadsTransaction (const Download& download) + { + for (auto* pdt : pendingDownloadsTransactions) + for (auto* pendingDownload : pdt->downloads) + if (pendingDownload == &download) + return true; + + return false; + } + + //============================================================================== + void processTransactionFinish (SKPaymentTransaction* transaction, bool success) + { + auto orderId = nsStringToJuce (transaction.transactionIdentifier); + auto packageName = nsStringToJuce ([[NSBundle mainBundle] bundleIdentifier]); + auto productId = nsStringToJuce (transaction.payment.productIdentifier); + auto purchaseTime = Time (1000 * (int64) transaction.transactionDate.timeIntervalSince1970) + .toString (true, true, true, true); + + Purchase purchase { orderId, productId, packageName, purchaseTime, {} }; + + Array downloads; + + // If transaction failed or there are no downloads, finish the transaction immediately, otherwise + // finish the transaction only after all downloads are finished. + if (transaction.transactionState == SKPaymentTransactionStateFailed + || transaction.downloads == nil + || [transaction.downloads count] == 0) + { + [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; + } + else + { + // On application startup or when the app is resumed we may receive multiple + // "purchased" callbacks with the same underlying transaction. Sadly, only + // the last set of downloads will be valid. + auto* pdt = getPendingDownloadsTransactionForSKTransaction (transaction); + + if (pdt == nullptr) + { + pdt = pendingDownloadsTransactions.add (new PendingDownloadsTransaction (transaction)); + } + else + { + pdt->downloads.clear(); + pdt->addDownloadsFromSKTransaction (transaction); + } + + for (auto* download : pdt->downloads) + downloads.add (download); + } + + if (transaction.transactionState == SKPaymentTransactionStateRestored) + restoredPurchases.add ({ purchase, downloads }); + else + owner.listeners.call (&Listener::productPurchaseFinished, { purchase, downloads }, success, + SKPaymentTransactionStateToString (transaction.transactionState)); + } + + PendingDownloadsTransaction* getPendingDownloadsTransactionForSKTransaction (SKPaymentTransaction* transaction) + { + for (auto* pdt : pendingDownloadsTransactions) + if (pdt->transaction == transaction) + return pdt; + + return nullptr; + } + + //============================================================================== + PendingDownloadsTransaction* getPendingDownloadsTransactionSKDownloadFor (SKDownload* download) + { + for (auto* pdt : pendingDownloadsTransactions) + for (auto* pendingDownload : pdt->downloads) + if (pendingDownload->download == download) + return pdt; + + jassertfalse; + return nullptr; + } + + Download* getPendingDownloadFor (SKDownload* download) + { + if (auto* pdt = getPendingDownloadsTransactionSKDownloadFor (download)) + for (auto* pendingDownload : pdt->downloads) + if (pendingDownload->download == download) + return pendingDownload; + + jassertfalse; + return nullptr; + } + + void processDownloadFinish (Download* pendingDownload, SKDownload* download) + { + if (auto* pdt = getPendingDownloadsTransactionSKDownloadFor (download)) + { + auto contentURL = download.downloadState == SKDownloadStateFinished + ? URL (nsStringToJuce (download.contentURL.absoluteString)) + : URL(); + + owner.listeners.call (&Listener::productDownloadFinished, *pendingDownload, contentURL); + + if (pdt->canBeMarkedAsFinished()) + { + // All downloads finished, mark transaction as finished too. + [[SKPaymentQueue defaultQueue] finishTransaction: pdt->transaction]; + + pendingDownloadsTransactions.removeObject (pdt); + } + } + } + + //============================================================================== + void processReceiptRefreshResponseWithSubscriptionsSharedSecret (const String& secret) + { + auto* receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; + + if (auto* receiptData = [NSData dataWithContentsOfURL: receiptURL]) + fetchReceiptDetailsFromAppStore (receiptData, secret); + else + owner.listeners.call (&Listener::purchasesListRestored, {}, false, NEEDS_TRANS ("Receipt fetch failed")); + } + + void fetchReceiptDetailsFromAppStore (NSData* receiptData, const String& secret) + { + auto* requestContents = [NSMutableDictionary dictionaryWithCapacity: (NSUInteger) (secret.isNotEmpty() ? 2 : 1)]; + [requestContents setObject: [receiptData base64EncodedStringWithOptions:0] forKey: nsStringLiteral ("receipt-data")]; + + if (secret.isNotEmpty()) + [requestContents setObject: juceStringToNS (secret) forKey: nsStringLiteral ("password")]; + + NSError* error; + auto* requestData = [NSJSONSerialization dataWithJSONObject: requestContents + options: 0 + error: &error]; + if (requestData == nil) + { + owner.listeners.call (&Listener::purchasesListRestored, {}, false, NEEDS_TRANS ("Receipt fetch failed")); + return; + } + + #if JUCE_IN_APP_PURCHASES_USE_SANDBOX_ENVIRONMENT + auto storeURL = "https://sandbox.itunes.apple.com/verifyReceipt"; + #else + auto storeURL = "https://buy.itunes.apple.com/verifyReceipt"; + #endif + + // TODO: use juce URL here + auto* storeRequest = [NSMutableURLRequest requestWithURL: [NSURL URLWithString: nsStringLiteral (storeURL)]]; + [storeRequest setHTTPMethod: nsStringLiteral ("POST")]; + [storeRequest setHTTPBody: requestData]; + + auto* task = [[NSURLSession sharedSession] dataTaskWithRequest: storeRequest + completionHandler: + ^(NSData* data, NSURLResponse*, NSError* connectionError) + { + if (connectionError != nil) + { + owner.listeners.call (&Listener::purchasesListRestored, {}, false, NEEDS_TRANS ("Receipt fetch failed")); + } + else + { + NSError* err; + + if (NSDictionary* receiptDetails = [NSJSONSerialization JSONObjectWithData: data options: 0 error: &err]) + processReceiptDetails (receiptDetails); + else + owner.listeners.call (&Listener::purchasesListRestored, {}, false, NEEDS_TRANS ("Receipt fetch failed")); + } + }]; + + [task resume]; + } + + void processReceiptDetails (NSDictionary* receiptDetails) + { + if (auto receipt = getAs (receiptDetails[nsStringLiteral ("receipt")])) + { + if (auto bundleId = getAs (receipt[nsStringLiteral ("bundle_id")])) + { + if (auto inAppPurchases = getAs (receipt[nsStringLiteral ("in_app")])) + { + Array purchases; + + for (id inAppPurchaseData in inAppPurchases) + { + if (auto* purchaseData = getAs (inAppPurchaseData)) + { + // Ignore products that were cancelled. + if (purchaseData[nsStringLiteral ("cancellation_date")] != nil) + continue; + + if (auto transactionId = getAs (purchaseData[nsStringLiteral ("original_transaction_id")])) + { + if (auto productId = getAs (purchaseData[nsStringLiteral ("product_id")])) + { + if (auto purchaseTime = getAs (purchaseData[nsStringLiteral ("purchase_date_ms")])) + { + purchases.add ({ { nsStringToJuce (transactionId), + nsStringToJuce (productId), + nsStringToJuce (bundleId), + Time ([purchaseTime integerValue]).toString (true, true, true, true), + {} }, {} }); + } + else + { + return sendReceiptFetchFail(); + } + } + } + } + else + { + return sendReceiptFetchFail(); + } + } + + MessageManager::callAsync ([this, purchases]() { owner.listeners.call (&Listener::purchasesListRestored, + purchases, true, NEEDS_TRANS ("Success")); }); + return; + } + } + } + + sendReceiptFetchFail(); + } + + void sendReceiptFetchFail() + { + MessageManager::callAsync ([this]() { owner.listeners.call (&Listener::purchasesListRestored, + {}, false, NEEDS_TRANS ("Receipt fetch failed")); }); + } + + //============================================================================== + static Product SKProductToIAPProduct (SKProduct* skProduct) + { + NSNumberFormatter* numberFormatter = [[NSNumberFormatter alloc] init]; + [numberFormatter setFormatterBehavior: NSNumberFormatterBehavior10_4]; + [numberFormatter setNumberStyle: NSNumberFormatterCurrencyStyle]; + [numberFormatter setLocale: skProduct.priceLocale]; + + auto identifier = nsStringToJuce (skProduct.productIdentifier); + auto title = nsStringToJuce (skProduct.localizedTitle); + auto description = nsStringToJuce (skProduct.localizedDescription); + auto priceLocale = nsStringToJuce (skProduct.priceLocale.languageCode); + auto price = nsStringToJuce ([numberFormatter stringFromNumber: skProduct.price]); + + [numberFormatter release]; + + return { identifier, title, description, price, priceLocale }; + } + + static String SKPaymentTransactionStateToString (SKPaymentTransactionState state) + { + switch (state) + { + case SKPaymentTransactionStatePurchasing: return NEEDS_TRANS ("Purchasing"); + case SKPaymentTransactionStatePurchased: return NEEDS_TRANS ("Success"); + case SKPaymentTransactionStateFailed: return NEEDS_TRANS ("Failure"); + case SKPaymentTransactionStateRestored: return NEEDS_TRANS ("Restored"); + case SKPaymentTransactionStateDeferred: return NEEDS_TRANS ("Deferred"); + default: jassertfalse; return NEEDS_TRANS ("Unknown status"); + } + + } + + static Download::Status SKDownloadStateToDownloadStatus (SKDownloadState state) + { + switch (state) + { + case SKDownloadStateWaiting: return Download::Status::waiting; + case SKDownloadStateActive: return Download::Status::active; + case SKDownloadStatePaused: return Download::Status::paused; + case SKDownloadStateFinished: return Download::Status::finished; + case SKDownloadStateFailed: return Download::Status::failed; + case SKDownloadStateCancelled: return Download::Status::cancelled; + default: jassertfalse; return Download::Status::waiting; + } + } + + static NSArray* downloadsToSKDownloads (const Array& downloads) + { + NSMutableArray* skDownloads = [NSMutableArray arrayWithCapacity: (NSUInteger) downloads.size()]; + + for (const auto& d : downloads) + if (auto impl = dynamic_cast(d)) + [skDownloads addObject: impl->download]; + + return skDownloads; + } + + template + static ObjCType* getAs (id o) + { + if (o == nil || ! [o isKindOfClass: [ObjCType class]]) + return nil; + + return (ObjCType*) o; + } + + //============================================================================== + InAppPurchases& owner; + + OwnedArray pendingProductInfoRequests; + OwnedArray pendingReceiptRefreshRequests; + + OwnedArray pendingDownloadsTransactions; + Array restoredPurchases; +};