From 83876a508b1142235187b51a0b3afd097970391f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjo=CC=88rn=20Ritzl?= Date: Thu, 23 Jul 2020 12:18:40 +0200 Subject: [PATCH] Almost done --- .../manifests/android/AndroidManifest.xml | 4 +- extension-iap/manifests/android/build.gradle | 3 + extension-iap/src/iap_android.cpp | 8 +- .../vending/billing/IInAppBillingService.java | 501 ------------ .../java/com/defold/iap/IapGooglePlay.java | 767 +++++++----------- .../com/defold/iap/IapGooglePlayActivity.java | 371 --------- main/main.gui | 181 ++++- main/main.gui_script | 22 +- 8 files changed, 513 insertions(+), 1344 deletions(-) create mode 100644 extension-iap/manifests/android/build.gradle delete mode 100644 extension-iap/src/java/com/android/vending/billing/IInAppBillingService.java delete mode 100644 extension-iap/src/java/com/defold/iap/IapGooglePlayActivity.java diff --git a/extension-iap/manifests/android/AndroidManifest.xml b/extension-iap/manifests/android/AndroidManifest.xml index c6b0a3c..158ba0a 100644 --- a/extension-iap/manifests/android/AndroidManifest.xml +++ b/extension-iap/manifests/android/AndroidManifest.xml @@ -3,7 +3,7 @@ package="{{android.package}}"> - + - - diff --git a/extension-iap/manifests/android/build.gradle b/extension-iap/manifests/android/build.gradle new file mode 100644 index 0000000..8ad2663 --- /dev/null +++ b/extension-iap/manifests/android/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation 'com.android.billingclient:billing:3.0.0' +} diff --git a/extension-iap/src/iap_android.cpp b/extension-iap/src/iap_android.cpp index fa6f5a1..ccd4a27 100644 --- a/extension-iap/src/iap_android.cpp +++ b/extension-iap/src/iap_android.cpp @@ -52,7 +52,12 @@ static IAP g_IAP; static int IAP_ProcessPendingTransactions(lua_State* L) { - //todo handle pending transactions if there is such thing on Android + IAP* iap = &g_IAP; + + JNIEnv* env = Attach(); + env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_ProcessPendingConsumables, g_IAP.m_IAPJNI); + Detach(); + return 0; } @@ -468,4 +473,3 @@ static dmExtension::Result FinalizeIAP(dmExtension::Params* params) DM_DECLARE_EXTENSION(IAPExt, "IAP", 0, 0, InitializeIAP, UpdateIAP, 0, FinalizeIAP) #endif //DM_PLATFORM_ANDROID - diff --git a/extension-iap/src/java/com/android/vending/billing/IInAppBillingService.java b/extension-iap/src/java/com/android/vending/billing/IInAppBillingService.java deleted file mode 100644 index 0d728e5..0000000 --- a/extension-iap/src/java/com/android/vending/billing/IInAppBillingService.java +++ /dev/null @@ -1,501 +0,0 @@ -/* - * This file is auto-generated. DO NOT MODIFY. - * Original file: /Users/chmu/android_workspace/TestBilling/src/com/android/vending/billing/IInAppBillingService.aidl - */ -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_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; -} -} -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; -} -/** - * Checks support for the requested billing API version, package and in-app type. - * Minimum API version supported by this interface is 3. - * @param apiVersion the billing version which the app is using - * @param packageName the package name of the calling app - * @param type type of the in-app item being purchased "inapp" for one-time purchases - * and "subs" for subscription. - * @return RESULT_OK(0) on success, corresponding result code on failures - */ -@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 Third-party is using - * @param packageName the package name of the calling app - * @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, other response codes on - * failure as listed above. - * "DETAILS_LIST" with a StringArrayList containing purchase information - * in JSON format similar to: - * '{ "productId" : "exampleSku", "type" : "inapp", "price" : "$5.00", - * "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 the type of the in-app item ("inapp" for one-time purchases - * and "subs" for subscription). - * @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, other response codes on - * failure as listed above. - * "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, other response codes on - * failure as listed above. - * "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 - * TODO: change this to app-specific keys. - */ -@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 the type of the in-app items being requested - * ("inapp" for one-time purchases and "subs" for subscription). - * @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, other response codes on - * failure as listed above. - * "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; -} -/** - * Consume the last purchase of the given SKU. This will result in this item being removed - * from all subsequent responses to getPurchases() and allow re-purchase of this item. - * @param apiVersion billing API version that the app is using - * @param packageName package name of the calling app - * @param purchaseToken token in the purchase information JSON that identifies the purchase - * to be consumed - * @return 0 if consumption succeeded. Appropriate error values for failures. - */ -@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; -} -} -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); -} -/** - * Checks support for the requested billing API version, package and in-app type. - * Minimum API version supported by this interface is 3. - * @param apiVersion the billing version which the app is using - * @param packageName the package name of the calling app - * @param type type of the in-app item being purchased "inapp" for one-time purchases - * and "subs" for subscription. - * @return RESULT_OK(0) on success, corresponding result code on failures - */ -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 Third-party is using - * @param packageName the package name of the calling app - * @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, other response codes on - * failure as listed above. - * "DETAILS_LIST" with a StringArrayList containing purchase information - * in JSON format similar to: - * '{ "productId" : "exampleSku", "type" : "inapp", "price" : "$5.00", - * "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 the type of the in-app item ("inapp" for one-time purchases - * and "subs" for subscription). - * @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, other response codes on - * failure as listed above. - * "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, other response codes on - * failure as listed above. - * "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 - * TODO: change this to app-specific keys. - */ -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 the type of the in-app items being requested - * ("inapp" for one-time purchases and "subs" for subscription). - * @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, other response codes on - * failure as listed above. - * "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; -/** - * Consume the last purchase of the given SKU. This will result in this item being removed - * from all subsequent responses to getPurchases() and allow re-purchase of this item. - * @param apiVersion billing API version that the app is using - * @param packageName package name of the calling app - * @param purchaseToken token in the purchase information JSON that identifies the purchase - * to be consumed - * @return 0 if consumption succeeded. Appropriate error values for failures. - */ -public int consumePurchase(int apiVersion, java.lang.String packageName, java.lang.String purchaseToken) throws android.os.RemoteException; -} diff --git a/extension-iap/src/java/com/defold/iap/IapGooglePlay.java b/extension-iap/src/java/com/defold/iap/IapGooglePlay.java index fce6853..67f6be9 100644 --- a/extension-iap/src/java/com/defold/iap/IapGooglePlay.java +++ b/extension-iap/src/java/com/defold/iap/IapGooglePlay.java @@ -5,507 +5,366 @@ import java.util.ArrayList; import java.util.Date; import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.HashMap; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import org.json.JSONException; import org.json.JSONObject; +import org.json.JSONArray; import android.app.Activity; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.pm.ResolveInfo; -import android.content.pm.PackageManager; -import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.os.Message; -import android.os.Messenger; -import android.os.RemoteException; import android.util.Log; -import com.android.vending.billing.IInAppBillingService; +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingClient.BillingResponseCode; +import com.android.billingclient.api.BillingClient.SkuType; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.Purchase.PurchaseState; +import com.android.billingclient.api.SkuDetails; +import com.android.billingclient.api.ConsumeParams; +import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.SkuDetailsParams; +import com.android.billingclient.api.PurchasesUpdatedListener; +import com.android.billingclient.api.BillingClientStateListener; +import com.android.billingclient.api.ConsumeResponseListener; +import com.android.billingclient.api.SkuDetailsResponseListener; -public class IapGooglePlay implements Handler.Callback { - public static final String PARAM_PRODUCT = "product"; - public static final String PARAM_PRODUCT_TYPE = "product_type"; - public static final String PARAM_PURCHASE_DATA = "purchase_data"; - public static final String PARAM_AUTOFINISH_TRANSACTIONS = "auto_finish_transactions"; - public static final String PARAM_MESSENGER = "com.defold.iap.messenger"; - public static final String RESPONSE_CODE = "RESPONSE_CODE"; - public static final String RESPONSE_GET_SKU_DETAILS_LIST = "DETAILS_LIST"; - public static final String RESPONSE_BUY_INTENT = "BUY_INTENT"; - public static final String RESPONSE_INAPP_PURCHASE_DATA = "INAPP_PURCHASE_DATA"; - public static final String RESPONSE_INAPP_SIGNATURE = "INAPP_DATA_SIGNATURE"; - public static final String RESPONSE_INAPP_ITEM_LIST = "INAPP_PURCHASE_ITEM_LIST"; - public static final String RESPONSE_INAPP_PURCHASE_DATA_LIST = "INAPP_PURCHASE_DATA_LIST"; - public static final String RESPONSE_INAPP_SIGNATURE_LIST = "INAPP_DATA_SIGNATURE_LIST"; - public static final String INAPP_CONTINUATION_TOKEN = "INAPP_CONTINUATION_TOKEN"; +public class IapGooglePlay implements PurchasesUpdatedListener { + public static final String TAG = "IapGooglePlay"; - public static enum Action { - BUY, - RESTORE, - PROCESS_PENDING_CONSUMABLES, - FINISH_TRANSACTION - } + private BillingClient billingClient; - public static final String TAG = "iap"; - - private Activity activity; - private Handler handler; - private Messenger messenger; - private ServiceConnection serviceConn; - private IInAppBillingService service; - - private SkuDetailsThread skuDetailsThread; - private BlockingQueue skuRequestQueue = new ArrayBlockingQueue(16); + private Map products; private IPurchaseListener purchaseListener; - private boolean initialized; private boolean autoFinishTransactions; - - private static interface ISkuRequestListener { - public void onProducts(int resultCode, JSONObject products); - } - - private static class SkuRequest { - private ArrayList skuList; - private ISkuRequestListener listener; - - public SkuRequest(ArrayList skuList, ISkuRequestListener listener) { - this.skuList = skuList; - this.listener = listener; - } - } - - private class SkuDetailsThread extends Thread { - public boolean stop = false; - - private void addProductsFromBundle(Bundle skuDetails, JSONObject products) throws JSONException { - int response = skuDetails.getInt("RESPONSE_CODE"); - if (response == IapJNI.BILLING_RESPONSE_RESULT_OK) { - ArrayList responseList = skuDetails.getStringArrayList("DETAILS_LIST"); - - for (String r : responseList) { - JSONObject product = new JSONObject(r); - products.put(product.getString("productId"), product); - } - } - else { - Log.e(TAG, "Failed to fetch product list: " + response); - } - } - - @Override - public void run() { - while (!stop) { - try { - SkuRequest sr = skuRequestQueue.take(); - if (service == null) { - Log.wtf(TAG, "service is null"); - sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); - continue; - } - if (activity == null) { - Log.wtf(TAG, "activity is null"); - sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); - continue; - } - - String packageName = activity.getPackageName(); - if (packageName == null) - { - Log.wtf(TAG, "activity packageName is null"); - sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); - continue; - } - - try { - Bundle querySkus = new Bundle(); - querySkus.putStringArrayList("ITEM_ID_LIST", sr.skuList); - - JSONObject products = new JSONObject(); - - Bundle inappSkuDetails = service.getSkuDetails(3, packageName, "inapp", querySkus); - addProductsFromBundle(inappSkuDetails, products); - - Bundle subscriptionSkuDetails = service.getSkuDetails(3, packageName, "subs", querySkus); - addProductsFromBundle(subscriptionSkuDetails, products); - - sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_OK, products); - - } catch (RemoteException e) { - Log.e(TAG, "Failed to fetch product list", e); - sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); - } catch (JSONException e) { - Log.e(TAG, "Failed to fetch product list", e); - sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); - } - } catch (InterruptedException e) { - continue; - } - } - } - } + private Activity activity; public IapGooglePlay(Activity activity, boolean autoFinishTransactions) { this.activity = activity; this.autoFinishTransactions = autoFinishTransactions; - } - - private static boolean isPlayStoreInstalled(Context context){ - try { - context.getPackageManager().getPackageInfo("com.android.vending", 0); - return true; - } catch (PackageManager.NameNotFoundException e) { - return false; - } - } - - private void init() { - // NOTE: We must create Handler lazily as construction of - // handlers must be in the context of a "looper" on Android - - if (!isPlayStoreInstalled(activity)) { - Log.e(TAG, "Unable to find Google Play Store (com.android.vending)"); - return; - } - - if (this.initialized) - return; - - this.initialized = true; - this.handler = new Handler(this); - this.messenger = new Messenger(this.handler); - - serviceConn = new ServiceConnection() { + products = new HashMap(); + billingClient = BillingClient.newBuilder(activity).setListener(this).enablePendingPurchases().build(); + billingClient.startConnection(new BillingClientStateListener() { @Override - public void onServiceDisconnected(ComponentName name) { - Log.v(TAG, "IAP disconnected"); - service = null; - } - - @Override - public void onServiceConnected(ComponentName name, IBinder binderService) { - Log.v(TAG, "IAP connected"); - service = IInAppBillingService.Stub.asInterface(binderService); - skuDetailsThread = new SkuDetailsThread(); - skuDetailsThread.start(); - } - }; - - Intent serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND"); - // Limit intent to vending package - serviceIntent.setPackage("com.android.vending"); - List intentServices = activity.getPackageManager().queryIntentServices(serviceIntent, 0); - if (intentServices != null && !intentServices.isEmpty()) { - // service available to handle that Intent - activity.bindService(serviceIntent, serviceConn, Context.BIND_AUTO_CREATE); - } else { - serviceConn = null; - Log.e(TAG, "Billing service unavailable on device."); - } - } - - public void stop() { - this.activity.runOnUiThread(new Runnable() { - @Override - public void run() { - if (serviceConn != null) { - activity.unbindService(serviceConn); - serviceConn = null; - } - if (skuDetailsThread != null) { - skuDetailsThread.stop = true; - skuDetailsThread.interrupt(); - try { - skuDetailsThread.join(); - } catch (InterruptedException e) { - Log.wtf(TAG, "Failed to join thread", e); - } - } - } - }); - } - - - private void queueSkuRequest(final SkuRequest request) { - this.activity.runOnUiThread(new Runnable() { - @Override - public void run() { - init(); - - if (serviceConn != null) { - try { - skuRequestQueue.put(request); - } catch (InterruptedException e) { - Log.wtf(TAG, "Failed to add sku request", e); - request.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE, null); - } - } else { - request.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE, null); - } - } - }); - } - - public void listItems(final String skus, final IListProductsListener listener, final long commandPtr) { - ArrayList skuList = new ArrayList(); - for (String x : skus.split(",")) { - if (x.trim().length() > 0) { - skuList.add(x); - } - } - - queueSkuRequest(new SkuRequest(skuList, new ISkuRequestListener() { - @Override - public void onProducts(int resultCode, JSONObject products) { - if (products != null && products.length() > 0) { - try { - // go through all of the products and convert them into - // the generic product format used for all IAP implementations - Iterator keys = products.keys(); - while(keys.hasNext()) { - String key = keys.next(); - if (products.get(key) instanceof JSONObject ) { - JSONObject product = products.getJSONObject(key); - products.put(key, convertProduct(product)); - } - } - listener.onProductsResult(resultCode, products.toString(), commandPtr); - } - catch(JSONException e) { - Log.wtf(TAG, "Failed to convert products", e); - listener.onProductsResult(resultCode, null, commandPtr); - } + public void onBillingSetupFinished(BillingResult billingResult) { + if (billingResult.getResponseCode() == BillingResponseCode.OK) { + Log.v(TAG, "Setup finished"); + // TODO BillingClient.queryPurchases() } else { - listener.onProductsResult(resultCode, null, commandPtr); + Log.wtf(TAG, "Setup error: " + billingResult.getDebugMessage()); } } - })); - } - // Convert the product data into the generic format shared between all Defold IAP implementations - private static JSONObject convertProduct(JSONObject product) { - try { - // Deep copy and modify - JSONObject p = new JSONObject(product.toString()); - p.put("price_string", p.get("price")); - p.put("ident", p.get("productId")); - // It is not yet possible to obtain the price (num) and currency code on Android for the correct locale/region. - // They have a currency code (price_currency_code), which reflects the merchant's locale, instead of the user's - // https://code.google.com/p/marketbilling/issues/detail?id=93&q=currency%20code&colspec=ID%20Type%20Status%20Google%20Priority%20Milestone%20Owner%20Summary - double price = 0.0; - if (p.has("price_amount_micros")) { - price = p.getLong("price_amount_micros") * 0.000001; - } - String currency_code = "Unknown"; - if (p.has("price_currency_code")) { - currency_code = (String)p.get("price_currency_code"); - } - p.put("currency_code", currency_code); - p.put("price", price); - - p.remove("productId"); - p.remove("type"); - p.remove("price_amount_micros"); - p.remove("price_currency_code"); - return p; - } catch (JSONException e) { - Log.wtf(TAG, "Failed to convert product json", e); - } - - return null; - } - - private void buyProduct(final String product, final String type, final IPurchaseListener listener) { - this.activity.runOnUiThread(new Runnable() { @Override - public void run() { - init(); - IapGooglePlay.this.purchaseListener = listener; - Intent intent = new Intent(activity, IapGooglePlayActivity.class); - intent.putExtra(PARAM_MESSENGER, messenger); - intent.putExtra(PARAM_AUTOFINISH_TRANSACTIONS, IapGooglePlay.this.autoFinishTransactions); - intent.putExtra(PARAM_PRODUCT, product); - intent.putExtra(PARAM_PRODUCT_TYPE, type); - intent.setAction(Action.BUY.toString()); - activity.startActivity(intent); + public void onBillingServiceDisconnected() { + Log.v(TAG, "Service disconnected"); } }); } - - public void buy(final String product, final IPurchaseListener listener) { - ArrayList skuList = new ArrayList(); - skuList.add(product); - queueSkuRequest(new SkuRequest(skuList, new ISkuRequestListener() { - @Override - public void onProducts(int resultCode, JSONObject products) { - String type = "inapp"; - if (resultCode == IapJNI.BILLING_RESPONSE_RESULT_OK && products != null) { - try { - JSONObject productData = products.getJSONObject(product); - type = productData.getString("type"); - } - catch(JSONException e) { - Log.wtf(TAG, "Failed to get product type before buying, assuming type 'inapp'", e); - } - } - else { - Log.wtf(TAG, "Failed to list product before buying, assuming type 'inapp'"); - } - buyProduct(product, type, listener); - } - })); - } - - public void finishTransaction(final String receipt, final IPurchaseListener listener) { - if(IapGooglePlay.this.autoFinishTransactions) { - return; - } - this.activity.runOnUiThread(new Runnable() { - @Override - public void run() { - init(); - IapGooglePlay.this.purchaseListener = listener; - Intent intent = new Intent(activity, IapGooglePlayActivity.class); - intent.putExtra(PARAM_MESSENGER, messenger); - intent.putExtra(PARAM_AUTOFINISH_TRANSACTIONS, false); - intent.putExtra(PARAM_PURCHASE_DATA, receipt); - intent.setAction(Action.FINISH_TRANSACTION.toString()); - activity.startActivity(intent); - } - }); - } - - public void processPendingConsumables(final IPurchaseListener listener) { - this.activity.runOnUiThread(new Runnable() { - @Override - public void run() { - init(); - IapGooglePlay.this.purchaseListener = listener; - Intent intent = new Intent(activity, IapGooglePlayActivity.class); - intent.putExtra(PARAM_MESSENGER, messenger); - intent.putExtra(PARAM_AUTOFINISH_TRANSACTIONS, IapGooglePlay.this.autoFinishTransactions); - intent.setAction(Action.PROCESS_PENDING_CONSUMABLES.toString()); - activity.startActivity(intent); - } - }); - } - - public void restore(final IPurchaseListener listener) { - this.activity.runOnUiThread(new Runnable() { - @Override - public void run() { - init(); - IapGooglePlay.this.purchaseListener = listener; - Intent intent = new Intent(activity, IapGooglePlayActivity.class); - intent.putExtra(PARAM_MESSENGER, messenger); - intent.putExtra(PARAM_AUTOFINISH_TRANSACTIONS, IapGooglePlay.this.autoFinishTransactions); - intent.setAction(Action.RESTORE.toString()); - activity.startActivity(intent); - } - }); - } - - public static String toISO8601(final Date date) { + public String toISO8601(final Date date) { String formatted = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").format(date); return formatted.substring(0, 22) + ":" + formatted.substring(22); } - private static String convertPurchase(String purchase, String signature) { + private String convertPurchase(Purchase purchase) { + // original JSON: + // { + // "orderId":"GPA.3301-1670-7033-37542", + // "packageName":"com.defold.extension.iap", + // "productId":"com.defold.iap.goldbar.medium", + // "purchaseTime":1595455967875, + // "purchaseState":0, + // "purchaseToken":"kacckamkehbbammphdcnhbme.AO-J1OxznnK6E8ILqaAgrPa-3sfaHny424R1e_ZJ2LkaJVsy-5aEOmHizw0vUp-017m8OUvw1rSvfAHbOog1fIvDGJmjaze3MEVFOh1ayJsNFfPDUGwMA_u_9rlV7OqX_nnIyDShH2KE5WrnMC0yQyw7sg5hfgeW6A", + // "acknowledged":false + // } + Log.d(TAG, "convertPurchase() original json: " + purchase.getOriginalJson()); + JSONObject p = new JSONObject(); try { - JSONObject p = new JSONObject(purchase); - p.put("ident", p.get("productId")); - p.put("state", IapJNI.TRANS_STATE_PURCHASED); - - // We check if orderId is actually set here, otherwise we return a blank string. - // This is what Google used to do, but after some updates around June/May 2016 - // they stopped to include the orderId key at all for test purchases. See: DEF-1940 - if (p.has("orderId")) { - p.put("trans_ident", p.get("orderId")); - } else { - p.put("trans_ident", ""); - } - - p.put("date", toISO8601(new Date(p.getLong("purchaseTime")))); - // Receipt is the complete json data - // http://robertomurray.co.uk/blog/2013/server-side-google-play-in-app-billing-receipt-validation-and-testing/ - p.put("receipt", purchase); - p.put("signature", signature); - // TODO: How to simulate original_trans on iOS? - - p.remove("packageName"); - p.remove("orderId"); - p.remove("productId"); - p.remove("developerPayload"); - p.remove("purchaseTime"); - p.remove("purchaseState"); - p.remove("purchaseToken"); - - return p.toString(); - - } catch (JSONException e) { - Log.wtf(TAG, "Failed to convert purchase json", e); + JSONObject original = new JSONObject(purchase.getOriginalJson()); + p.put("ident", original.get("productId")); + p.put("state", purchaseStateToDefoldState(purchase.getPurchaseState())); + p.put("trans_ident", purchase.getOrderId()); + p.put("date", toISO8601(new Date(purchase.getPurchaseTime()))); + p.put("receipt", purchase.getPurchaseToken()); + p.put("signature", purchase.getSignature()); } + catch (JSONException e) { + Log.wtf(TAG, "Failed to convert purchase", e); + } + return p.toString(); + } - return null; + private JSONObject convertSkuDetails(SkuDetails skuDetails) { + JSONObject p = new JSONObject(); + try { + p.put("price_string", skuDetails.getPrice()); + p.put("ident", skuDetails.getSku()); + p.put("currency_code", skuDetails.getPriceCurrencyCode()); + p.put("price", skuDetails.getPriceAmountMicros() * 0.000001); + } + catch(JSONException e) { + Log.wtf(TAG, "Failed to convert sku details", e); + } + return p; + } + + private int purchaseStateToDefoldState(int purchaseState) { + int defoldState; + switch(purchaseState) { + case PurchaseState.PENDING: + defoldState = IapJNI.TRANS_STATE_PURCHASING; + break; + case PurchaseState.PURCHASED: + defoldState = IapJNI.TRANS_STATE_PURCHASED; + break; + default: + case PurchaseState.UNSPECIFIED_STATE: + defoldState = IapJNI.TRANS_STATE_UNVERIFIED; + break; + } + return defoldState; + } + + private int billingResponseCodeToDefoldResponse(int responseCode) + { + int defoldResponse; + switch(responseCode) { + case BillingResponseCode.BILLING_UNAVAILABLE: + defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE; + break; + case BillingResponseCode.DEVELOPER_ERROR: + defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_DEVELOPER_ERROR; + break; + case BillingResponseCode.ITEM_ALREADY_OWNED: + defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED; + break; + case BillingResponseCode.ITEM_NOT_OWNED: + defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED; + break; + case BillingResponseCode.ITEM_UNAVAILABLE: + defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE; + break; + case BillingResponseCode.OK: + defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_OK; + break; + case BillingResponseCode.SERVICE_TIMEOUT: + case BillingResponseCode.SERVICE_UNAVAILABLE: + case BillingResponseCode.SERVICE_DISCONNECTED: + defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_SERVICE_UNAVAILABLE; + break; + case BillingResponseCode.USER_CANCELED: + defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_USER_CANCELED; + break; + case BillingResponseCode.FEATURE_NOT_SUPPORTED: + case BillingResponseCode.ERROR: + default: + defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_ERROR; + break; + } + return defoldResponse; + } + + private int billingResultToDefoldResponse(BillingResult result) { + return billingResponseCodeToDefoldResponse(result.getResponseCode()); + } + + // ------------------------------------------- + + private List queryPurchases(final String type) { + PurchasesResult result = billingClient.queryPurchases(type); + if (result.getBillingResult().getResponseCode() != BillingResponseCode.OK) { + Log.e(TAG, "Unable to query pending purchases: " + result.getBillingResult().getDebugMessage()); + return new ArrayList(); + } + return result.getPurchasesList(); + } + + public void processPendingConsumables(final IPurchaseListener purchaseListener) + { + Log.d(TAG, "processPendingConsumables()"); + List purchasesList = new ArrayList(); + purchasesList.addAll(queryPurchases(SkuType.INAPP)); + purchasesList.addAll(queryPurchases(SkuType.SUBS)); + for (Purchase purchase : purchasesList) { + handlePurchase(purchase, purchaseListener); + } + } + + // ------------------------------------------- + + private void consumePurchase(final Purchase purchase, final IPurchaseListener purchaseListener) + { + Log.d(TAG, "consumePurchase()"); + ConsumeParams consumeParams = ConsumeParams.newBuilder() + .setPurchaseToken(purchase.getPurchaseToken()) + .build(); + + billingClient.consumeAsync(consumeParams, new ConsumeResponseListener() { + @Override + public void onConsumeResponse(BillingResult billingResult, String purchaseToken) { + Log.d(TAG, "consumePurchase() onConsumeResponse " + billingResult.getResponseCode() + " purchaseToken: " + purchaseToken); + if (billingResult.getResponseCode() != BillingResponseCode.OK) { + Log.e(TAG, "Unable to consume purchase: " + billingResult.getDebugMessage()); + purchaseListener.onPurchaseResult(billingResultToDefoldResponse(billingResult), ""); + return; + } + } + }); + } + + public void finishTransaction(final String purchaseToken, final IPurchaseListener purchaseListener) { + Log.d(TAG, "finishTransaction() " + purchaseToken); + List purchasesList = new ArrayList(); + purchasesList.addAll(queryPurchases(SkuType.INAPP)); + purchasesList.addAll(queryPurchases(SkuType.SUBS)); + + for(Purchase p : purchasesList) { + Log.d(TAG, "finishTransaction() purchase: " + p.getOriginalJson()); + if (p.getPurchaseToken().equals(purchaseToken)) { + consumePurchase(p, purchaseListener); + return; + } + } + Log.e(TAG, "Unable to find purchase for token: " + purchaseToken); + purchaseListener.onPurchaseResult(IapJNI.BILLING_RESPONSE_RESULT_ERROR, ""); + } + + // ------------------------------------------- + + private void handlePurchase(final Purchase purchase, final IPurchaseListener purchaseListener) { + if (this.autoFinishTransactions) { + consumePurchase(purchase, purchaseListener); + } + else { + purchaseListener.onPurchaseResult(billingResponseCodeToDefoldResponse(BillingResponseCode.OK), convertPurchase(purchase)); + } } @Override - public boolean handleMessage(Message msg) { - Bundle bundle = msg.getData(); - - String actionString = bundle.getString("action"); - if (actionString == null) { - return false; - } - - if (purchaseListener == null) { - Log.wtf(TAG, "No purchase listener set"); - return false; - } - - Action action = Action.valueOf(actionString); - - if (action == Action.BUY) { - int responseCode = bundle.getInt(RESPONSE_CODE); - String purchaseData = bundle.getString(RESPONSE_INAPP_PURCHASE_DATA); - String dataSignature = bundle.getString(RESPONSE_INAPP_SIGNATURE); - - if (purchaseData != null && dataSignature != null) { - purchaseData = convertPurchase(purchaseData, dataSignature); - } else { - purchaseData = ""; + public void onPurchasesUpdated(BillingResult billingResult, List purchases) { + if (billingResult.getResponseCode() == BillingResponseCode.OK && purchases != null) { + for (Purchase purchase : purchases) { + handlePurchase(purchase, this.purchaseListener); } + } else { + this.purchaseListener.onPurchaseResult(billingResultToDefoldResponse(billingResult), ""); + } + } - purchaseListener.onPurchaseResult(responseCode, purchaseData); - } else if (action == Action.RESTORE) { - Bundle items = bundle.getBundle("items"); + private void buyProduct(SkuDetails sku, final IPurchaseListener purchaseListener) { + this.purchaseListener = purchaseListener; - if (!items.containsKey(RESPONSE_INAPP_ITEM_LIST)) { - purchaseListener.onPurchaseResult(IapJNI.BILLING_RESPONSE_RESULT_ERROR, ""); - return true; - } + BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder() + .setSkuDetails(sku) + .build(); - ArrayList ownedSkus = items.getStringArrayList(RESPONSE_INAPP_ITEM_LIST); - ArrayList purchaseDataList = items.getStringArrayList(RESPONSE_INAPP_PURCHASE_DATA_LIST); - ArrayList signatureList = items.getStringArrayList(RESPONSE_INAPP_SIGNATURE_LIST); - for (int i = 0; i < ownedSkus.size(); ++i) { - int c = IapJNI.BILLING_RESPONSE_RESULT_OK; - String pd = convertPurchase(purchaseDataList.get(i), signatureList.get(i)); - if (pd == null) { - pd = ""; - c = IapJNI.BILLING_RESPONSE_RESULT_ERROR; + BillingResult billingResult = billingClient.launchBillingFlow(this.activity, billingFlowParams); + if (billingResult.getResponseCode() != BillingResponseCode.OK) { + Log.e(TAG, "Purchase failed: " + billingResult.getDebugMessage()); + purchaseListener.onPurchaseResult(billingResultToDefoldResponse(billingResult), ""); + } + } + + public void buy(final String product, final IPurchaseListener purchaseListener) { + Log.d(TAG, "buy()"); + + SkuDetails sku = this.products.get(product); + if (sku != null) { + buyProduct(sku, purchaseListener); + } + else { + List skuList = new ArrayList(); + skuList.add(product); + querySkuDetailsAsync(skuList, new SkuDetailsResponseListener() { + @Override + public void onSkuDetailsResponse(BillingResult billingResult, List skuDetailsList) { + if (billingResult.getResponseCode() == BillingResponseCode.OK) { + buyProduct(skuDetailsList.get(0), purchaseListener); + } + else { + Log.e(TAG, "Unable to get product details before buying: " + billingResult.getDebugMessage()); + purchaseListener.onPurchaseResult(billingResultToDefoldResponse(billingResult), ""); + } } - purchaseListener.onPurchaseResult(c, pd); + }); + } + } + + // ------------------------------------------- + + private void querySkuDetailsAsync(final List skuList, final SkuDetailsResponseListener listener) + { + SkuDetailsResponseListener detailsListener = new SkuDetailsResponseListener() { + private List allSkuDetails = new ArrayList(); + private int queries = 2; + + @Override + public void onSkuDetailsResponse(BillingResult billingResult, List skuDetails) { + // cache skus (cache will be used to speed up buying) + for (SkuDetails sd : skuDetails) + { + IapGooglePlay.this.products.put(sd.getSku(), sd); + } + // + allSkuDetails.addAll(skuDetails); + queries--; + if (queries == 0) + { + listener.onSkuDetailsResponse(billingResult, allSkuDetails); + } + } + }; + billingClient.querySkuDetailsAsync(SkuDetailsParams.newBuilder().setSkusList(skuList).setType(SkuType.INAPP).build(), detailsListener); + billingClient.querySkuDetailsAsync(SkuDetailsParams.newBuilder().setSkusList(skuList).setType(SkuType.SUBS).build(), detailsListener); + } + + public void listItems(final String products, final IListProductsListener productsListener, final long commandPtr) + { + Log.d(TAG, "listItems()"); + + // create list of skus from comma separated string + List skuList = new ArrayList(); + for (String p : products.split(",")) { + if (p.trim().length() > 0) { + skuList.add(p); } } - return true; + + querySkuDetailsAsync(skuList, new SkuDetailsResponseListener() { + @Override + public void onSkuDetailsResponse(BillingResult billingResult, List skuDetails) { + JSONArray a = new JSONArray(); + if (billingResult.getResponseCode() == BillingResponseCode.OK) { + for (SkuDetails sd : skuDetails) + { + a.put(convertSkuDetails(sd)); + } + } + else { + Log.e(TAG, "Unable to list products: " + billingResult.getDebugMessage()); + } + productsListener.onProductsResult(billingResultToDefoldResponse(billingResult), a.toString(), commandPtr); + } + }); + } + + // ------------------------------------------- + + public void stop() + { + Log.d(TAG, "stop()"); + } + + public void restore(final IPurchaseListener listener) + { + Log.d(TAG, "restore()"); } } diff --git a/extension-iap/src/java/com/defold/iap/IapGooglePlayActivity.java b/extension-iap/src/java/com/defold/iap/IapGooglePlayActivity.java deleted file mode 100644 index 54198b0..0000000 --- a/extension-iap/src/java/com/defold/iap/IapGooglePlayActivity.java +++ /dev/null @@ -1,371 +0,0 @@ -package com.defold.iap; - -import java.util.ArrayList; -import java.util.List; - -import org.json.JSONException; -import org.json.JSONObject; - -import android.app.Activity; -import android.app.PendingIntent; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentSender.SendIntentException; -import android.content.ServiceConnection; -import android.content.pm.ResolveInfo; -import android.os.Bundle; -import android.os.IBinder; -import android.os.Message; -import android.os.Messenger; -import android.os.RemoteException; -import android.util.Log; -import android.view.View; -import android.view.ViewGroup.LayoutParams; - -import com.android.vending.billing.IInAppBillingService; -import com.defold.iap.IapGooglePlay.Action; - -public class IapGooglePlayActivity extends Activity { - - private boolean hasPendingPurchases = false; - private boolean autoFinishTransactions = true; - private boolean isDone = false; - private Messenger messenger; - ServiceConnection serviceConn; - IInAppBillingService service; - - // NOTE: Code from "trivialdrivesample" - int getResponseCodeFromBundle(Bundle b) { - Object o = b.get(IapGooglePlay.RESPONSE_CODE); - if (o == null) { - Log.d(IapGooglePlay.TAG, "Bundle with null response code, assuming OK (known issue)"); - return IapJNI.BILLING_RESPONSE_RESULT_OK; - } else if (o instanceof Integer) - return ((Integer) o).intValue(); - else if (o instanceof Long) - return (int) ((Long) o).longValue(); - else { - Log.e(IapGooglePlay.TAG, "Unexpected type for bundle response code."); - Log.e(IapGooglePlay.TAG, o.getClass().getName()); - throw new RuntimeException("Unexpected type for bundle response code: " + o.getClass().getName()); - } - } - - private void sendBuyError(int error) { - Bundle bundle = new Bundle(); - bundle.putString("action", Action.BUY.toString()); - - bundle.putInt(IapGooglePlay.RESPONSE_CODE, error); - Message msg = new Message(); - msg.setData(bundle); - - try { - messenger.send(msg); - } catch (RemoteException e) { - Log.wtf(IapGooglePlay.TAG, "Unable to send message", e); - } - this.finish(); - } - - private void buy(String product, String productType) { - // Flush any pending items, in order to be able to buy the same (new) product again - processPendingConsumables(); - - try { - Bundle buyIntentBundle = service.getBuyIntent(3, getPackageName(), product, productType, ""); - int response = getResponseCodeFromBundle(buyIntentBundle); - if (response == IapJNI.BILLING_RESPONSE_RESULT_OK) { - hasPendingPurchases = true; - PendingIntent pendingIntent = buyIntentBundle.getParcelable("BUY_INTENT"); - startIntentSenderForResult(pendingIntent.getIntentSender(), 1001, new Intent(), Integer.valueOf(0), Integer.valueOf(0), Integer.valueOf(0)); - } else if (response == IapJNI.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED) { - sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED); - } else { - sendBuyError(response); - } - - } catch (RemoteException e) { - Log.e(IapGooglePlay.TAG, String.format("Failed to buy", e)); - sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ERROR); - } catch (SendIntentException e) { - Log.e(IapGooglePlay.TAG, String.format("Failed to buy", e)); - sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ERROR); - } - } - - private boolean consume(String purchaseData) { - try { - if (purchaseData == null) { - Log.e(IapGooglePlay.TAG, String.format("Failed to consume purchase, purchaseData was null!")); - return false; - } - - JSONObject pd = new JSONObject(purchaseData); - if (!pd.isNull("autoRenewing")) { - Log.i(IapGooglePlay.TAG, "Will not consume purchase since it is a subscription."); - return true; - } - String token = pd.getString("purchaseToken"); - int consumeResponse = service.consumePurchase(3, getPackageName(), token); - if (consumeResponse == IapJNI.BILLING_RESPONSE_RESULT_OK) { - return true; - } else { - Log.e(IapGooglePlay.TAG, String.format("Failed to consume purchase (%d)", consumeResponse)); - sendBuyError(consumeResponse); - } - } catch (RemoteException e) { - Log.e(IapGooglePlay.TAG, "Failed to consume purchase", e); - sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ERROR); - } catch (JSONException e) { - Log.e(IapGooglePlay.TAG, "Failed to consume purchase", e); - sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ERROR); - } - return false; - } - - private boolean processPurchase(String purchaseData, String signature) - { - if (this.autoFinishTransactions && !consume(purchaseData)) { - Log.e(IapGooglePlay.TAG, "Failed to consume and send message"); - return false; - } - - Bundle bundle = new Bundle(); - bundle.putString("action", Action.BUY.toString()); - bundle.putInt(IapGooglePlay.RESPONSE_CODE, IapJNI.BILLING_RESPONSE_RESULT_OK); - bundle.putString(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA, purchaseData); - bundle.putString(IapGooglePlay.RESPONSE_INAPP_SIGNATURE, signature); - - Message msg = new Message(); - msg.setData(bundle); - try { - messenger.send(msg); - return true; - } catch (RemoteException e) { - Log.wtf(IapGooglePlay.TAG, "Unable to send message", e); - return false; - } - } - - // Make buy response codes for all consumables not yet processed. - private void processPendingConsumables() { - try { - // Note: subscriptions cannot be consumed - // https://developer.android.com/google/play/billing/api.html#subs - Bundle items = service.getPurchases(3, getPackageName(), "inapp", null); - int response = getResponseCodeFromBundle(items); - if (response == IapJNI.BILLING_RESPONSE_RESULT_OK) { - ArrayList purchaseDataList = items.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA_LIST); - ArrayList signatureList = items.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_SIGNATURE_LIST); - for (int i = 0; i < purchaseDataList.size(); ++i) { - String purchaseData = purchaseDataList.get(i); - String signature = signatureList.get(i); - if (!processPurchase(purchaseData, signature)) { - // abort and retry some other time - break; - } - } - } - } catch (RemoteException e) { - Log.e(IapGooglePlay.TAG, "Failed to process purchase", e); - } - } - - private void restore() { - int response = IapJNI.BILLING_RESPONSE_RESULT_ERROR; - Bundle bundle = new Bundle(); - bundle.putString("action", Action.RESTORE.toString()); - - Bundle items = new Bundle(); - try { - ArrayList purchaseItemList = new ArrayList(); - ArrayList purchaseDataList = new ArrayList(); - ArrayList signatureList = new ArrayList(); - - Bundle inapp = service.getPurchases(3, getPackageName(), "inapp", null); - if (getResponseCodeFromBundle(inapp) == IapJNI.BILLING_RESPONSE_RESULT_OK) { - purchaseItemList.addAll(inapp.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_ITEM_LIST)); - purchaseDataList.addAll(inapp.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA_LIST)); - signatureList.addAll(inapp.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_SIGNATURE_LIST)); - } - - Bundle subs = service.getPurchases(3, getPackageName(), "subs", null); - if (getResponseCodeFromBundle(subs) == IapJNI.BILLING_RESPONSE_RESULT_OK) { - purchaseItemList.addAll(subs.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_ITEM_LIST)); - purchaseDataList.addAll(subs.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA_LIST)); - signatureList.addAll(subs.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_SIGNATURE_LIST)); - } - - items.putStringArrayList(IapGooglePlay.RESPONSE_INAPP_ITEM_LIST, purchaseItemList); - items.putStringArrayList(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA_LIST, purchaseDataList); - items.putStringArrayList(IapGooglePlay.RESPONSE_INAPP_SIGNATURE_LIST, signatureList); - } catch (RemoteException e) { - Log.e(IapGooglePlay.TAG, "Failed to restore purchases", e); - } - bundle.putBundle("items", items); - - bundle.putInt(IapGooglePlay.RESPONSE_CODE, response); - Message msg = new Message(); - msg.setData(bundle); - - try { - messenger.send(msg); - } catch (RemoteException e) { - Log.wtf(IapGooglePlay.TAG, "Unable to send message", e); - } - this.finish(); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - View view = new View(this); - view.setBackgroundColor(0x10ffffff); - setContentView(view, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); - - Intent intent = getIntent(); - final Bundle extras = intent.getExtras(); - this.messenger = (Messenger) extras.getParcelable(IapGooglePlay.PARAM_MESSENGER); - final Action action = Action.valueOf(intent.getAction()); - this.autoFinishTransactions = extras.getBoolean(IapGooglePlay.PARAM_AUTOFINISH_TRANSACTIONS); - - Intent serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND"); - serviceIntent.setPackage("com.android.vending"); - List intentServices = getPackageManager().queryIntentServices(serviceIntent, 0); - if (intentServices != null && !intentServices.isEmpty()) { - // service available to handle that Intent - serviceConn = new ServiceConnection() { - @Override - public void onServiceDisconnected(ComponentName name) { - service = null; - } - - @Override - public void onServiceConnected(ComponentName name, IBinder serviceBinder) { - service = IInAppBillingService.Stub.asInterface(serviceBinder); - if (action == Action.BUY) { - buy(extras.getString(IapGooglePlay.PARAM_PRODUCT), extras.getString(IapGooglePlay.PARAM_PRODUCT_TYPE)); - } else if (action == Action.RESTORE) { - restore(); - } else if (action == Action.PROCESS_PENDING_CONSUMABLES) { - processPendingConsumables(); - finish(); - } else if (action == Action.FINISH_TRANSACTION) { - consume(extras.getString(IapGooglePlay.PARAM_PURCHASE_DATA)); - finish(); - } - } - }; - - bindService(serviceIntent, serviceConn, Context.BIND_AUTO_CREATE); - } else { - // Service will never be connected; just send unavailability message - Bundle bundle = new Bundle(); - bundle.putString("action", intent.getAction()); - bundle.putInt(IapGooglePlay.RESPONSE_CODE, IapJNI.BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE); - Message msg = new Message(); - msg.setData(bundle); - try { - messenger.send(msg); - } catch (RemoteException e) { - Log.wtf(IapGooglePlay.TAG, "Unable to send message", e); - } - this.finish(); - } - } - - @Override - public void finish() { - super.finish(); - this.isDone = true; - } - - @Override - protected void onDestroy() { - if (hasPendingPurchases) { - // Not sure connection is up so need to check here. - if (service != null) { - if(autoFinishTransactions) { - processPendingConsumables(); - } - } - hasPendingPurchases = false; - } - - if( !isDone ) - { - Intent intent = getIntent(); - - if( intent != null && intent.getComponent().getClassName().equals( getClass().getName() ) ) - { - Log.v(IapGooglePlay.TAG, "There's still an intent left: " + intent.getAction() ); - sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ERROR); - } - } - - if (serviceConn != null) { - try - { - unbindService(serviceConn); - } catch (IllegalArgumentException e) { - Log.wtf(IapGooglePlay.TAG, "Unable to unbind service", e); - } - } - super.onDestroy(); - } - - // NOTE: Code from "trivialdrivesample" - int getResponseCodeFromIntent(Intent i) { - Object o = i.getExtras().get(IapGooglePlay.RESPONSE_CODE); - if (o == null) { - Log.e(IapGooglePlay.TAG, "Intent with no response code, assuming OK (known issue)"); - return IapJNI.BILLING_RESPONSE_RESULT_OK; - } else if (o instanceof Integer) { - return ((Integer) o).intValue(); - } else if (o instanceof Long) { - return (int) ((Long) o).longValue(); - } else { - Log.e(IapGooglePlay.TAG, "Unexpected type for intent response code."); - Log.e(IapGooglePlay.TAG, o.getClass().getName()); - throw new RuntimeException("Unexpected type for intent response code: " + o.getClass().getName()); - } - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - Bundle bundle = null; - if (data != null) { - int responseCode = getResponseCodeFromIntent(data); - String purchaseData = data.getStringExtra(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA); - String dataSignature = data.getStringExtra(IapGooglePlay.RESPONSE_INAPP_SIGNATURE); - if (responseCode == IapJNI.BILLING_RESPONSE_RESULT_OK) { - processPurchase(purchaseData, dataSignature); - } else { - bundle = new Bundle(); - bundle.putString("action", Action.BUY.toString()); - bundle.putInt(IapGooglePlay.RESPONSE_CODE, responseCode); - bundle.putString(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA, purchaseData); - bundle.putString(IapGooglePlay.RESPONSE_INAPP_SIGNATURE, dataSignature); - } - } else { - bundle = new Bundle(); - bundle.putString("action", Action.BUY.toString()); - bundle.putInt(IapGooglePlay.RESPONSE_CODE, IapJNI.BILLING_RESPONSE_RESULT_ERROR); - } - - // Send message if generated above - if (bundle != null) { - Message msg = new Message(); - msg.setData(bundle); - try { - messenger.send(msg); - } catch (RemoteException e) { - Log.wtf(IapGooglePlay.TAG, "Unable to send message", e); - } - } - - this.finish(); - } -} diff --git a/main/main.gui b/main/main.gui index 5775982..3500faa 100644 --- a/main/main.gui +++ b/main/main.gui @@ -169,7 +169,7 @@ nodes { } nodes { position { - x: 485.0 + x: 530.0 y: 56.0 z: 0.0 w: 1.0 @@ -199,7 +199,7 @@ nodes { w: 1.0 } type: TYPE_TEMPLATE - id: "reset" + id: "restore" layer: "" inherit_alpha: true alpha: 1.0 @@ -226,7 +226,7 @@ nodes { w: 1.0 } size { - x: 300.0 + x: 200.0 y: 88.0 z: 0.0 w: 1.0 @@ -240,12 +240,12 @@ nodes { type: TYPE_BOX blend_mode: BLEND_MODE_ALPHA texture: "button/button_normal" - id: "reset/larrybutton" + id: "restore/larrybutton" xanchor: XANCHOR_NONE yanchor: YANCHOR_NONE pivot: PIVOT_CENTER adjust_mode: ADJUST_MODE_FIT - parent: "reset" + parent: "restore" layer: "" inherit_alpha: true slice9 { @@ -258,6 +258,7 @@ nodes { clipping_visible: true clipping_inverted: false alpha: 1.0 + overridden_fields: 4 template_node_child: true size_mode: SIZE_MODE_MANUAL } @@ -294,9 +295,9 @@ nodes { } type: TYPE_TEXT blend_mode: BLEND_MODE_ALPHA - text: "Reset" + text: "Restore" font: "larryfont" - id: "reset/larrylabel" + id: "restore/larrylabel" xanchor: XANCHOR_NONE yanchor: YANCHOR_NONE pivot: PIVOT_CENTER @@ -314,7 +315,7 @@ nodes { } adjust_mode: ADJUST_MODE_FIT line_break: false - parent: "reset/larrybutton" + parent: "restore/larrybutton" layer: "" inherit_alpha: true alpha: 1.0 @@ -705,7 +706,7 @@ nodes { } nodes { position { - x: 159.0 + x: 109.0 y: 56.0 z: 0.0 w: 1.0 @@ -762,7 +763,7 @@ nodes { w: 1.0 } size { - x: 300.0 + x: 200.0 y: 88.0 z: 0.0 w: 1.0 @@ -794,6 +795,7 @@ nodes { clipping_visible: true clipping_inverted: false alpha: 1.0 + overridden_fields: 4 template_node_child: true size_mode: SIZE_MODE_MANUAL } @@ -1019,6 +1021,165 @@ nodes { text_leading: 1.0 text_tracking: 0.0 } +nodes { + position { + x: 320.0 + y: 56.0 + z: 0.0 + w: 1.0 + } + rotation { + x: 0.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + scale { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + size { + x: 200.0 + y: 100.0 + z: 0.0 + w: 1.0 + } + color { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + type: TYPE_TEMPLATE + id: "pending" + layer: "" + inherit_alpha: true + alpha: 1.0 + template: "/dirtylarry/button.gui" + template_node_child: false +} +nodes { + position { + x: 0.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + rotation { + x: 0.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + scale { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + size { + x: 200.0 + y: 88.0 + z: 0.0 + w: 1.0 + } + color { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + type: TYPE_BOX + blend_mode: BLEND_MODE_ALPHA + texture: "button/button_normal" + id: "pending/larrybutton" + xanchor: XANCHOR_NONE + yanchor: YANCHOR_NONE + pivot: PIVOT_CENTER + adjust_mode: ADJUST_MODE_FIT + parent: "pending" + layer: "" + inherit_alpha: true + slice9 { + x: 32.0 + y: 32.0 + z: 32.0 + w: 32.0 + } + clipping_mode: CLIPPING_MODE_NONE + clipping_visible: true + clipping_inverted: false + alpha: 1.0 + overridden_fields: 4 + template_node_child: true + size_mode: SIZE_MODE_MANUAL +} +nodes { + position { + x: 0.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + rotation { + x: 0.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + scale { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + size { + x: 200.0 + y: 100.0 + z: 0.0 + w: 1.0 + } + color { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + type: TYPE_TEXT + blend_mode: BLEND_MODE_ALPHA + text: "Pending" + font: "larryfont" + id: "pending/larrylabel" + xanchor: XANCHOR_NONE + yanchor: YANCHOR_NONE + pivot: PIVOT_CENTER + outline { + x: 0.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + shadow { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + adjust_mode: ADJUST_MODE_FIT + line_break: false + parent: "pending/larrybutton" + layer: "" + inherit_alpha: true + alpha: 1.0 + outline_alpha: 1.0 + shadow_alpha: 1.0 + overridden_fields: 8 + template_node_child: true + text_leading: 1.0 + text_tracking: 0.0 +} material: "/builtins/materials/gui.material" adjust_reference: ADJUST_REFERENCE_PARENT max_nodes: 512 diff --git a/main/main.gui_script b/main/main.gui_script index 29ff3fd..f5f7bba 100644 --- a/main/main.gui_script +++ b/main/main.gui_script @@ -36,11 +36,21 @@ local function log(fmt, ...) gui.set_text(gui.get_node("log"), s) end +local function process_pending_transactions() + log("iap.process_pending_transactions()") + iap.process_pending_transactions() +end + local function buy(id) log("iap.buy() " .. id) iap.buy(id) end +local function restore() + log("iap.restore()") + iap.restore() +end + local function list() log("iap.list()") for item, button in pairs(item_buttons) do @@ -67,12 +77,12 @@ end local function buy_listener(self, transaction, error) - pprint(transaction, error) + pprint(transaction) if error then log("iap.buy() error %s - %s", tostring(error.error), tostring(error.reason)) return end - + if iap.get_provider_id() == iap.PROVIDER_ID_GOOGLE and transaction.ident == NON_CONSUMABLE then log("iap.buy() ok - google") gui.set_color(gui.get_node("reset/larrylabel"), vmath.vector4(1,1,1,1)) @@ -109,5 +119,11 @@ function on_input(self, action_id, action) dirtylarry:button("list", action_id, action, function() list() end) + dirtylarry:button("restore", action_id, action, function() + restore() + end) + dirtylarry:button("pending", action_id, action, function() + process_pending_transactions() + end) end -end \ No newline at end of file +end