diff --git a/app/build.gradle b/app/build.gradle index 3d6f06ddc..fac7b882f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -167,6 +167,7 @@ dependencies { implementation 'com.google.apis:google-api-services-tasks:v1-rev47-1.22.0' implementation 'com.google.api-client:google-api-client-android:1.22.0' + googleplayImplementation 'com.android.billingclient:billing:1.0' googleplayImplementation "com.google.android.gms:play-services-location:${GPS_VERSION}" googleplayImplementation "com.google.android.gms:play-services-analytics:${GPS_VERSION}" googleplayImplementation "com.google.android.gms:play-services-auth:${GPS_VERSION}" diff --git a/app/src/googleplay/AndroidManifest.xml b/app/src/googleplay/AndroidManifest.xml index 44e8864af..a3e0d8ab8 100644 --- a/app/src/googleplay/AndroidManifest.xml +++ b/app/src/googleplay/AndroidManifest.xml @@ -3,11 +3,6 @@ xmlns:tools="http://schemas.android.com/tools" package="org.tasks"> - - - - - diff --git a/app/src/googleplay/aidl/com/android/vending/billing/IInAppBillingService.aidl b/app/src/googleplay/aidl/com/android/vending/billing/IInAppBillingService.aidl deleted file mode 100644 index 0092998ad..000000000 --- a/app/src/googleplay/aidl/com/android/vending/billing/IInAppBillingService.aidl +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.vending.billing; - -import android.os.Bundle; - -/** - * 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 - */ -interface IInAppBillingService { - /** - * Checks support for the requested billing API version, package and in-app type. - * Minimum API version supported by this interface is 3. - * @param apiVersion billing API version that 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 subscriptions) - * @return RESULT_OK(0) on success and appropriate response code on failures. - */ - int isBillingSupported(int apiVersion, String packageName, String type); - - /** - * 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" }' - */ - Bundle getSkuDetails(int apiVersion, String packageName, String type, in Bundle skusBundle); - - /** - * 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 - * TODO: change this to app-specific keys. - */ - Bundle getBuyIntent(int apiVersion, String packageName, String sku, String type, - String developerPayload); - - /** - * 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. - */ - Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken); - - /** - * 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 RESULT_OK(0) if consumption succeeded, appropriate response codes on failures. - */ - int consumePurchase(int apiVersion, String packageName, String purchaseToken); - - /** - * This API is currently under development. - */ - int stub(int apiVersion, String packageName, String type); - - /** - * 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 - * TODO: change this to app-specific keys. - */ - Bundle getBuyIntentToReplaceSkus(int apiVersion, String packageName, - in List oldSkus, String newSku, String type, String developerPayload); -} diff --git a/app/src/googleplay/java/com/android/vending/billing/IabBroadcastReceiver.java b/app/src/googleplay/java/com/android/vending/billing/IabBroadcastReceiver.java deleted file mode 100644 index 0bf9e38fb..000000000 --- a/app/src/googleplay/java/com/android/vending/billing/IabBroadcastReceiver.java +++ /dev/null @@ -1,55 +0,0 @@ -/* Copyright (c) 2014 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.vending.billing; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -/** - * Receiver for the "com.android.vending.billing.PURCHASES_UPDATED" Action from the Play Store. - * - *

It is possible that an in-app item may be acquired without the application calling - * getBuyIntent(), for example if the item can be redeemed from inside the Play Store using a - * promotional code. If this application isn't running at the time, then when it is started a call - * to getPurchases() will be sufficient notification. However, if the application is already running - * in the background when the item is acquired, a message to this BroadcastReceiver will indicate - * that the an item has been acquired. - */ -public class IabBroadcastReceiver extends BroadcastReceiver { - - /** The Intent action that this Receiver should filter for. */ - public static final String ACTION = "com.android.vending.billing.PURCHASES_UPDATED"; - - private final IabBroadcastListener mListener; - - public IabBroadcastReceiver(IabBroadcastListener listener) { - mListener = listener; - } - - @Override - public void onReceive(Context context, Intent intent) { - if (mListener != null) { - mListener.receivedBroadcast(); - } - } - - /** Listener interface for received broadcast messages. */ - public interface IabBroadcastListener { - - void receivedBroadcast(); - } -} diff --git a/app/src/googleplay/java/com/android/vending/billing/IabException.java b/app/src/googleplay/java/com/android/vending/billing/IabException.java deleted file mode 100644 index adbdd34c8..000000000 --- a/app/src/googleplay/java/com/android/vending/billing/IabException.java +++ /dev/null @@ -1,49 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.vending.billing; - -/** - * Exception thrown when something went wrong with in-app billing. An IabException has an associated - * IabResult (an error). To get the IAB result that caused this exception to be thrown, call {@link - * #getResult()}. - */ -@SuppressWarnings("ALL") -public class IabException extends Exception { - - IabResult mResult; - - public IabException(IabResult r) { - this(r, null); - } - - public IabException(int response, String message) { - this(new IabResult(response, message)); - } - - public IabException(IabResult r, Exception cause) { - super(r.getMessage(), cause); - mResult = r; - } - - public IabException(int response, String message, Exception cause) { - this(new IabResult(response, message), cause); - } - - /** Returns the IAB result (error) that this exception signals. */ - public IabResult getResult() { - return mResult; - } -} diff --git a/app/src/googleplay/java/com/android/vending/billing/IabHelper.java b/app/src/googleplay/java/com/android/vending/billing/IabHelper.java deleted file mode 100644 index 8c4c73ebf..000000000 --- a/app/src/googleplay/java/com/android/vending/billing/IabHelper.java +++ /dev/null @@ -1,1095 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.vending.billing; - -import android.annotation.SuppressLint; -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.Handler; -import android.os.IBinder; -import android.os.RemoteException; -import android.text.TextUtils; -import android.util.Log; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.Executor; -import org.json.JSONException; - -/** - * Provides convenience methods for in-app billing. You can create one instance of this class for - * your application and use it to process in-app billing operations. It provides synchronous - * (blocking) and asynchronous (non-blocking) methods for many common in-app billing operations, as - * well as automatic signature verification. - * - *

After instantiating, you must perform setup in order to start using the object. To perform - * setup, call the {@link #startSetup} method and provide a listener; that listener will be notified - * when setup is complete, after which (and not before) you may call other methods. - * - *

After setup is complete, you will typically want to request an inventory of owned items and - * subscriptions. See {@link #queryInventory}, {@link #queryInventoryAsync} and related methods. - * - *

When you are done with this object, don't forget to call {@link #dispose} to ensure proper - * cleanup. This object holds a binding to the in-app billing service, which will leak unless you - * dispose of it correctly. If you created the object on an Activity's onCreate method, then the - * recommended place to dispose of it is the Activity's onDestroy method. - * - *

A note about threading: When using this object from a background thread, you may call the - * blocking versions of methods; when using from a UI thread, call only the asynchronous versions - * and handle the results via callbacks. Also, notice that you can only call one asynchronous - * operation at a time; attempting to start a second asynchronous operation while the first one has - * not yet completed will result in an exception being thrown. - */ -@SuppressWarnings("ALL") -@SuppressLint("all") -public class IabHelper { - - // Billing response codes - public static final int BILLING_RESPONSE_RESULT_OK = 0; - public static final int BILLING_RESPONSE_RESULT_USER_CANCELED = 1; - public static final int BILLING_RESPONSE_RESULT_SERVICE_UNAVAILABLE = 2; - public static final int BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE = 3; - public static final int BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE = 4; - public static final int BILLING_RESPONSE_RESULT_DEVELOPER_ERROR = 5; - public static final int BILLING_RESPONSE_RESULT_ERROR = 6; - public static final int BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7; - public static final int BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8; - // IAB Helper error codes - public static final int IABHELPER_ERROR_BASE = -1000; - public static final int IABHELPER_REMOTE_EXCEPTION = -1001; - public static final int IABHELPER_BAD_RESPONSE = -1002; - public static final int IABHELPER_VERIFICATION_FAILED = -1003; - public static final int IABHELPER_SEND_INTENT_FAILED = -1004; - public static final int IABHELPER_USER_CANCELLED = -1005; - public static final int IABHELPER_UNKNOWN_PURCHASE_RESPONSE = -1006; - public static final int IABHELPER_MISSING_TOKEN = -1007; - public static final int IABHELPER_UNKNOWN_ERROR = -1008; - public static final int IABHELPER_SUBSCRIPTIONS_NOT_AVAILABLE = -1009; - public static final int IABHELPER_INVALID_CONSUMPTION = -1010; - public static final int IABHELPER_SUBSCRIPTION_UPDATE_NOT_AVAILABLE = -1011; - // Keys for the responses from InAppBillingService - 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"; - // Item types - public static final String ITEM_TYPE_INAPP = "inapp"; - public static final String ITEM_TYPE_SUBS = "subs"; - // some fields on the getSkuDetails response bundle - public static final String GET_SKU_DETAILS_ITEM_LIST = "ITEM_ID_LIST"; - public static final String GET_SKU_DETAILS_ITEM_TYPE_LIST = "ITEM_TYPE_LIST"; - private final Executor executor; - // Is debug logging enabled? - boolean mDebugLog = false; - String mDebugTag = "IabHelper"; - // Is setup done? - boolean mSetupDone = false; - // Has this object been disposed of? (If so, we should ignore callbacks, etc) - boolean mDisposed = false; - // Are subscriptions supported? - boolean mSubscriptionsSupported = false; - // Is subscription update supported? - boolean mSubscriptionUpdateSupported = false; - // Is an asynchronous operation in progress? - // (only one at a time can be in progress) - boolean mAsyncInProgress = false; - // (for logging/debugging) - // if mAsyncInProgress == true, what asynchronous operation is in progress? - String mAsyncOperation = ""; - // Context we were passed during initialization - Context mContext; - // Connection to the service - IInAppBillingService mService; - ServiceConnection mServiceConn; - // The request code used to launch purchase flow - int mRequestCode; - // The item type of the current purchase flow - String mPurchasingItemType; - // Public key for verifying signature, in base64 encoding - String mSignatureBase64 = null; - // The listener registered on launchPurchaseFlow, which we have to call back when - // the purchase finishes - OnIabPurchaseFinishedListener mPurchaseListener; - - /** - * Creates an instance. After creation, it will not yet be ready to use. You must perform setup by - * calling {@link #startSetup} and wait for setup to complete. This constructor does not block and - * is safe to call from a UI thread. - * - * @param ctx Your application or Activity context. Needed to bind to the in-app billing service. - * @param base64PublicKey Your application's public key, encoded in base64. This is used for - * verification of purchase signatures. You can find your app's base64-encoded public key in - * your application's page on Google Play Developer Console. Note that this is NOT your - * "developer public key". - */ - public IabHelper(Context ctx, String base64PublicKey, Executor executor) { - mContext = ctx.getApplicationContext(); - mSignatureBase64 = base64PublicKey; - this.executor = executor; - logDebug("IAB helper created."); - } - - /** - * Returns a human-readable description for the given response code. - * - * @param code The response code - * @return A human-readable string explaining the result code. It also includes the result code - * numerically. - */ - public static String getResponseDesc(int code) { - String[] iab_msgs = - ("0:OK/1:User Canceled/2:Unknown/" - + "3:Billing Unavailable/4:Item unavailable/" - + "5:Developer Error/6:Error/7:Item Already Owned/" - + "8:Item not owned") - .split("/"); - String[] iabhelper_msgs = - ("0:OK/-1001:Remote exception during initialization/" - + "-1002:Bad response received/" - + "-1003:Purchase signature verification failed/" - + "-1004:Send intent failed/" - + "-1005:User cancelled/" - + "-1006:Unknown purchase response/" - + "-1007:Missing token/" - + "-1008:Unknown error/" - + "-1009:Subscriptions not available/" - + "-1010:Invalid consumption attempt") - .split("/"); - - if (code <= IABHELPER_ERROR_BASE) { - int index = IABHELPER_ERROR_BASE - code; - if (index >= 0 && index < iabhelper_msgs.length) { - return iabhelper_msgs[index]; - } else { - return String.valueOf(code) + ":Unknown IAB Helper Error"; - } - } else if (code < 0 || code >= iab_msgs.length) { - return String.valueOf(code) + ":Unknown"; - } else { - return iab_msgs[code]; - } - } - - /** Enables or disable debug logging through LogCat. */ - public void enableDebugLogging(boolean enable, String tag) { - checkNotDisposed(); - mDebugLog = enable; - mDebugTag = tag; - } - - public void enableDebugLogging(boolean enable) { - checkNotDisposed(); - mDebugLog = enable; - } - - /** - * Starts the setup process. This will start up the setup process asynchronously. You will be - * notified through the listener when the setup process is complete. This method is safe to call - * from a UI thread. - * - * @param listener The listener to notify when the setup process is complete. - */ - public void startSetup(final OnIabSetupFinishedListener listener) { - // If already set up, can't do it again. - checkNotDisposed(); - if (mSetupDone) { - throw new IllegalStateException("IAB helper is already set up."); - } - - // Connection to IAB service - logDebug("Starting in-app billing setup."); - mServiceConn = - new ServiceConnection() { - @Override - public void onServiceDisconnected(ComponentName name) { - logDebug("Billing service disconnected."); - mService = null; - } - - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - if (mDisposed) { - return; - } - logDebug("Billing service connected."); - mService = IInAppBillingService.Stub.asInterface(service); - String packageName = mContext.getPackageName(); - try { - logDebug("Checking for in-app billing 3 support."); - - // check for in-app billing v3 support - int response = mService.isBillingSupported(3, packageName, ITEM_TYPE_INAPP); - if (response != BILLING_RESPONSE_RESULT_OK) { - if (listener != null) { - listener.onIabSetupFinished( - new IabResult(response, "Error checking for billing v3 support.")); - } - - // if in-app purchases aren't supported, neither are subscriptions - mSubscriptionsSupported = false; - mSubscriptionUpdateSupported = false; - return; - } else { - logDebug("In-app billing version 3 supported for " + packageName); - } - - // Check for v5 subscriptions support. This is needed for - // getBuyIntentToReplaceSku which allows for subscription update - response = mService.isBillingSupported(5, packageName, ITEM_TYPE_SUBS); - if (response == BILLING_RESPONSE_RESULT_OK) { - logDebug("Subscription re-signup AVAILABLE."); - mSubscriptionUpdateSupported = true; - } else { - logDebug("Subscription re-signup not available."); - mSubscriptionUpdateSupported = false; - } - - if (mSubscriptionUpdateSupported) { - mSubscriptionsSupported = true; - } else { - // check for v3 subscriptions support - response = mService.isBillingSupported(3, packageName, ITEM_TYPE_SUBS); - if (response == BILLING_RESPONSE_RESULT_OK) { - logDebug("Subscriptions AVAILABLE."); - mSubscriptionsSupported = true; - } else { - logDebug("Subscriptions NOT AVAILABLE. Response: " + response); - mSubscriptionsSupported = false; - mSubscriptionUpdateSupported = false; - } - } - - mSetupDone = true; - } catch (RemoteException e) { - if (listener != null) { - listener.onIabSetupFinished( - new IabResult( - IABHELPER_REMOTE_EXCEPTION, - "RemoteException while setting up in-app billing.")); - } - e.printStackTrace(); - return; - } - - if (listener != null) { - listener.onIabSetupFinished( - new IabResult(BILLING_RESPONSE_RESULT_OK, "Setup successful.")); - } - } - }; - - Intent serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND"); - serviceIntent.setPackage("com.android.vending"); - List intentServices = - mContext.getPackageManager().queryIntentServices(serviceIntent, 0); - if (intentServices != null && !intentServices.isEmpty()) { - // service available to handle that Intent - mContext.bindService(serviceIntent, mServiceConn, Context.BIND_AUTO_CREATE); - } else { - // no service available to handle that Intent - if (listener != null) { - listener.onIabSetupFinished( - new IabResult( - BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE, - "Billing service unavailable on device.")); - } - } - } - - /** - * Dispose of object, releasing resources. It's very important to call this method when you are - * done with this object. It will release any resources used by it such as service connections. - * Naturally, once the object is disposed of, it can't be used again. - */ - public void dispose() { - logDebug("Disposing."); - mSetupDone = false; - if (mServiceConn != null) { - logDebug("Unbinding from service."); - if (mContext != null && mService != null) { - mContext.unbindService(mServiceConn); - } - } - mDisposed = true; - mContext = null; - mServiceConn = null; - mService = null; - mPurchaseListener = null; - } - - private void checkNotDisposed() { - if (mDisposed) { - throw new IllegalStateException("IabHelper was disposed of, so it cannot be used."); - } - } - - /** Returns whether subscriptions are supported. */ - public boolean subscriptionsSupported() { - checkNotDisposed(); - return mSubscriptionsSupported; - } - - public void launchPurchaseFlow( - Activity act, String sku, int requestCode, OnIabPurchaseFinishedListener listener) { - launchPurchaseFlow(act, sku, requestCode, listener, ""); - } - - public void launchPurchaseFlow( - Activity act, - String sku, - int requestCode, - OnIabPurchaseFinishedListener listener, - String extraData) { - launchPurchaseFlow(act, sku, ITEM_TYPE_INAPP, null, requestCode, listener, extraData); - } - - public void launchSubscriptionPurchaseFlow( - Activity act, String sku, int requestCode, OnIabPurchaseFinishedListener listener) { - launchSubscriptionPurchaseFlow(act, sku, requestCode, listener, ""); - } - - public void launchSubscriptionPurchaseFlow( - Activity act, - String sku, - int requestCode, - OnIabPurchaseFinishedListener listener, - String extraData) { - launchPurchaseFlow(act, sku, ITEM_TYPE_SUBS, null, requestCode, listener, extraData); - } - - /** - * Initiate the UI flow for an in-app purchase. Call this method to initiate an in-app purchase, - * which will involve bringing up the Google Play screen. The calling activity will be paused - * while the user interacts with Google Play, and the result will be delivered via the activity's - * {@link android.app.Activity#onActivityResult} method, at which point you must call this - * object's {@link #handleActivityResult} method to continue the purchase flow. This method MUST - * be called from the UI thread of the Activity. - * - * @param act The calling activity. - * @param sku The sku of the item to purchase. - * @param itemType indicates if it's a product or a subscription (ITEM_TYPE_INAPP or - * ITEM_TYPE_SUBS) - * @param oldSkus A list of SKUs which the new SKU is replacing or null if there are none - * @param requestCode A request code (to differentiate from other responses -- as in {@link - * android.app.Activity#startActivityForResult}). - * @param listener The listener to notify when the purchase process finishes - * @param extraData Extra data (developer payload), which will be returned with the purchase data - * when the purchase completes. This extra data will be permanently bound to that purchase and - * will always be returned when the purchase is queried. - */ - public void launchPurchaseFlow( - Activity act, - String sku, - String itemType, - List oldSkus, - int requestCode, - OnIabPurchaseFinishedListener listener, - String extraData) { - checkNotDisposed(); - checkSetupDone("launchPurchaseFlow"); - flagStartAsync("launchPurchaseFlow"); - IabResult result; - - if (itemType.equals(ITEM_TYPE_SUBS) && !mSubscriptionsSupported) { - IabResult r = - new IabResult(IABHELPER_SUBSCRIPTIONS_NOT_AVAILABLE, "Subscriptions are not available."); - flagEndAsync(); - if (listener != null) { - listener.onIabPurchaseFinished(r, null); - } - return; - } - - try { - logDebug("Constructing buy intent for " + sku + ", item type: " + itemType); - Bundle buyIntentBundle; - if (oldSkus == null || oldSkus.isEmpty()) { - // Purchasing a new item or subscription re-signup - buyIntentBundle = - mService.getBuyIntent(3, mContext.getPackageName(), sku, itemType, extraData); - } else { - // Subscription upgrade/downgrade - if (!mSubscriptionUpdateSupported) { - IabResult r = - new IabResult( - IABHELPER_SUBSCRIPTION_UPDATE_NOT_AVAILABLE, - "Subscription updates are not available."); - flagEndAsync(); - if (listener != null) { - listener.onIabPurchaseFinished(r, null); - } - return; - } - buyIntentBundle = - mService.getBuyIntentToReplaceSkus( - 5, mContext.getPackageName(), oldSkus, sku, itemType, extraData); - } - int response = getResponseCodeFromBundle(buyIntentBundle); - if (response != BILLING_RESPONSE_RESULT_OK) { - logError("Unable to buy item, Error response: " + getResponseDesc(response)); - flagEndAsync(); - result = new IabResult(response, "Unable to buy item"); - if (listener != null) { - listener.onIabPurchaseFinished(result, null); - } - return; - } - - PendingIntent pendingIntent = buyIntentBundle.getParcelable(RESPONSE_BUY_INTENT); - logDebug("Launching buy intent for " + sku + ". Request code: " + requestCode); - mRequestCode = requestCode; - mPurchaseListener = listener; - mPurchasingItemType = itemType; - act.startIntentSenderForResult( - pendingIntent.getIntentSender(), - requestCode, - new Intent(), - Integer.valueOf(0), - Integer.valueOf(0), - Integer.valueOf(0)); - } catch (SendIntentException e) { - logError("SendIntentException while launching purchase flow for sku " + sku); - e.printStackTrace(); - flagEndAsync(); - - result = new IabResult(IABHELPER_SEND_INTENT_FAILED, "Failed to send intent."); - if (listener != null) { - listener.onIabPurchaseFinished(result, null); - } - } catch (RemoteException e) { - logError("RemoteException while launching purchase flow for sku " + sku); - e.printStackTrace(); - flagEndAsync(); - - result = - new IabResult( - IABHELPER_REMOTE_EXCEPTION, "Remote exception while starting purchase flow"); - if (listener != null) { - listener.onIabPurchaseFinished(result, null); - } - } - } - - /** - * Handles an activity result that's part of the purchase flow in in-app billing. If you are - * calling {@link #launchPurchaseFlow}, then you must call this method from your Activity's {@link - * android.app.Activity@onActivityResult} method. This method MUST be called from the UI thread of - * the Activity. - * - * @param requestCode The requestCode as you received it. - * @param resultCode The resultCode as you received it. - * @param data The data (Intent) as you received it. - * @return Returns true if the result was related to a purchase flow and was handled; false if the - * result was not related to a purchase, in which case you should handle it normally. - */ - public boolean handleActivityResult(int requestCode, int resultCode, Intent data) { - IabResult result; - if (requestCode != mRequestCode) { - return false; - } - - checkNotDisposed(); - checkSetupDone("handleActivityResult"); - - // end of async purchase operation that started on launchPurchaseFlow - flagEndAsync(); - - if (data == null) { - logError("Null data in IAB activity result."); - result = new IabResult(IABHELPER_BAD_RESPONSE, "Null data in IAB result"); - if (mPurchaseListener != null) { - mPurchaseListener.onIabPurchaseFinished(result, null); - } - return true; - } - - int responseCode = getResponseCodeFromIntent(data); - String purchaseData = data.getStringExtra(RESPONSE_INAPP_PURCHASE_DATA); - String dataSignature = data.getStringExtra(RESPONSE_INAPP_SIGNATURE); - - if (resultCode == Activity.RESULT_OK && responseCode == BILLING_RESPONSE_RESULT_OK) { - logDebug("Successful resultcode from purchase activity."); - logDebug("Purchase data: " + purchaseData); - logDebug("Data signature: " + dataSignature); - logDebug("Extras: " + data.getExtras()); - logDebug("Expected item type: " + mPurchasingItemType); - - if (purchaseData == null || dataSignature == null) { - logError("BUG: either purchaseData or dataSignature is null."); - logDebug("Extras: " + data.getExtras().toString()); - result = - new IabResult( - IABHELPER_UNKNOWN_ERROR, "IAB returned null purchaseData or dataSignature"); - if (mPurchaseListener != null) { - mPurchaseListener.onIabPurchaseFinished(result, null); - } - return true; - } - - Purchase purchase = null; - try { - purchase = new Purchase(mPurchasingItemType, purchaseData, dataSignature); - String sku = purchase.getSku(); - - // Verify signature - if (!Security.verifyPurchase(mSignatureBase64, purchaseData, dataSignature)) { - logError("Purchase signature verification FAILED for sku " + sku); - result = - new IabResult( - IABHELPER_VERIFICATION_FAILED, "Signature verification failed for sku " + sku); - if (mPurchaseListener != null) { - mPurchaseListener.onIabPurchaseFinished(result, purchase); - } - return true; - } - logDebug("Purchase signature successfully verified."); - } catch (JSONException e) { - logError("Failed to parse purchase data."); - e.printStackTrace(); - result = new IabResult(IABHELPER_BAD_RESPONSE, "Failed to parse purchase data."); - if (mPurchaseListener != null) { - mPurchaseListener.onIabPurchaseFinished(result, null); - } - return true; - } - - if (mPurchaseListener != null) { - mPurchaseListener.onIabPurchaseFinished( - new IabResult(BILLING_RESPONSE_RESULT_OK, "Success"), purchase); - } - } else if (resultCode == Activity.RESULT_OK) { - // result code was OK, but in-app billing response was not OK. - logDebug( - "Result code was OK but in-app billing response was not OK: " - + getResponseDesc(responseCode)); - if (mPurchaseListener != null) { - result = new IabResult(responseCode, "Problem purchashing item."); - mPurchaseListener.onIabPurchaseFinished(result, null); - } - } else if (resultCode == Activity.RESULT_CANCELED) { - logDebug("Purchase canceled - Response: " + getResponseDesc(responseCode)); - result = new IabResult(IABHELPER_USER_CANCELLED, "User canceled."); - if (mPurchaseListener != null) { - mPurchaseListener.onIabPurchaseFinished(result, null); - } - } else { - logError( - "Purchase failed. Result code: " - + Integer.toString(resultCode) - + ". Response: " - + getResponseDesc(responseCode)); - result = new IabResult(IABHELPER_UNKNOWN_PURCHASE_RESPONSE, "Unknown purchase response."); - if (mPurchaseListener != null) { - mPurchaseListener.onIabPurchaseFinished(result, null); - } - } - return true; - } - - public Inventory queryInventory(boolean querySkuDetails, List moreSkus) - throws IabException { - return queryInventory(querySkuDetails, moreSkus, null); - } - - /** - * Queries the inventory. This will query all owned items from the server, as well as information - * on additional skus, if specified. This method may block or take long to execute. Do not call - * from a UI thread. For that, use the non-blocking version {@link #queryInventoryAsync}. - * - * @param querySkuDetails if true, SKU details (price, description, etc) will be queried as well - * as purchase information. - * @param moreItemSkus additional PRODUCT skus to query information on, regardless of ownership. - * Ignored if null or if querySkuDetails is false. - * @param moreSubsSkus additional SUBSCRIPTIONS skus to query information on, regardless of - * ownership. Ignored if null or if querySkuDetails is false. - * @throws IabException if a problem occurs while refreshing the inventory. - */ - public Inventory queryInventory( - boolean querySkuDetails, List moreItemSkus, List moreSubsSkus) - throws IabException { - checkNotDisposed(); - checkSetupDone("queryInventory"); - try { - Inventory inv = new Inventory(); - int r = queryPurchases(inv, ITEM_TYPE_INAPP); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new IabException(r, "Error refreshing inventory (querying owned items)."); - } - - if (querySkuDetails) { - r = querySkuDetails(ITEM_TYPE_INAPP, inv, moreItemSkus); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new IabException(r, "Error refreshing inventory (querying prices of items)."); - } - } - - // if subscriptions are supported, then also query for subscriptions - if (mSubscriptionsSupported) { - r = queryPurchases(inv, ITEM_TYPE_SUBS); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new IabException(r, "Error refreshing inventory (querying owned subscriptions)."); - } - - if (querySkuDetails) { - r = querySkuDetails(ITEM_TYPE_SUBS, inv, moreSubsSkus); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new IabException( - r, "Error refreshing inventory (querying prices of subscriptions)."); - } - } - } - - return inv; - } catch (RemoteException e) { - throw new IabException( - IABHELPER_REMOTE_EXCEPTION, "Remote exception while refreshing inventory.", e); - } catch (JSONException e) { - throw new IabException( - IABHELPER_BAD_RESPONSE, "Error parsing JSON response while refreshing inventory.", e); - } - } - - /** - * Asynchronous wrapper for inventory query. This will perform an inventory query as described in - * {@link #queryInventory}, but will do so asynchronously and call back the specified listener - * upon completion. This method is safe to call from a UI thread. - * - * @param querySkuDetails as in {@link #queryInventory} - * @param moreSkus as in {@link #queryInventory} - * @param listener The listener to notify when the refresh operation completes. - */ - public void queryInventoryAsync( - final boolean querySkuDetails, - final List moreSkus, - final QueryInventoryFinishedListener listener) { - final Handler handler = new Handler(); - checkNotDisposed(); - checkSetupDone("queryInventory"); - flagStartAsync("refresh inventory"); - executor.execute( - () -> { - IabResult result = - new IabResult(BILLING_RESPONSE_RESULT_OK, "Inventory refresh successful."); - Inventory inv = null; - try { - inv = queryInventory(querySkuDetails, moreSkus); - } catch (IabException ex) { - result = ex.getResult(); - } - - flagEndAsync(); - - final IabResult result_f = result; - final Inventory inv_f = inv; - if (!mDisposed && listener != null) { - handler.post(() -> listener.onQueryInventoryFinished(result_f, inv_f)); - } - }); - } - - public void queryInventoryAsync(QueryInventoryFinishedListener listener) { - queryInventoryAsync(true, null, listener); - } - - public void queryInventoryAsync( - boolean querySkuDetails, QueryInventoryFinishedListener listener) { - queryInventoryAsync(querySkuDetails, null, listener); - } - - /** - * Consumes a given in-app product. Consuming can only be done on an item that's owned, and as a - * result of consumption, the user will no longer own it. This method may block or take long to - * return. Do not call from the UI thread. For that, see {@link #consumeAsync}. - * - * @param itemInfo The PurchaseInfo that represents the item to consume. - * @throws IabException if there is a problem during consumption. - */ - void consume(Purchase itemInfo) throws IabException { - checkNotDisposed(); - checkSetupDone("consume"); - - if (!itemInfo.mItemType.equals(ITEM_TYPE_INAPP)) { - throw new IabException( - IABHELPER_INVALID_CONSUMPTION, - "Items of type '" + itemInfo.mItemType + "' can't be consumed."); - } - - try { - String token = itemInfo.getToken(); - String sku = itemInfo.getSku(); - if (token == null || token.equals("")) { - logError("Can't consume " + sku + ". No token."); - throw new IabException( - IABHELPER_MISSING_TOKEN, - "PurchaseInfo is missing token for sku: " + sku + " " + itemInfo); - } - - logDebug("Consuming sku: " + sku + ", token: " + token); - int response = mService.consumePurchase(3, mContext.getPackageName(), token); - if (response == BILLING_RESPONSE_RESULT_OK) { - logDebug("Successfully consumed sku: " + sku); - } else { - logDebug("Error consuming consuming sku " + sku + ". " + getResponseDesc(response)); - throw new IabException(response, "Error consuming sku " + sku); - } - } catch (RemoteException e) { - throw new IabException( - IABHELPER_REMOTE_EXCEPTION, - "Remote exception while consuming. PurchaseInfo: " + itemInfo, - e); - } - } - - /** - * Asynchronous wrapper to item consumption. Works like {@link #consume}, but performs the - * consumption in the background and notifies completion through the provided listener. This - * method is safe to call from a UI thread. - * - * @param purchase The purchase to be consumed. - * @param listener The listener to notify when the consumption operation finishes. - */ - public void consumeAsync(Purchase purchase, OnConsumeFinishedListener listener) { - checkNotDisposed(); - checkSetupDone("consume"); - List purchases = new ArrayList(); - purchases.add(purchase); - consumeAsyncInternal(purchases, listener, null); - } - - /** - * Same as {@link #consumeAsync}, but for multiple items at once. - * - * @param purchases The list of PurchaseInfo objects representing the purchases to consume. - * @param listener The listener to notify when the consumption operation finishes. - */ - public void consumeAsync(List purchases, OnConsumeMultiFinishedListener listener) { - checkNotDisposed(); - checkSetupDone("consume"); - consumeAsyncInternal(purchases, null, listener); - } - - // Checks that setup was done; if not, throws an exception. - void checkSetupDone(String operation) { - if (!mSetupDone) { - logError("Illegal state for operation (" + operation + "): IAB helper is not set up."); - throw new IllegalStateException( - "IAB helper is not set up. Can't perform operation: " + operation); - } - } - - // Workaround to bug where sometimes response codes come as Long instead of Integer - int getResponseCodeFromBundle(Bundle b) { - Object o = b.get(RESPONSE_CODE); - if (o == null) { - logDebug("Bundle with null response code, assuming OK (known issue)"); - return BILLING_RESPONSE_RESULT_OK; - } else if (o instanceof Integer) { - return ((Integer) o).intValue(); - } else if (o instanceof Long) { - return (int) ((Long) o).longValue(); - } else { - logError("Unexpected type for bundle response code."); - logError(o.getClass().getName()); - throw new RuntimeException( - "Unexpected type for bundle response code: " + o.getClass().getName()); - } - } - - // Workaround to bug where sometimes response codes come as Long instead of Integer - int getResponseCodeFromIntent(Intent i) { - Object o = i.getExtras().get(RESPONSE_CODE); - if (o == null) { - logError("Intent with no response code, assuming OK (known issue)"); - return BILLING_RESPONSE_RESULT_OK; - } else if (o instanceof Integer) { - return ((Integer) o).intValue(); - } else if (o instanceof Long) { - return (int) ((Long) o).longValue(); - } else { - logError("Unexpected type for intent response code."); - logError(o.getClass().getName()); - throw new RuntimeException( - "Unexpected type for intent response code: " + o.getClass().getName()); - } - } - - void flagStartAsync(String operation) { - if (mAsyncInProgress) { - throw new IllegalStateException( - "Can't start async operation (" - + operation - + ") because another async operation(" - + mAsyncOperation - + ") is in progress."); - } - mAsyncOperation = operation; - mAsyncInProgress = true; - logDebug("Starting async operation: " + operation); - } - - void flagEndAsync() { - logDebug("Ending async operation: " + mAsyncOperation); - mAsyncOperation = ""; - mAsyncInProgress = false; - } - - int queryPurchases(Inventory inv, String itemType) throws JSONException, RemoteException { - // Query purchases - logDebug("Querying owned items, item type: " + itemType); - logDebug("Package name: " + mContext.getPackageName()); - boolean verificationFailed = false; - String continueToken = null; - - do { - logDebug("Calling getPurchases with continuation token: " + continueToken); - Bundle ownedItems = - mService.getPurchases(3, mContext.getPackageName(), itemType, continueToken); - - int response = getResponseCodeFromBundle(ownedItems); - logDebug("Owned items response: " + String.valueOf(response)); - if (response != BILLING_RESPONSE_RESULT_OK) { - logDebug("getPurchases() failed: " + getResponseDesc(response)); - return response; - } - if (!ownedItems.containsKey(RESPONSE_INAPP_ITEM_LIST) - || !ownedItems.containsKey(RESPONSE_INAPP_PURCHASE_DATA_LIST) - || !ownedItems.containsKey(RESPONSE_INAPP_SIGNATURE_LIST)) { - logError("Bundle returned from getPurchases() doesn't contain required fields."); - return IABHELPER_BAD_RESPONSE; - } - - ArrayList ownedSkus = ownedItems.getStringArrayList(RESPONSE_INAPP_ITEM_LIST); - ArrayList purchaseDataList = - ownedItems.getStringArrayList(RESPONSE_INAPP_PURCHASE_DATA_LIST); - ArrayList signatureList = - ownedItems.getStringArrayList(RESPONSE_INAPP_SIGNATURE_LIST); - - for (int i = 0; i < purchaseDataList.size(); ++i) { - String purchaseData = purchaseDataList.get(i); - String signature = signatureList.get(i); - String sku = ownedSkus.get(i); - if (Security.verifyPurchase(mSignatureBase64, purchaseData, signature)) { - logDebug("Sku is owned: " + sku); - Purchase purchase = new Purchase(itemType, purchaseData, signature); - - if (TextUtils.isEmpty(purchase.getToken())) { - logWarn("BUG: empty/null token!"); - logDebug("Purchase data: " + purchaseData); - } - - // Record ownership and token - inv.addPurchase(purchase); - } else { - logWarn("Purchase signature verification **FAILED**. Not adding item."); - logDebug(" Purchase data: " + purchaseData); - logDebug(" Signature: " + signature); - verificationFailed = true; - } - } - - continueToken = ownedItems.getString(INAPP_CONTINUATION_TOKEN); - logDebug("Continuation token: " + continueToken); - } while (!TextUtils.isEmpty(continueToken)); - - return verificationFailed ? IABHELPER_VERIFICATION_FAILED : BILLING_RESPONSE_RESULT_OK; - } - - int querySkuDetails(String itemType, Inventory inv, List moreSkus) - throws RemoteException, JSONException { - logDebug("Querying SKU details."); - ArrayList skuList = new ArrayList(); - skuList.addAll(inv.getAllOwnedSkus(itemType)); - if (moreSkus != null) { - for (String sku : moreSkus) { - if (!skuList.contains(sku)) { - skuList.add(sku); - } - } - } - - if (skuList.size() == 0) { - logDebug("queryPrices: nothing to do because there are no SKUs."); - return BILLING_RESPONSE_RESULT_OK; - } - - // Split the sku list in blocks of no more than 20 elements. - ArrayList> packs = new ArrayList>(); - ArrayList tempList; - int n = skuList.size() / 20; - int mod = skuList.size() % 20; - for (int i = 0; i < n; i++) { - tempList = new ArrayList(); - for (String s : skuList.subList(i * 20, i * 20 + 20)) { - tempList.add(s); - } - packs.add(tempList); - } - if (mod != 0) { - tempList = new ArrayList(); - for (String s : skuList.subList(n * 20, n * 20 + mod)) { - tempList.add(s); - } - packs.add(tempList); - } - - for (ArrayList skuPartList : packs) { - Bundle querySkus = new Bundle(); - querySkus.putStringArrayList(GET_SKU_DETAILS_ITEM_LIST, skuPartList); - Bundle skuDetails = mService.getSkuDetails(3, mContext.getPackageName(), itemType, querySkus); - - if (!skuDetails.containsKey(RESPONSE_GET_SKU_DETAILS_LIST)) { - int response = getResponseCodeFromBundle(skuDetails); - if (response != BILLING_RESPONSE_RESULT_OK) { - logDebug("getSkuDetails() failed: " + getResponseDesc(response)); - return response; - } else { - logError("getSkuDetails() returned a bundle with neither an error nor a detail list."); - return IABHELPER_BAD_RESPONSE; - } - } - - ArrayList responseList = skuDetails.getStringArrayList(RESPONSE_GET_SKU_DETAILS_LIST); - - for (String thisResponse : responseList) { - SkuDetails d = new SkuDetails(itemType, thisResponse); - logDebug("Got sku details: " + d); - inv.addSkuDetails(d); - } - } - - return BILLING_RESPONSE_RESULT_OK; - } - - void consumeAsyncInternal( - final List purchases, - final OnConsumeFinishedListener singleListener, - final OnConsumeMultiFinishedListener multiListener) { - final Handler handler = new Handler(); - flagStartAsync("consume"); - executor.execute( - () -> { - final List results = new ArrayList(); - for (Purchase purchase : purchases) { - try { - consume(purchase); - results.add( - new IabResult( - BILLING_RESPONSE_RESULT_OK, - "Successful consume of sku " + purchase.getSku())); - } catch (IabException ex) { - results.add(ex.getResult()); - } - } - - flagEndAsync(); - if (!mDisposed && singleListener != null) { - handler.post(() -> singleListener.onConsumeFinished(purchases.get(0), results.get(0))); - } - if (!mDisposed && multiListener != null) { - handler.post(() -> multiListener.onConsumeMultiFinished(purchases, results)); - } - }); - } - - void logDebug(String msg) { - if (mDebugLog) { - Log.d(mDebugTag, msg); - } - } - - void logError(String msg) { - Log.e(mDebugTag, "In-app billing error: " + msg); - } - - void logWarn(String msg) { - Log.w(mDebugTag, "In-app billing warning: " + msg); - } - - /** - * Callback for setup process. This listener's {@link #onIabSetupFinished} method is called when - * the setup process is complete. - */ - public interface OnIabSetupFinishedListener { - - /** - * Called to notify that setup is complete. - * - * @param result The result of the setup process. - */ - void onIabSetupFinished(IabResult result); - } - - /** Callback that notifies when a purchase is finished. */ - public interface OnIabPurchaseFinishedListener { - - /** - * Called to notify that an in-app purchase finished. If the purchase was successful, then the - * sku parameter specifies which item was purchased. If the purchase failed, the sku and - * extraData parameters may or may not be null, depending on how far the purchase process went. - * - * @param result The result of the purchase. - * @param info The purchase information (null if purchase failed) - */ - void onIabPurchaseFinished(IabResult result, Purchase info); - } - - /** Listener that notifies when an inventory query operation completes. */ - public interface QueryInventoryFinishedListener { - - /** - * Called to notify that an inventory query operation completed. - * - * @param result The result of the operation. - * @param inv The inventory. - */ - void onQueryInventoryFinished(IabResult result, Inventory inv); - } - - /** Callback that notifies when a consumption operation finishes. */ - public interface OnConsumeFinishedListener { - - /** - * Called to notify that a consumption has finished. - * - * @param purchase The purchase that was (or was to be) consumed. - * @param result The result of the consumption operation. - */ - void onConsumeFinished(Purchase purchase, IabResult result); - } - - /** Callback that notifies when a multi-item consumption operation finishes. */ - public interface OnConsumeMultiFinishedListener { - - /** - * Called to notify that a consumption of multiple items has finished. - * - * @param purchases The purchases that were (or were to be) consumed. - * @param results The results of each consumption operation, corresponding to each sku. - */ - void onConsumeMultiFinished(List purchases, List results); - } -} diff --git a/app/src/googleplay/java/com/android/vending/billing/IabResult.java b/app/src/googleplay/java/com/android/vending/billing/IabResult.java deleted file mode 100644 index cdbc125e9..000000000 --- a/app/src/googleplay/java/com/android/vending/billing/IabResult.java +++ /dev/null @@ -1,58 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.vending.billing; - -/** - * Represents the result of an in-app billing operation. A result is composed of a response code (an - * integer) and possibly a message (String). You can get those by calling {@link #getResponse} and - * {@link #getMessage()}, respectively. You can also inquire whether a result is a success or a - * failure by calling {@link #isSuccess()} and {@link #isFailure()}. - */ -@SuppressWarnings("ALL") -public class IabResult { - - int mResponse; - String mMessage; - - public IabResult(int response, String message) { - mResponse = response; - if (message == null || message.trim().length() == 0) { - mMessage = IabHelper.getResponseDesc(response); - } else { - mMessage = message + " (response: " + IabHelper.getResponseDesc(response) + ")"; - } - } - - public int getResponse() { - return mResponse; - } - - public String getMessage() { - return mMessage; - } - - public boolean isSuccess() { - return mResponse == IabHelper.BILLING_RESPONSE_RESULT_OK; - } - - public boolean isFailure() { - return !isSuccess(); - } - - public String toString() { - return "IabResult: " + getMessage(); - } -} diff --git a/app/src/googleplay/java/com/android/vending/billing/Inventory.java b/app/src/googleplay/java/com/android/vending/billing/Inventory.java deleted file mode 100644 index 09010ad1f..000000000 --- a/app/src/googleplay/java/com/android/vending/billing/Inventory.java +++ /dev/null @@ -1,96 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.vending.billing; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Represents a block of information about in-app items. An Inventory is returned by such methods as - * {@link IabHelper#queryInventory}. - */ -@SuppressWarnings("ALL") -public class Inventory { - - Map mSkuMap = new HashMap(); - Map mPurchaseMap = new HashMap(); - - Inventory() {} - - /** Returns the listing details for an in-app product. */ - public SkuDetails getSkuDetails(String sku) { - return mSkuMap.get(sku); - } - - /** Returns purchase information for a given product, or null if there is no purchase. */ - public Purchase getPurchase(String sku) { - return mPurchaseMap.get(sku); - } - - /** Returns whether or not there exists a purchase of the given product. */ - public boolean hasPurchase(String sku) { - return mPurchaseMap.containsKey(sku); - } - - /** Return whether or not details about the given product are available. */ - public boolean hasDetails(String sku) { - return mSkuMap.containsKey(sku); - } - - /** - * Erase a purchase (locally) from the inventory, given its product ID. This just modifies the - * Inventory object locally and has no effect on the server! This is useful when you have an - * existing Inventory object which you know to be up to date, and you have just consumed an item - * successfully, which means that erasing its purchase data from the Inventory you already have is - * quicker than querying for a new Inventory. - */ - public void erasePurchase(String sku) { - if (mPurchaseMap.containsKey(sku)) { - mPurchaseMap.remove(sku); - } - } - - /** Returns a list of all owned product IDs. */ - List getAllOwnedSkus() { - return new ArrayList(mPurchaseMap.keySet()); - } - - /** Returns a list of all owned product IDs of a given type */ - List getAllOwnedSkus(String itemType) { - List result = new ArrayList(); - for (Purchase p : mPurchaseMap.values()) { - if (p.getItemType().equals(itemType)) { - result.add(p.getSku()); - } - } - return result; - } - - /** Returns a list of all purchases. */ - List getAllPurchases() { - return new ArrayList(mPurchaseMap.values()); - } - - void addSkuDetails(SkuDetails d) { - mSkuMap.put(d.getSku(), d); - } - - void addPurchase(Purchase p) { - mPurchaseMap.put(p.getSku(), p); - } -} diff --git a/app/src/googleplay/java/com/android/vending/billing/Purchase.java b/app/src/googleplay/java/com/android/vending/billing/Purchase.java deleted file mode 100644 index 7d2e2395b..000000000 --- a/app/src/googleplay/java/com/android/vending/billing/Purchase.java +++ /dev/null @@ -1,100 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.vending.billing; - -import org.json.JSONException; -import org.json.JSONObject; - -/** Represents an in-app billing purchase. */ -@SuppressWarnings("ALL") -public class Purchase { - - String mItemType; // ITEM_TYPE_INAPP or ITEM_TYPE_SUBS - String mOrderId; - String mPackageName; - String mSku; - long mPurchaseTime; - int mPurchaseState; - String mDeveloperPayload; - String mToken; - String mOriginalJson; - String mSignature; - boolean mIsAutoRenewing; - - public Purchase(String itemType, String jsonPurchaseInfo, String signature) throws JSONException { - mItemType = itemType; - mOriginalJson = jsonPurchaseInfo; - JSONObject o = new JSONObject(mOriginalJson); - mOrderId = o.optString("orderId"); - mPackageName = o.optString("packageName"); - mSku = o.optString("productId"); - mPurchaseTime = o.optLong("purchaseTime"); - mPurchaseState = o.optInt("purchaseState"); - mDeveloperPayload = o.optString("developerPayload"); - mToken = o.optString("token", o.optString("purchaseToken")); - mIsAutoRenewing = o.optBoolean("autoRenewing"); - mSignature = signature; - } - - public String getItemType() { - return mItemType; - } - - public String getOrderId() { - return mOrderId; - } - - public String getPackageName() { - return mPackageName; - } - - public String getSku() { - return mSku; - } - - public long getPurchaseTime() { - return mPurchaseTime; - } - - public int getPurchaseState() { - return mPurchaseState; - } - - public String getDeveloperPayload() { - return mDeveloperPayload; - } - - public String getToken() { - return mToken; - } - - public String getOriginalJson() { - return mOriginalJson; - } - - public String getSignature() { - return mSignature; - } - - public boolean isAutoRenewing() { - return mIsAutoRenewing; - } - - @Override - public String toString() { - return "PurchaseInfo(type:" + mItemType + "):" + mOriginalJson; - } -} diff --git a/app/src/googleplay/java/com/android/vending/billing/SkuDetails.java b/app/src/googleplay/java/com/android/vending/billing/SkuDetails.java deleted file mode 100644 index ced974234..000000000 --- a/app/src/googleplay/java/com/android/vending/billing/SkuDetails.java +++ /dev/null @@ -1,84 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.vending.billing; - -import org.json.JSONException; -import org.json.JSONObject; - -/** Represents an in-app product's listing details. */ -@SuppressWarnings("ALL") -public class SkuDetails { - - private final String mItemType; - private final String mSku; - private final String mType; - private final String mPrice; - private final long mPriceAmountMicros; - private final String mPriceCurrencyCode; - private final String mTitle; - private final String mDescription; - private final String mJson; - - public SkuDetails(String jsonSkuDetails) throws JSONException { - this(IabHelper.ITEM_TYPE_INAPP, jsonSkuDetails); - } - - public SkuDetails(String itemType, String jsonSkuDetails) throws JSONException { - mItemType = itemType; - mJson = jsonSkuDetails; - JSONObject o = new JSONObject(mJson); - mSku = o.optString("productId"); - mType = o.optString("type"); - mPrice = o.optString("price"); - mPriceAmountMicros = o.optLong("price_amount_micros"); - mPriceCurrencyCode = o.optString("price_currency_code"); - mTitle = o.optString("title"); - mDescription = o.optString("description"); - } - - public String getSku() { - return mSku; - } - - public String getType() { - return mType; - } - - public String getPrice() { - return mPrice; - } - - public long getPriceAmountMicros() { - return mPriceAmountMicros; - } - - public String getPriceCurrencyCode() { - return mPriceCurrencyCode; - } - - public String getTitle() { - return mTitle; - } - - public String getDescription() { - return mDescription; - } - - @Override - public String toString() { - return "SkuDetails:" + mJson; - } -} diff --git a/app/src/googleplay/java/org/tasks/FlavorSetup.java b/app/src/googleplay/java/org/tasks/FlavorSetup.java index 242af6183..3f23b1eda 100644 --- a/app/src/googleplay/java/org/tasks/FlavorSetup.java +++ b/app/src/googleplay/java/org/tasks/FlavorSetup.java @@ -1,22 +1,23 @@ package org.tasks; import javax.inject.Inject; -import org.tasks.billing.InventoryHelper; +import org.tasks.billing.BillingClient; import org.tasks.gtasks.PlayServices; public class FlavorSetup { - private final InventoryHelper inventoryHelper; private final PlayServices playServices; + private final BillingClient billingClient; @Inject - public FlavorSetup(InventoryHelper inventoryHelper, PlayServices playServices) { - this.inventoryHelper = inventoryHelper; + public FlavorSetup(PlayServices playServices, + BillingClient billingClient) { this.playServices = playServices; + this.billingClient = billingClient; } public void setup() { - inventoryHelper.initialize(); + billingClient.initialize(); playServices.refresh(); } } diff --git a/app/src/googleplay/java/org/tasks/analytics/Tracker.java b/app/src/googleplay/java/org/tasks/analytics/Tracker.java index 957460b22..10c0e120d 100644 --- a/app/src/googleplay/java/org/tasks/analytics/Tracker.java +++ b/app/src/googleplay/java/org/tasks/analytics/Tracker.java @@ -1,7 +1,9 @@ package org.tasks.analytics; +import static org.tasks.billing.BillingClient.BillingResponseToString; + import android.content.Context; -import com.android.vending.billing.IabResult; +import com.android.billingclient.api.BillingClient.BillingResponse; import com.google.android.gms.analytics.ExceptionParser; import com.google.android.gms.analytics.ExceptionReporter; import com.google.android.gms.analytics.GoogleAnalytics; @@ -92,12 +94,14 @@ public class Tracker { tracker.send(eventBuilder.build()); } - public void reportIabResult(IabResult result, String sku) { + public void reportIabResult(@BillingResponse int response, String sku) { tracker.send( new HitBuilders.EventBuilder() .setCategory(context.getString(R.string.tracking_category_iab)) .setAction(sku) - .setLabel(result.getMessage()) + .setLabel(BillingResponseToString(response)) .build()); } + + } diff --git a/app/src/googleplay/java/org/tasks/billing/InventoryHelper.java b/app/src/googleplay/java/org/tasks/billing/InventoryHelper.java deleted file mode 100644 index 6f2c7aa72..000000000 --- a/app/src/googleplay/java/org/tasks/billing/InventoryHelper.java +++ /dev/null @@ -1,100 +0,0 @@ -package org.tasks.billing; - -import android.content.Context; -import android.content.IntentFilter; -import com.android.vending.billing.IabBroadcastReceiver; -import com.android.vending.billing.IabHelper; -import com.android.vending.billing.Inventory; -import com.android.vending.billing.Purchase; -import java.util.concurrent.Executor; -import javax.inject.Inject; -import javax.inject.Named; -import org.tasks.LocalBroadcastManager; -import org.tasks.R; -import org.tasks.injection.ApplicationScope; -import org.tasks.injection.ForApplication; -import org.tasks.preferences.Preferences; -import timber.log.Timber; - -@ApplicationScope -public class InventoryHelper implements IabBroadcastReceiver.IabBroadcastListener { - - private final Context context; - private final Preferences preferences; - private final LocalBroadcastManager localBroadcastManager; - private final Executor executor; - - private Inventory inventory; - - @Inject - public InventoryHelper( - @ForApplication Context context, - Preferences preferences, - LocalBroadcastManager localBroadcastManager, - @Named("iab-executor") Executor executor) { - this.context = context; - this.preferences = preferences; - this.localBroadcastManager = localBroadcastManager; - this.executor = executor; - } - - public void initialize() { - context.registerReceiver( - new IabBroadcastReceiver(this), new IntentFilter(IabBroadcastReceiver.ACTION)); - refreshInventory(); - } - - public void refreshInventory() { - final IabHelper helper = new IabHelper(context, context.getString(R.string.gp_key), executor); - helper.startSetup(getSetupListener(helper)); - } - - private IabHelper.OnIabSetupFinishedListener getSetupListener(final IabHelper helper) { - return result -> { - if (result.isSuccess()) { - helper.queryInventoryAsync(getQueryListener(helper)); - } else { - Timber.e("setup failed: %s", result.getMessage()); - helper.dispose(); - } - }; - } - - private IabHelper.QueryInventoryFinishedListener getQueryListener(final IabHelper helper) { - return (result, inv) -> { - if (result.isSuccess()) { - inventory = inv; - checkPurchase(R.string.sku_tasker, R.string.p_purchased_tasker); - checkPurchase(R.string.sku_dashclock, R.string.p_purchased_dashclock); - checkPurchase(R.string.sku_themes, R.string.p_purchased_themes); - localBroadcastManager.broadcastRefresh(); - } else { - Timber.e("query inventory failed: %s", result.getMessage()); - } - helper.dispose(); - }; - } - - @Override - public void receivedBroadcast() { - refreshInventory(); - } - - private void checkPurchase(int skuRes, final int prefRes) { - final String sku = context.getString(skuRes); - if (inventory.hasPurchase(sku)) { - Timber.d("Found purchase: %s", sku); - preferences.setBoolean(prefRes, true); - } else { - Timber.d("No purchase: %s", sku); - } - } - - public void erasePurchase(String sku) { - inventory.erasePurchase(sku); - } - - public Purchase getPurchase(String sku) { - return inventory.getPurchase(sku); - } -} diff --git a/app/src/googleplay/java/org/tasks/billing/PurchaseHelper.java b/app/src/googleplay/java/org/tasks/billing/PurchaseHelper.java deleted file mode 100644 index 083775373..000000000 --- a/app/src/googleplay/java/org/tasks/billing/PurchaseHelper.java +++ /dev/null @@ -1,197 +0,0 @@ -package org.tasks.billing; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.widget.Toast; -import com.android.vending.billing.IabHelper; -import com.android.vending.billing.IabResult; -import com.android.vending.billing.Purchase; -import com.google.common.base.Strings; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.Executor; -import javax.inject.Inject; -import javax.inject.Named; -import org.tasks.BuildConfig; -import org.tasks.LocalBroadcastManager; -import org.tasks.R; -import org.tasks.analytics.Tracker; -import org.tasks.injection.ApplicationScope; -import org.tasks.injection.ForApplication; -import org.tasks.preferences.Preferences; -import timber.log.Timber; - -@ApplicationScope -public class PurchaseHelper implements IabHelper.OnIabSetupFinishedListener { - - private final Context context; - private final Preferences preferences; - private final Tracker tracker; - private final InventoryHelper inventory; - private final Executor executor; - private final LocalBroadcastManager localBroadcastManager; - - private PurchaseHelperCallback activityResultCallback; - private IabHelper iabHelper; - - @Inject - public PurchaseHelper( - @ForApplication Context context, - Preferences preferences, - Tracker tracker, - InventoryHelper inventory, - @Named("iab-executor") Executor executor, - LocalBroadcastManager localBroadcastManager) { - this.context = context; - this.preferences = preferences; - this.tracker = tracker; - this.inventory = inventory; - this.executor = executor; - this.localBroadcastManager = localBroadcastManager; - } - - @Override - public void onIabSetupFinished(IabResult result) { - if (result.isFailure()) { - Timber.e("in-app billing setup failed: %s", result.getMessage()); - } - } - - public boolean purchase( - final Activity activity, - final String sku, - final String pref, - final int requestCode, - final PurchaseHelperCallback callback) { - launchPurchaseFlow(activity, sku, pref, requestCode, callback); - return true; - } - - public void consumePurchases() { - if (BuildConfig.DEBUG) { - final List purchases = new ArrayList<>(); - final Purchase tasker = inventory.getPurchase(context.getString(R.string.sku_tasker)); - final Purchase dashclock = inventory.getPurchase(context.getString(R.string.sku_dashclock)); - final Purchase themes = inventory.getPurchase(context.getString(R.string.sku_themes)); - if (tasker != null) { - purchases.add(tasker); - } - if (dashclock != null) { - purchases.add(dashclock); - } - if (themes != null) { - purchases.add(themes); - } - final IabHelper iabHelper = - new IabHelper(context, context.getString(R.string.gp_key), executor); - iabHelper.enableDebugLogging(true); - iabHelper.startSetup( - result -> { - if (result.isSuccess()) { - iabHelper.consumeAsync( - purchases, - (purchases1, results) -> { - for (int i = 0; i < purchases1.size(); i++) { - Purchase purchase = purchases1.get(i); - IabResult iabResult = results.get(i); - if (iabResult.isSuccess()) { - if (purchase.equals(tasker)) { - preferences.setBoolean(R.string.p_purchased_tasker, false); - } else if (purchase.equals(dashclock)) { - preferences.setBoolean(R.string.p_purchased_dashclock, false); - } else if (purchase.equals(themes)) { - preferences.setBoolean(R.string.p_purchased_themes, false); - } else { - Timber.e("Unhandled consumption for purchase: %s", purchase); - } - inventory.erasePurchase(purchase.getSku()); - Timber.d("Consumed %s", purchase); - } else { - Timber.e("Consume failed: %s, %s", purchase, iabResult); - } - } - iabHelper.dispose(); - }); - } else { - Timber.e("setup failed: %s", result.getMessage()); - iabHelper.dispose(); - } - }); - } - } - - private void launchPurchaseFlow( - final Activity activity, - final String sku, - final String pref, - final int requestCode, - final PurchaseHelperCallback callback) { - if (iabHelper != null) { - Toast.makeText(activity, R.string.billing_service_busy, Toast.LENGTH_LONG).show(); - callback.purchaseCompleted(false, sku); - return; - } - iabHelper = new IabHelper(context, context.getString(R.string.gp_key), executor); - iabHelper.enableDebugLogging(BuildConfig.DEBUG); - Timber.d("%s: startSetup", iabHelper); - iabHelper.startSetup( - result -> { - if (result.isSuccess()) { - try { - Timber.d("%s: launchPurchaseFlow for %s", iabHelper, sku); - iabHelper.launchPurchaseFlow( - activity, - sku, - requestCode, - (result1, info) -> { - Timber.d(result1.toString()); - tracker.reportIabResult(result1, sku); - if (result1.isSuccess()) { - if (!Strings.isNullOrEmpty(pref)) { - preferences.setBoolean(pref, true); - localBroadcastManager.broadcastRefresh(); - } - inventory.refreshInventory(); - } else if (result1.getResponse() - != IabHelper.BILLING_RESPONSE_RESULT_USER_CANCELED - && result1.getResponse() != IabHelper.IABHELPER_USER_CANCELLED) { - Toast.makeText(activity, result1.getMessage(), Toast.LENGTH_LONG).show(); - } - if (activityResultCallback != null) { - activityResultCallback.purchaseCompleted(result1.isSuccess(), sku); - } - disposeIabHelper(); - }); - } catch (IllegalStateException e) { - tracker.reportException(e); - Toast.makeText(activity, R.string.billing_service_busy, Toast.LENGTH_LONG).show(); - callback.purchaseCompleted(false, sku); - disposeIabHelper(); - } - } else { - Timber.e(result.toString()); - Toast.makeText(activity, result.getMessage(), Toast.LENGTH_LONG).show(); - callback.purchaseCompleted(false, sku); - disposeIabHelper(); - } - }); - } - - public void disposeIabHelper() { - if (iabHelper != null) { - Timber.d("%s: dispose", iabHelper); - iabHelper.dispose(); - iabHelper = null; - } - } - - public void handleActivityResult( - PurchaseHelperCallback callback, int requestCode, int resultCode, Intent data) { - this.activityResultCallback = callback; - - if (iabHelper != null) { - iabHelper.handleActivityResult(requestCode, resultCode, data); - } - } -} diff --git a/app/src/googleplay/res/values/keys.xml b/app/src/googleplay/res/values/keys.xml index 8f2a01edb..133f35ea4 100644 --- a/app/src/googleplay/res/values/keys.xml +++ b/app/src/googleplay/res/values/keys.xml @@ -1,6 +1,5 @@ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk8mXRE3dDXwtinUILCEzKjov2rxs3kZbLRzNrcjFWXpG9OEsUzRGLzqEN+WwibVuMRpZLj/+IxbU2sJWq/M0q+90rOhmXn46ZPeNyr77IqX2pWKIAWpzBoWq/mshRwtm9m1FIiGdBNlXrhSE7u3TGB5FuEuuSqKWvWzxeqQ7fHmlM04Lqrh1mN3FaMne8rWv+DWVHDbLrtnXBuC36glOAj17HxrzaE2v6Pv7Df3QefJ3rM1+0fAp/5jNInaP0qHAlG8WTbUmDShQ5kG3urbv3HLByyx6TSqhmNudXUK/6TusvIj50OptAG7x+UFYf956dD3diXhY3yoICvyFWx1sNwIDAQAB - themes play_services_available \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b03547b0d..8ff7189f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -480,6 +480,10 @@ android:uiOptions="splitActionBarWhenNarrow" android:windowSoftInputMode="adjustResize"/> + +

Note: This method does not automatically retry for RESULT_SERVICE_DISCONNECTED. It is only + * used in unit tests and after queryPurchases execution, which already has a retry-mechanism + * implemented. + */ + private boolean areSubscriptionsSupported() { + int responseCode = billingClient.isFeatureSupported(FeatureType.SUBSCRIPTIONS); + if (responseCode != BillingResponse.OK) { + Timber.d("areSubscriptionsSupported() got an error response: %s", responseCode); + } + return responseCode == BillingResponse.OK; + } + + public void querySkuDetailsAsync( + @SkuType final String itemType, + final List skuList, + final SkuDetailsResponseListener listener) { + Runnable request = + () -> { + Builder params = SkuDetailsParams.newBuilder(); + params.setSkusList(skuList).setType(itemType); + billingClient.querySkuDetailsAsync(params.build(), listener); + }; + executeServiceRequest(request); + } + + public void consume(String sku) { + if (!BuildConfig.DEBUG) { + throw new IllegalStateException(); + } + if (!inventory.purchased(sku)) { + throw new IllegalArgumentException(); + } + final ConsumeResponseListener onConsumeListener = + (responseCode, purchaseToken1) -> { + Timber.d("onConsumeResponse(%s, %s)", responseCode, purchaseToken1); + queryPurchases(); + }; + + Runnable request = + () -> + billingClient.consumeAsync( + inventory.getPurchase(sku).getPurchaseToken(), onConsumeListener); + executeServiceRequest(request); + } + + public int getBillingClientResponseCode() { + return billingClientResponseCode; + } + + public static String BillingResponseToString(@BillingResponse int response) { + switch (response) { + case BillingResponse.FEATURE_NOT_SUPPORTED: + return "FEATURE_NOT_SUPPORTED"; + case BillingResponse.SERVICE_DISCONNECTED: + return "SERVICE_DISCONNECTED"; + case BillingResponse.OK: + return "OK"; + case BillingResponse.USER_CANCELED: + return "USER_CANCELED"; + case BillingResponse.SERVICE_UNAVAILABLE: + return "SERVICE_UNAVAILABLE"; + case BillingResponse.BILLING_UNAVAILABLE: + return "BILLING_UNAVAILABLE"; + case BillingResponse.ITEM_UNAVAILABLE: + return "ITEM_UNAVAILABLE"; + case BillingResponse.DEVELOPER_ERROR: + return "DEVELOPER_ERROR"; + case BillingResponse.ERROR: + return "ERROR"; + case BillingResponse.ITEM_ALREADY_OWNED: + return "ITEM_ALREADY_OWNED"; + case BillingResponse.ITEM_NOT_OWNED: + return "ITEM_NOT_OWNED"; + default: + return "Unknown"; + } + } +} diff --git a/app/src/main/java/org/tasks/billing/CardsWithHeadersDecoration.java b/app/src/main/java/org/tasks/billing/CardsWithHeadersDecoration.java new file mode 100644 index 000000000..a9352460a --- /dev/null +++ b/app/src/main/java/org/tasks/billing/CardsWithHeadersDecoration.java @@ -0,0 +1,56 @@ +// Copyright 2017 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package org.tasks.billing; + +import android.graphics.Rect; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import org.tasks.billing.row.RowDataProvider; +import org.tasks.billing.row.SkuRowData; + +/** + * A separator for RecyclerView that keeps the specified spaces between headers and the cards. + */ +public class CardsWithHeadersDecoration extends RecyclerView.ItemDecoration { + + private final RowDataProvider mRowDataProvider; + private final int mHeaderGap, mRowGap; + + public CardsWithHeadersDecoration(RowDataProvider rowDataProvider, int headerGap, + int rowGap) { + this.mRowDataProvider = rowDataProvider; + this.mHeaderGap = headerGap; + this.mRowGap = rowGap; + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, + RecyclerView.State state) { + + final int position = parent.getChildAdapterPosition(view); + final SkuRowData data = mRowDataProvider.getData(position); + + // We should add a space on top of every header card + if (data.getRowType() == SkusAdapter.TYPE_HEADER || position == 0) { + outRect.top = mHeaderGap; + } + + // Adding a space under the last item + if (position == parent.getAdapter().getItemCount() - 1) { + outRect.bottom = mHeaderGap; + } else { + outRect.bottom = mRowGap; + } + } +} diff --git a/app/src/main/java/org/tasks/billing/Inventory.java b/app/src/main/java/org/tasks/billing/Inventory.java new file mode 100644 index 000000000..89d06edb0 --- /dev/null +++ b/app/src/main/java/org/tasks/billing/Inventory.java @@ -0,0 +1,98 @@ +package org.tasks.billing; + +import android.content.Context; +import com.android.billingclient.api.Purchase; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; +import org.tasks.R; +import org.tasks.injection.ApplicationScope; +import org.tasks.injection.ForApplication; +import org.tasks.preferences.Preferences; +import timber.log.Timber; + +@ApplicationScope +public class Inventory { + + private static final String SKU_PRO = "annual_499"; + static final String SKU_VIP = "vip"; + static final String SKU_TASKER = "tasker"; + static final String SKU_THEMES = "themes"; + static final String SKU_DASHCLOCK = "dashclock"; + + public static final List SKU_SUBS = ImmutableList.of(SKU_PRO); + + private final Preferences preferences; + private final String billingKey; + + private Map purchases = new HashMap<>(); + + @Inject + public Inventory(@ForApplication Context context, Preferences preferences) { + this.preferences = preferences; + billingKey = context.getString(R.string.gp_key); + for (Purchase purchase : preferences.getPurchases()) { + add(purchase); + } + } + + public void clear() { + Timber.d("clear()"); + purchases.clear(); + } + + public void add(List purchases) { + for (Purchase purchase : purchases) { + add(purchase); + } + preferences.setPurchases(this.purchases.values()); + } + + private void add(Purchase purchase) { + if (verifySignature(purchase)) { + Timber.d("add(%s)", purchase); + purchases.put(purchase.getSku(), purchase); + } + } + + public boolean purchasedTasker() { + return hasPro() || purchases.containsKey(SKU_TASKER); + } + + public boolean purchasedDashclock() { + return hasPro() || purchases.containsKey(SKU_DASHCLOCK); + } + + public boolean purchasedThemes() { + return hasPro() || purchases.containsKey(SKU_THEMES); + } + + public List getPurchases() { + return ImmutableList.copyOf(purchases.values()); + } + + public boolean hasPro() { + return purchases.containsKey(SKU_PRO) || purchases.containsKey(SKU_VIP); + } + + public boolean purchased(String sku) { + return purchases.containsKey(sku); + } + + private boolean verifySignature(Purchase purchase) { + try { + return Security.verifyPurchase( + billingKey, purchase.getOriginalJson(), purchase.getSignature()); + } catch (IOException e) { + Timber.e(e, e.getMessage()); + return false; + } + } + + public Purchase getPurchase(String sku) { + return purchases.get(sku); + } +} diff --git a/app/src/main/java/org/tasks/billing/PurchaseActivity.java b/app/src/main/java/org/tasks/billing/PurchaseActivity.java new file mode 100644 index 000000000..c231965f0 --- /dev/null +++ b/app/src/main/java/org/tasks/billing/PurchaseActivity.java @@ -0,0 +1,245 @@ +package org.tasks.billing; + +import static android.text.TextUtils.isEmpty; +import static com.google.common.collect.Iterables.any; +import static com.google.common.collect.Iterables.filter; +import static com.google.common.collect.Lists.newArrayList; +import static com.google.common.collect.Lists.transform; +import static org.tasks.billing.Inventory.SKU_DASHCLOCK; +import static org.tasks.billing.Inventory.SKU_TASKER; +import static org.tasks.billing.Inventory.SKU_THEMES; +import static org.tasks.billing.Inventory.SKU_VIP; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.os.Bundle; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.support.v7.widget.Toolbar.OnMenuItemClickListener; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; +import butterknife.BindView; +import butterknife.ButterKnife; +import com.android.billingclient.api.BillingClient.BillingResponse; +import com.android.billingclient.api.BillingClient.SkuType; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.SkuDetails; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import org.tasks.BuildConfig; +import org.tasks.LocalBroadcastManager; +import org.tasks.R; +import org.tasks.billing.SkusAdapter.OnClickHandler; +import org.tasks.billing.row.SkuRowData; +import org.tasks.injection.ActivityComponent; +import org.tasks.injection.ForApplication; +import org.tasks.injection.ThemedInjectingAppCompatActivity; +import org.tasks.preferences.HelpAndFeedbackActivity; +import org.tasks.ui.MenuColorizer; +import timber.log.Timber; + +public class PurchaseActivity extends ThemedInjectingAppCompatActivity + implements OnClickHandler, OnMenuItemClickListener { + + private static final List DEBUG_SKUS = + ImmutableList.of(SKU_THEMES, SKU_TASKER, SKU_DASHCLOCK, SKU_VIP); + + @Inject @ForApplication Context context; + @Inject BillingClient billingClient; + @Inject Inventory inventory; + @Inject LocalBroadcastManager localBroadcastManager; + + private SkusAdapter adapter; + + @BindView(R.id.toolbar) + Toolbar toolbar; + + @BindView(R.id.list) + RecyclerView recyclerView; + + @BindView(R.id.screen_wait) + View loadingView; + + @BindView(R.id.error_textview) + TextView errorTextView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_purchase); + + ButterKnife.bind(this); + + toolbar.setTitle(R.string.upgrade); + toolbar.setNavigationIcon(R.drawable.ic_arrow_back_24dp); + toolbar.setNavigationOnClickListener(v -> onBackPressed()); + toolbar.inflateMenu(R.menu.menu_purchase_activity); + toolbar.setOnMenuItemClickListener(this); + MenuColorizer.colorToolbar(this, toolbar); + + adapter = new SkusAdapter(context, inventory, this); + recyclerView.setAdapter(adapter); + Resources res = getResources(); + recyclerView.addItemDecoration( + new CardsWithHeadersDecoration( + adapter, + (int) res.getDimension(R.dimen.header_gap), + (int) res.getDimension(R.dimen.row_gap))); + recyclerView.setLayoutManager(new LinearLayoutManager(context)); + setWaitScreen(true); + querySkuDetails(); + } + + @Override + protected void onResume() { + super.onResume(); + + querySkuDetails(); + } + + @Override + protected void onStart() { + super.onStart(); + + localBroadcastManager.registerPurchaseReceiver(purchaseReceiver); + } + + @Override + protected void onStop() { + super.onStop(); + + localBroadcastManager.unregisterReceiver(purchaseReceiver); + } + + /** Queries for in-app and subscriptions SKU details and updates an adapter with new data */ + private void querySkuDetails() { + if (!isFinishing()) { + List data = new ArrayList<>(); + String owned = getString(R.string.owned); + String debug = getString(R.string.debug); + Runnable addDebug = + BuildConfig.DEBUG + ? () -> + addSkuRows( + data, + newArrayList( + filter(DEBUG_SKUS, sku -> !any(data, row -> sku.equals(row.getSku())))), + debug, + SkuType.INAPP, + null) + : null; + Runnable addIaps = + () -> + addSkuRows( + data, + newArrayList( + filter( + transform(inventory.getPurchases(), Purchase::getSku), + sku1 -> !Inventory.SKU_SUBS.contains(sku1))), + owned, + SkuType.INAPP, + addDebug); + addSkuRows(data, Inventory.SKU_SUBS, null, SkuType.SUBS, addIaps); + } + } + + private void addSkuRows( + List data, + List skus, + String title, + @SkuType String skuType, + Runnable whenFinished) { + billingClient.querySkuDetailsAsync( + skuType, + skus, + (responseCode, skuDetailsList) -> { + if (responseCode != BillingResponse.OK) { + Timber.w("Unsuccessful query for type: " + skuType + ". Error code: " + responseCode); + } else if (skuDetailsList != null && skuDetailsList.size() > 0) { + if (!isEmpty(title)) { + data.add(new SkuRowData(title)); + } + Timber.d("Adding %s skus", skuDetailsList.size()); + // Then fill all the other rows + for (SkuDetails details : skuDetailsList) { + Timber.i("Adding sku: %s", details); + data.add(new SkuRowData(details, SkusAdapter.TYPE_NORMAL, skuType)); + } + + if (data.size() == 0) { + displayAnErrorIfNeeded(); + } else { + adapter.setData(data); + setWaitScreen(false); + } + } + + if (whenFinished != null) { + whenFinished.run(); + } + }); + } + + private void displayAnErrorIfNeeded() { + if (!isFinishing()) { + loadingView.setVisibility(View.GONE); + errorTextView.setVisibility(View.VISIBLE); + errorTextView.setText( + billingClient.getBillingClientResponseCode() == BillingResponse.BILLING_UNAVAILABLE + ? R.string.error_billing_unavailable + : R.string.error_billing_default); + } + } + + private void setWaitScreen(boolean set) { + recyclerView.setVisibility(set ? View.GONE : View.VISIBLE); + loadingView.setVisibility(set ? View.VISIBLE : View.GONE); + } + + @Override + public void inject(ActivityComponent component) { + component.inject(this); + } + + @Override + public void click(SkuRowData skuRowData) { + String sku = skuRowData.getSku(); + String skuType = skuRowData.getSkuType(); + if (inventory.purchased(sku)) { + if (BuildConfig.DEBUG && SkuType.INAPP.equals(skuType)) { + billingClient.consume(sku); + } + } else { + billingClient.initiatePurchaseFlow(this, sku, skuType); + } + } + + private BroadcastReceiver purchaseReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + querySkuDetails(); + } + }; + + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_help: + startActivity(new Intent(this, HelpAndFeedbackActivity.class)); + return true; + case R.id.menu_refresh_purchases: + billingClient.queryPurchases(); + return true; + default: + return false; + } + } +} diff --git a/app/src/main/java/org/tasks/billing/PurchaseHelperCallback.java b/app/src/main/java/org/tasks/billing/PurchaseHelperCallback.java deleted file mode 100644 index c7344a8da..000000000 --- a/app/src/main/java/org/tasks/billing/PurchaseHelperCallback.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.tasks.billing; - -public interface PurchaseHelperCallback { - - void purchaseCompleted(boolean success, String sku); -} diff --git a/app/src/googleplay/java/com/android/vending/billing/Security.java b/app/src/main/java/org/tasks/billing/Security.java similarity index 64% rename from app/src/googleplay/java/com/android/vending/billing/Security.java rename to app/src/main/java/org/tasks/billing/Security.java index 03479ed81..c2b4c4cde 100644 --- a/app/src/googleplay/java/com/android/vending/billing/Security.java +++ b/app/src/main/java/org/tasks/billing/Security.java @@ -1,4 +1,5 @@ -/* Copyright (c) 2012 Google Inc. +/* + * Copyright (c) 2012 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,12 +14,12 @@ * limitations under the License. */ -package com.android.vending.billing; +package org.tasks.billing; -import android.annotation.SuppressLint; import android.text.TextUtils; import android.util.Base64; -import android.util.Log; +import com.android.billingclient.util.BillingHelper; +import java.io.IOException; import java.security.InvalidKeyException; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; @@ -30,64 +31,60 @@ import java.security.spec.X509EncodedKeySpec; /** * Security-related methods. For a secure implementation, all of this code should be implemented on - * a server that communicates with the application on the device. For the sake of simplicity and - * clarity of this example, this code is included here and is executed on the device. If you must - * verify the purchases on the phone, you should obfuscate this code to make it harder for an - * attacker to replace the code with stubs that treat all purchases as verified. + * a server that communicates with the application on the device. */ -@SuppressWarnings("ALL") -@SuppressLint("all") public class Security { - private static final String TAG = "IABUtil/Security"; private static final String KEY_FACTORY_ALGORITHM = "RSA"; private static final String SIGNATURE_ALGORITHM = "SHA1withRSA"; /** - * Verifies that the data was signed with the given signature, and returns the verified purchase. - * The data is in JSON format and signed with a private key. The data also contains the {@link - * PurchaseState} and product ID of the purchase. - * + * Verifies that the data was signed with the given signature, and returns the verified + * purchase. * @param base64PublicKey the base64-encoded public key to use for verifying. * @param signedData the signed JSON string (signed, not encrypted) * @param signature the signature for the data, signed with the private key + * @throws IOException if encoding algorithm is not supported or key specification + * is invalid */ - public static boolean verifyPurchase( - String base64PublicKey, String signedData, String signature) { - if (TextUtils.isEmpty(signedData) - || TextUtils.isEmpty(base64PublicKey) + public static boolean verifyPurchase(String base64PublicKey, String signedData, + String signature) throws IOException { + if (TextUtils.isEmpty(signedData) || TextUtils.isEmpty(base64PublicKey) || TextUtils.isEmpty(signature)) { - Log.e(TAG, "Purchase verification failed: missing data."); + BillingHelper.logWarn(TAG, "Purchase verification failed: missing data."); return false; } - PublicKey key = Security.generatePublicKey(base64PublicKey); - return Security.verify(key, signedData, signature); + PublicKey key = generatePublicKey(base64PublicKey); + return verify(key, signedData, signature); } /** * Generates a PublicKey instance from a string containing the Base64-encoded public key. * * @param encodedPublicKey Base64-encoded public key - * @throws IllegalArgumentException if encodedPublicKey is invalid + * @throws IOException if encoding algorithm is not supported or key specification + * is invalid */ - public static PublicKey generatePublicKey(String encodedPublicKey) { + public static PublicKey generatePublicKey(String encodedPublicKey) throws IOException { try { byte[] decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT); KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM); return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey)); } catch (NoSuchAlgorithmException e) { + // "RSA" is guaranteed to be available. throw new RuntimeException(e); } catch (InvalidKeySpecException e) { - Log.e(TAG, "Invalid key specification."); - throw new IllegalArgumentException(e); + String msg = "Invalid key specification: " + e; + BillingHelper.logWarn(TAG, msg); + throw new IOException(msg); } } /** - * Verifies that the signature from the server matches the computed signature on the data. Returns - * true if the data is correctly signed. + * Verifies that the signature from the server matches the computed signature on the data. + * Returns true if the data is correctly signed. * * @param publicKey public key associated with the developer account * @param signedData signed data from server @@ -99,24 +96,25 @@ public class Security { try { signatureBytes = Base64.decode(signature, Base64.DEFAULT); } catch (IllegalArgumentException e) { - Log.e(TAG, "Base64 decoding failed."); + BillingHelper.logWarn(TAG, "Base64 decoding failed."); return false; } try { - Signature sig = Signature.getInstance(SIGNATURE_ALGORITHM); - sig.initVerify(publicKey); - sig.update(signedData.getBytes()); - if (!sig.verify(signatureBytes)) { - Log.e(TAG, "Signature verification failed."); + Signature signatureAlgorithm = Signature.getInstance(SIGNATURE_ALGORITHM); + signatureAlgorithm.initVerify(publicKey); + signatureAlgorithm.update(signedData.getBytes()); + if (!signatureAlgorithm.verify(signatureBytes)) { + BillingHelper.logWarn(TAG, "Signature verification failed."); return false; } return true; } catch (NoSuchAlgorithmException e) { - Log.e(TAG, "NoSuchAlgorithmException."); + // "RSA" is guaranteed to be available. + throw new RuntimeException(e); } catch (InvalidKeyException e) { - Log.e(TAG, "Invalid key specification."); + BillingHelper.logWarn(TAG, "Invalid key specification."); } catch (SignatureException e) { - Log.e(TAG, "Signature exception."); + BillingHelper.logWarn(TAG, "Signature exception."); } return false; } diff --git a/app/src/main/java/org/tasks/billing/SkusAdapter.java b/app/src/main/java/org/tasks/billing/SkusAdapter.java new file mode 100644 index 000000000..9565030cd --- /dev/null +++ b/app/src/main/java/org/tasks/billing/SkusAdapter.java @@ -0,0 +1,131 @@ +/* + * Copyright 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.tasks.billing; + +import static com.google.common.collect.Lists.transform; +import static java.lang.annotation.RetentionPolicy.SOURCE; +import static java.util.Arrays.asList; + +import android.content.Context; +import android.support.annotation.IntDef; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import com.android.billingclient.api.BillingClient.SkuType; +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import java.lang.annotation.Retention; +import java.util.List; +import javax.annotation.Nonnull; +import org.tasks.BuildConfig; +import org.tasks.R; +import org.tasks.billing.row.RowDataProvider; +import org.tasks.billing.row.RowViewHolder; +import org.tasks.billing.row.SkuRowData; + +public class SkusAdapter extends RecyclerView.Adapter implements RowDataProvider { + + public static final int TYPE_HEADER = 0; + public static final int TYPE_NORMAL = 1; + private final Context context; + private final Inventory inventory; + private final OnClickHandler onClickHandler; + private List data = ImmutableList.of(); + + SkusAdapter(Context context, Inventory inventory, OnClickHandler onClickHandler) { + this.context = context; + this.inventory = inventory; + this.onClickHandler = onClickHandler; + } + + public void setData(List data) { + this.data = data; + + notifyDataSetChanged(); + } + + @Override + public @RowTypeDef int getItemViewType(int position) { + return data.isEmpty() ? TYPE_HEADER : data.get(position).getRowType(); + } + + @Override + @Nonnull + public RowViewHolder onCreateViewHolder(@Nonnull ViewGroup parent, @RowTypeDef int viewType) { + // Selecting a flat layout for header rows + if (viewType == SkusAdapter.TYPE_HEADER) { + View item = + LayoutInflater.from(parent.getContext()) + .inflate(R.layout.sku_details_row_header, parent, false); + return new RowViewHolder(item, null); + } else { + View item = + LayoutInflater.from(parent.getContext()).inflate(R.layout.sku_details_row, parent, false); + return new RowViewHolder(item, row -> onClickHandler.click(getData(row))); + } + } + + @Override + public void onBindViewHolder(@Nonnull RowViewHolder holder, int position) { + SkuRowData data = getData(position); + if (data != null) { + holder.title.setText(data.getTitle()); + if (getItemViewType(position) != SkusAdapter.TYPE_HEADER) { + + String sku = data.getSku(); + if (SkuType.SUBS.equals(data.getSkuType())) { + String[] rows = context.getResources().getStringArray(R.array.pro_description); + holder.description.setText( + Joiner.on('\n').join(transform(asList(rows), item -> "\u2022 " + item))); + holder.button.setVisibility(View.VISIBLE); + holder.price.setVisibility(View.VISIBLE); + holder.price.setText(data.getPrice()); + holder.button.setText( + inventory.purchased(sku) ? R.string.button_subscribed : R.string.button_subscribe); + } else { + holder.description.setText(data.getDescription()); + holder.button.setVisibility(View.GONE); + holder.price.setVisibility(View.GONE); + if (BuildConfig.DEBUG) { + holder.button.setVisibility(View.VISIBLE); + holder.button.setText( + inventory.purchased(sku) ? R.string.debug_consume : R.string.debug_buy); + } + } + } + } + } + + @Override + public int getItemCount() { + return data.size(); + } + + @Override + public SkuRowData getData(int position) { + return data.isEmpty() ? null : data.get(position); + } + + public interface OnClickHandler { + void click(SkuRowData skuRowData); + } + + /** Types for adapter rows */ + @Retention(SOURCE) + @IntDef({TYPE_HEADER, TYPE_NORMAL}) + public @interface RowTypeDef {} +} diff --git a/app/src/main/java/org/tasks/billing/row/RowDataProvider.java b/app/src/main/java/org/tasks/billing/row/RowDataProvider.java new file mode 100644 index 000000000..c4fa6f180 --- /dev/null +++ b/app/src/main/java/org/tasks/billing/row/RowDataProvider.java @@ -0,0 +1,26 @@ +/* + * Copyright 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.tasks.billing.row; + + + +/** + * Provider for data that corresponds to a particular row + */ +public interface RowDataProvider { + SkuRowData getData(int position); +} + diff --git a/app/src/main/java/org/tasks/billing/row/RowViewHolder.java b/app/src/main/java/org/tasks/billing/row/RowViewHolder.java new file mode 100644 index 000000000..74f5d1d70 --- /dev/null +++ b/app/src/main/java/org/tasks/billing/row/RowViewHolder.java @@ -0,0 +1,29 @@ +package org.tasks.billing.row; + +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; +import org.tasks.R; + +public final class RowViewHolder extends RecyclerView.ViewHolder { + public final TextView title; + public final TextView description; + public final TextView price; + public final Button button; + + public interface ButtonClick { + void onClick(int row); + } + + public RowViewHolder(final View itemView, final ButtonClick onClick) { + super(itemView); + title = itemView.findViewById(R.id.title); + price = itemView.findViewById(R.id.price); + description = itemView.findViewById(R.id.description); + button = itemView.findViewById(R.id.buy_button); + if (button != null) { + button.setOnClickListener(view -> onClick.onClick(getAdapterPosition())); + } + } +} diff --git a/app/src/main/java/org/tasks/billing/row/SkuRowData.java b/app/src/main/java/org/tasks/billing/row/SkuRowData.java new file mode 100644 index 000000000..b59149bbf --- /dev/null +++ b/app/src/main/java/org/tasks/billing/row/SkuRowData.java @@ -0,0 +1,70 @@ +/* + * Copyright 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.tasks.billing.row; + +import com.android.billingclient.api.BillingClient.SkuType; +import com.android.billingclient.api.SkuDetails; +import org.tasks.billing.SkusAdapter; +import org.tasks.billing.SkusAdapter.RowTypeDef; + +/** + * A model for SkusAdapter's row + */ +public class SkuRowData { + private String sku, title, price, description; + private @RowTypeDef int type; + private @SkuType String billingType; + + public SkuRowData(SkuDetails details, @RowTypeDef int rowType, + @SkuType String billingType) { + this.sku = details.getSku(); + this.title = details.getTitle(); + this.price = details.getPrice(); + this.description = details.getDescription(); + this.type = rowType; + this.billingType = billingType; + } + + public SkuRowData(String title) { + this.title = title; + this.type = SkusAdapter.TYPE_HEADER; + } + + public String getSku() { + return sku; + } + + public String getTitle() { + return title; + } + + public String getPrice() { + return price; + } + + public String getDescription() { + return description; + } + + public @RowTypeDef int getRowType() { + return type; + } + + public @SkuType + String getSkuType() { + return billingType; + } +} diff --git a/app/src/main/java/org/tasks/dashclock/DashClockExtension.java b/app/src/main/java/org/tasks/dashclock/DashClockExtension.java index 20a127acc..d622a3210 100644 --- a/app/src/main/java/org/tasks/dashclock/DashClockExtension.java +++ b/app/src/main/java/org/tasks/dashclock/DashClockExtension.java @@ -12,6 +12,7 @@ import java.util.List; import javax.inject.Inject; import org.tasks.LocalBroadcastManager; import org.tasks.R; +import org.tasks.billing.Inventory; import org.tasks.injection.InjectingApplication; import org.tasks.preferences.DefaultFilterProvider; import org.tasks.preferences.Preferences; @@ -22,6 +23,8 @@ public class DashClockExtension extends com.google.android.apps.dashclock.api.Da @Inject DefaultFilterProvider defaultFilterProvider; @Inject TaskDao taskDao; @Inject Preferences preferences; + @Inject LocalBroadcastManager localBroadcastManager; + @Inject Inventory inventory; private final BroadcastReceiver refreshReceiver = new BroadcastReceiver() { @Override @@ -29,7 +32,6 @@ public class DashClockExtension extends com.google.android.apps.dashclock.api.Da refresh(); } }; - @Inject LocalBroadcastManager localBroadcastManager; @Override public void onCreate() { @@ -53,7 +55,7 @@ public class DashClockExtension extends com.google.android.apps.dashclock.api.Da } private void refresh() { - if (preferences.hasPurchase(R.string.p_purchased_dashclock)) { + if (inventory.purchasedDashclock()) { final String filterPreference = preferences.getStringValue(R.string.p_dashclock_filter); Filter filter = defaultFilterProvider.getFilterFromPreference(filterPreference); @@ -85,8 +87,8 @@ public class DashClockExtension extends com.google.android.apps.dashclock.api.Da new ExtensionData() .visible(true) .icon(R.drawable.ic_check_white_24dp) - .status(getString(R.string.buy)) - .expandedTitle(getString(R.string.buy_dashclock_extension)) + .status(getString(R.string.subscribe_to_pro)) + .expandedTitle(getString(R.string.subscribe_to_pro)) .clickIntent(new Intent(this, DashClockSettings.class))); } } diff --git a/app/src/main/java/org/tasks/dashclock/DashClockSettings.java b/app/src/main/java/org/tasks/dashclock/DashClockSettings.java index ea652cb2a..d1233f888 100644 --- a/app/src/main/java/org/tasks/dashclock/DashClockSettings.java +++ b/app/src/main/java/org/tasks/dashclock/DashClockSettings.java @@ -8,35 +8,27 @@ import javax.inject.Inject; import org.tasks.LocalBroadcastManager; import org.tasks.R; import org.tasks.activities.FilterSelectionActivity; -import org.tasks.billing.PurchaseHelper; -import org.tasks.billing.PurchaseHelperCallback; +import org.tasks.billing.BillingClient; +import org.tasks.billing.Inventory; +import org.tasks.billing.PurchaseActivity; import org.tasks.injection.ActivityComponent; import org.tasks.injection.InjectingPreferenceActivity; import org.tasks.preferences.DefaultFilterProvider; -import org.tasks.preferences.Preferences; -public class DashClockSettings extends InjectingPreferenceActivity - implements PurchaseHelperCallback { +public class DashClockSettings extends InjectingPreferenceActivity { - private static final String EXTRA_PURCHASE_INITIATED = "extra_purchase_initiated"; private static final int REQUEST_SELECT_FILTER = 1005; - private static final int REQUEST_PURCHASE = 1006; + private static final int REQUEST_SUBSCRIPTION = 1006; - @Inject Preferences preferences; @Inject DefaultFilterProvider defaultFilterProvider; @Inject LocalBroadcastManager localBroadcastManager; - @Inject PurchaseHelper purchaseHelper; - - private boolean purchaseInitiated; + @Inject BillingClient billingClient; + @Inject Inventory inventory; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (savedInstanceState != null) { - purchaseInitiated = savedInstanceState.getBoolean(EXTRA_PURCHASE_INITIATED); - } - addPreferencesFromResource(R.xml.preferences_dashclock); findPreference(getString(R.string.p_dashclock_filter)) @@ -52,23 +44,8 @@ public class DashClockSettings extends InjectingPreferenceActivity refreshPreferences(); - if (!preferences.hasPurchase(R.string.p_purchased_dashclock) && !purchaseInitiated) { - purchaseHelper.purchase( - this, - getString(R.string.sku_dashclock), - getString(R.string.p_purchased_dashclock), - REQUEST_PURCHASE, - this); - purchaseInitiated = true; - } - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - if (!isChangingConfigurations()) { - purchaseHelper.disposeIabHelper(); + if (!inventory.purchasedDashclock()) { + startActivityForResult(new Intent(this, PurchaseActivity.class), REQUEST_SUBSCRIPTION); } } @@ -86,29 +63,15 @@ public class DashClockSettings extends InjectingPreferenceActivity refreshPreferences(); localBroadcastManager.broadcastRefresh(); } - } else if (requestCode == REQUEST_PURCHASE) { - purchaseHelper.handleActivityResult(this, requestCode, resultCode, data); + } else if (requestCode == REQUEST_SUBSCRIPTION) { + if (!inventory.purchasedDashclock()) { + finish(); + } } else { super.onActivityResult(requestCode, resultCode, data); } } - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - - outState.putBoolean(EXTRA_PURCHASE_INITIATED, purchaseInitiated); - } - - @Override - public void purchaseCompleted(boolean success, String sku) { - if (success) { - localBroadcastManager.broadcastRefresh(); - } else { - finish(); - } - } - private void refreshPreferences() { Filter filter = defaultFilterProvider.getFilterFromPreference(R.string.p_dashclock_filter); findPreference(getString(R.string.p_dashclock_filter)).setSummary(filter.listingTitle); diff --git a/app/src/main/java/org/tasks/dialogs/ColorPickerDialog.java b/app/src/main/java/org/tasks/dialogs/ColorPickerDialog.java index df3fd4755..69d91997a 100644 --- a/app/src/main/java/org/tasks/dialogs/ColorPickerDialog.java +++ b/app/src/main/java/org/tasks/dialogs/ColorPickerDialog.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.List; import javax.inject.Inject; import org.tasks.R; +import org.tasks.billing.Inventory; import org.tasks.injection.DialogFragmentComponent; import org.tasks.injection.ForActivity; import org.tasks.injection.InjectingDialogFragment; @@ -30,6 +31,7 @@ public class ColorPickerDialog extends InjectingDialogFragment { @Inject @ForActivity Context context; @Inject Preferences preferences; @Inject Theme theme; + @Inject Inventory inventory; private ThemePickerCallback callback; private SingleCheckedArrayAdapter adapter; private Dialog dialog; @@ -59,8 +61,7 @@ public class ColorPickerDialog extends InjectingDialogFragment { context, transform(items, Pickable::getName), theme.getThemeAccent()) { @Override protected int getDrawable(int position) { - return preferences.hasPurchase(R.string.p_purchased_themes) - || items.get(position).isFree() + return inventory.purchasedThemes() || items.get(position).isFree() ? R.drawable.ic_lens_black_24dp : R.drawable.ic_vpn_key_black_24dp; } @@ -79,7 +80,7 @@ public class ColorPickerDialog extends InjectingDialogFragment { selected, (dialog, which) -> { Pickable picked = items.get(which); - if (preferences.hasPurchase(R.string.p_purchased_themes) || picked.isFree()) { + if (inventory.purchasedThemes() || picked.isFree()) { callback.themePicked(picked); } else { callback.initiateThemePurchase(); diff --git a/app/src/main/java/org/tasks/dialogs/DonationDialog.java b/app/src/main/java/org/tasks/dialogs/DonationDialog.java deleted file mode 100644 index da5332b33..000000000 --- a/app/src/main/java/org/tasks/dialogs/DonationDialog.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.tasks.dialogs; - -import android.app.Dialog; -import android.os.Bundle; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.inject.Inject; -import org.tasks.R; -import org.tasks.billing.PurchaseHelper; -import org.tasks.injection.InjectingNativeDialogFragment; -import org.tasks.injection.NativeDialogFragmentComponent; -import org.tasks.preferences.BasicPreferences; - -public class DonationDialog extends InjectingNativeDialogFragment { - - @Inject DialogBuilder dialogBuilder; - @Inject PurchaseHelper purchaseHelper; - - public static DonationDialog newDonationDialog() { - return new DonationDialog(); - } - - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - final List donationValues = getDonationValues(); - return dialogBuilder - .newDialog() - .setTitle(R.string.select_amount) - .setItems( - donationValues, - (dialog, which) -> { - String value = donationValues.get(which); - Pattern pattern = Pattern.compile("\\$(\\d+) USD"); - Matcher matcher = pattern.matcher(value); - //noinspection ResultOfMethodCallIgnored - matcher.matches(); - String sku = - String.format( - java.util.Locale.ENGLISH, "%03d", Integer.parseInt(matcher.group(1))); - purchaseHelper.purchase( - getActivity(), - sku, - null, - BasicPreferences.REQUEST_PURCHASE, - (BasicPreferences) getActivity()); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - private List getDonationValues() { - List values = new ArrayList<>(); - for (int i = 1; i <= 100; i++) { - values.add(String.format("$%s USD", Integer.toString(i))); - } - return values; - } - - @Override - protected void inject(NativeDialogFragmentComponent component) { - component.inject(this); - } -} diff --git a/app/src/main/java/org/tasks/injection/ActivityComponent.java b/app/src/main/java/org/tasks/injection/ActivityComponent.java index 799e71e83..d36f77c1c 100644 --- a/app/src/main/java/org/tasks/injection/ActivityComponent.java +++ b/app/src/main/java/org/tasks/injection/ActivityComponent.java @@ -22,6 +22,7 @@ import org.tasks.activities.FilterSettingsActivity; import org.tasks.activities.GoogleTaskListSettingsActivity; import org.tasks.activities.TagSettingsActivity; import org.tasks.activities.TimePickerActivity; +import org.tasks.billing.PurchaseActivity; import org.tasks.caldav.CaldavSettingsActivity; import org.tasks.dashclock.DashClockSettings; import org.tasks.files.FileExplore; @@ -134,4 +135,6 @@ public interface ActivityComponent { void inject(TaskerCreateTaskActivity taskerCreateTaskActivity); void inject(TaskListViewModel taskListViewModel); + + void inject(PurchaseActivity purchaseActivity); } diff --git a/app/src/main/java/org/tasks/injection/NativeDialogFragmentComponent.java b/app/src/main/java/org/tasks/injection/NativeDialogFragmentComponent.java index f3272eadc..ce553d414 100644 --- a/app/src/main/java/org/tasks/injection/NativeDialogFragmentComponent.java +++ b/app/src/main/java/org/tasks/injection/NativeDialogFragmentComponent.java @@ -2,7 +2,6 @@ package org.tasks.injection; import dagger.Subcomponent; import org.tasks.activities.RemoteListNativePicker; -import org.tasks.dialogs.DonationDialog; import org.tasks.dialogs.ExportTasksDialog; import org.tasks.dialogs.ImportTasksDialog; import org.tasks.dialogs.NativeDatePickerDialog; @@ -26,6 +25,4 @@ public interface NativeDialogFragmentComponent { void inject(ExportTasksDialog exportTasksDialog); void inject(ImportTasksDialog importTasksDialog); - - void inject(DonationDialog donationDialog); } diff --git a/app/src/main/java/org/tasks/locale/ui/activity/TaskerCreateTaskActivity.java b/app/src/main/java/org/tasks/locale/ui/activity/TaskerCreateTaskActivity.java index fc5af1d84..87049d82e 100755 --- a/app/src/main/java/org/tasks/locale/ui/activity/TaskerCreateTaskActivity.java +++ b/app/src/main/java/org/tasks/locale/ui/activity/TaskerCreateTaskActivity.java @@ -11,22 +11,25 @@ import butterknife.BindView; import butterknife.ButterKnife; import javax.inject.Inject; import net.dinglisch.android.tasker.TaskerPlugin; +import org.tasks.LocalBroadcastManager; import org.tasks.R; -import org.tasks.billing.PurchaseHelper; -import org.tasks.billing.PurchaseHelperCallback; +import org.tasks.billing.BillingClient; +import org.tasks.billing.Inventory; +import org.tasks.billing.PurchaseActivity; import org.tasks.injection.ActivityComponent; import org.tasks.locale.bundle.TaskCreationBundle; import org.tasks.preferences.Preferences; import org.tasks.ui.MenuColorizer; public final class TaskerCreateTaskActivity extends AbstractFragmentPluginAppCompatActivity - implements PurchaseHelperCallback, Toolbar.OnMenuItemClickListener { + implements Toolbar.OnMenuItemClickListener { - private static final int REQUEST_PURCHASE = 10125; - private static final String EXTRA_PURCHASE_INITIATED = "extra_purchase_initiated"; + private static final int REQUEST_SUBSCRIPTION = 10101; @Inject Preferences preferences; - @Inject PurchaseHelper purchaseHelper; + @Inject BillingClient billingClient; + @Inject Inventory inventory; + @Inject LocalBroadcastManager localBroadcastManager; @BindView(R.id.title) TextInputEditText title; @@ -47,7 +50,6 @@ public final class TaskerCreateTaskActivity extends AbstractFragmentPluginAppCom TextInputEditText description; private Bundle previousBundle; - private boolean purchaseInitiated; @Override public void onCreate(final Bundle savedInstanceState) { @@ -76,19 +78,12 @@ public final class TaskerCreateTaskActivity extends AbstractFragmentPluginAppCom if (savedInstanceState != null) { previousBundle = savedInstanceState.getParcelable(TaskCreationBundle.EXTRA_BUNDLE); - purchaseInitiated = savedInstanceState.getBoolean(EXTRA_PURCHASE_INITIATED); TaskCreationBundle bundle = new TaskCreationBundle(previousBundle); title.setText(bundle.getTitle()); } - if (!preferences.hasPurchase(R.string.p_purchased_tasker) && !purchaseInitiated) { - purchaseInitiated = - purchaseHelper.purchase( - this, - getString(R.string.sku_tasker), - getString(R.string.p_purchased_tasker), - REQUEST_PURCHASE, - this); + if (!inventory.purchasedTasker()) { + startActivityForResult(new Intent(this, PurchaseActivity.class), REQUEST_SUBSCRIPTION); } } @@ -137,15 +132,6 @@ public final class TaskerCreateTaskActivity extends AbstractFragmentPluginAppCom return title.getText().toString().trim(); } - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == REQUEST_PURCHASE) { - purchaseHelper.handleActivityResult(this, requestCode, resultCode, data); - } else { - super.onActivityResult(requestCode, resultCode, data); - } - } - @Override public void onBackPressed() { final boolean backButtonSavesTask = preferences.backButtonSavesTask(); @@ -165,20 +151,10 @@ public final class TaskerCreateTaskActivity extends AbstractFragmentPluginAppCom finish(); } - @Override - protected void onDestroy() { - super.onDestroy(); - - if (!isChangingConfigurations()) { - purchaseHelper.disposeIabHelper(); - } - } - @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putParcelable(TaskCreationBundle.EXTRA_BUNDLE, previousBundle); - outState.putBoolean(EXTRA_PURCHASE_INITIATED, purchaseInitiated); } @Override @@ -186,13 +162,6 @@ public final class TaskerCreateTaskActivity extends AbstractFragmentPluginAppCom component.inject(this); } - @Override - public void purchaseCompleted(boolean success, String sku) { - if (!success) { - discardButtonClick(); - } - } - @Override public boolean onMenuItemClick(MenuItem item) { switch (item.getItemId()) { @@ -204,6 +173,17 @@ public final class TaskerCreateTaskActivity extends AbstractFragmentPluginAppCom new Intent(Intent.ACTION_VIEW).setData(Uri.parse("http://tasks.org/help/tasker"))); return true; } - return super.onOptionsItemSelected(item); + return onOptionsItemSelected(item); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_SUBSCRIPTION) { + if (!inventory.purchasedTasker()) { + discardButtonClick(); + } + } else { + super.onActivityResult(requestCode, resultCode, data); + } } } diff --git a/app/src/main/java/org/tasks/locale/ui/activity/TaskerSettingsActivity.java b/app/src/main/java/org/tasks/locale/ui/activity/TaskerSettingsActivity.java index ac6891709..95db8ac9a 100755 --- a/app/src/main/java/org/tasks/locale/ui/activity/TaskerSettingsActivity.java +++ b/app/src/main/java/org/tasks/locale/ui/activity/TaskerSettingsActivity.java @@ -8,28 +8,26 @@ import com.todoroo.astrid.api.Filter; import javax.inject.Inject; import org.tasks.R; import org.tasks.activities.FilterSelectionActivity; -import org.tasks.billing.PurchaseHelper; -import org.tasks.billing.PurchaseHelperCallback; +import org.tasks.billing.BillingClient; +import org.tasks.billing.Inventory; +import org.tasks.billing.PurchaseActivity; import org.tasks.injection.ActivityComponent; import org.tasks.locale.bundle.ListNotificationBundle; import org.tasks.preferences.DefaultFilterProvider; -import org.tasks.preferences.Preferences; public final class TaskerSettingsActivity extends AbstractFragmentPluginPreferenceActivity - implements PurchaseHelperCallback, Toolbar.OnMenuItemClickListener { + implements Toolbar.OnMenuItemClickListener { private static final int REQUEST_SELECT_FILTER = 10124; - private static final int REQUEST_PURCHASE = 10125; + private static final int REQUEST_SUBSCRIPTION = 10125; private static final String EXTRA_FILTER = "extra_filter"; - private static final String EXTRA_PURCHASE_INITIATED = "extra_purchase_initiated"; - @Inject Preferences preferences; @Inject DefaultFilterProvider defaultFilterProvider; - @Inject PurchaseHelper purchaseHelper; + @Inject BillingClient billingClient; + @Inject Inventory inventory; private Bundle previousBundle; private Filter filter; - private boolean purchaseInitiated; @Override public void onCreate(final Bundle savedInstanceState) { @@ -41,7 +39,6 @@ public final class TaskerSettingsActivity extends AbstractFragmentPluginPreferen previousBundle = savedInstanceState.getParcelable(ListNotificationBundle.BUNDLE_EXTRA_PREVIOUS_BUNDLE); filter = savedInstanceState.getParcelable(EXTRA_FILTER); - purchaseInitiated = savedInstanceState.getBoolean(EXTRA_PURCHASE_INITIATED); } else { filter = defaultFilterProvider.getDefaultFilter(); } @@ -59,14 +56,8 @@ public final class TaskerSettingsActivity extends AbstractFragmentPluginPreferen refreshPreferences(); - if (!preferences.hasPurchase(R.string.p_purchased_tasker) && !purchaseInitiated) { - purchaseInitiated = - purchaseHelper.purchase( - this, - getString(R.string.sku_tasker), - getString(R.string.p_purchased_tasker), - REQUEST_PURCHASE, - this); + if (!inventory.purchasedTasker()) { + startActivityForResult(new Intent(this, PurchaseActivity.class), REQUEST_SUBSCRIPTION); } } @@ -108,28 +99,20 @@ public final class TaskerSettingsActivity extends AbstractFragmentPluginPreferen filter = data.getParcelableExtra(FilterSelectionActivity.EXTRA_FILTER); refreshPreferences(); } - } else if (requestCode == REQUEST_PURCHASE) { - purchaseHelper.handleActivityResult(this, requestCode, resultCode, data); + } else if (requestCode == REQUEST_SUBSCRIPTION) { + if (!inventory.purchasedTasker()) { + cancel(); + } } else { super.onActivityResult(requestCode, resultCode, data); } } - @Override - protected void onDestroy() { - super.onDestroy(); - - if (!isChangingConfigurations()) { - purchaseHelper.disposeIabHelper(); - } - } - @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putParcelable(ListNotificationBundle.BUNDLE_EXTRA_PREVIOUS_BUNDLE, previousBundle); outState.putParcelable(EXTRA_FILTER, filter); - outState.putBoolean(EXTRA_PURCHASE_INITIATED, purchaseInitiated); } private void refreshPreferences() { @@ -141,13 +124,6 @@ public final class TaskerSettingsActivity extends AbstractFragmentPluginPreferen component.inject(this); } - @Override - public void purchaseCompleted(boolean success, String sku) { - if (!success) { - cancel(); - } - } - @Override public boolean onMenuItemClick(MenuItem item) { switch (item.getItemId()) { @@ -155,6 +131,6 @@ public final class TaskerSettingsActivity extends AbstractFragmentPluginPreferen finish(); return true; } - return super.onOptionsItemSelected(item); + return onOptionsItemSelected(item); } } diff --git a/app/src/main/java/org/tasks/preferences/BasicPreferences.java b/app/src/main/java/org/tasks/preferences/BasicPreferences.java index b955eaa68..33cac69b6 100644 --- a/app/src/main/java/org/tasks/preferences/BasicPreferences.java +++ b/app/src/main/java/org/tasks/preferences/BasicPreferences.java @@ -1,17 +1,14 @@ package org.tasks.preferences; import static com.todoroo.andlib.utility.AndroidUtilities.atLeastJellybeanMR1; -import static org.tasks.dialogs.DonationDialog.newDonationDialog; import static org.tasks.dialogs.ExportTasksDialog.newExportTasksDialog; import static org.tasks.dialogs.ImportTasksDialog.newImportTasksDialog; import static org.tasks.locale.LocalePickerDialog.newLocalePickerDialog; import android.app.Activity; import android.content.Intent; -import android.net.Uri; import android.os.Bundle; import android.preference.Preference; -import android.preference.TwoStatePreference; import com.google.common.base.Strings; import com.todoroo.astrid.core.OldTaskPreferences; import com.todoroo.astrid.reminders.ReminderPreferences; @@ -22,8 +19,8 @@ import org.tasks.R; import org.tasks.activities.ColorPickerActivity; import org.tasks.analytics.Tracker; import org.tasks.analytics.Tracking; -import org.tasks.billing.PurchaseHelper; -import org.tasks.billing.PurchaseHelperCallback; +import org.tasks.billing.BillingClient; +import org.tasks.billing.Inventory; import org.tasks.dialogs.DialogBuilder; import org.tasks.files.FileExplore; import org.tasks.injection.ActivityComponent; @@ -34,15 +31,12 @@ import org.tasks.themes.ThemeAccent; import org.tasks.themes.ThemeBase; import org.tasks.themes.ThemeCache; import org.tasks.themes.ThemeColor; -import timber.log.Timber; public class BasicPreferences extends InjectingPreferenceActivity - implements LocalePickerDialog.LocaleSelectionHandler, PurchaseHelperCallback { + implements LocalePickerDialog.LocaleSelectionHandler { - public static final int REQUEST_PURCHASE = 10007; private static final String EXTRA_RESULT = "extra_result"; private static final String FRAG_TAG_LOCALE_PICKER = "frag_tag_locale_picker"; - private static final String FRAG_TAG_DONATION = "frag_tag_donation"; private static final String FRAG_TAG_IMPORT_TASKS = "frag_tag_import_tasks"; private static final String FRAG_TAG_EXPORT_TASKS = "frag_tag_export_tasks"; private static final int RC_PREFS = 10001; @@ -59,7 +53,8 @@ public class BasicPreferences extends InjectingPreferenceActivity @Inject DialogBuilder dialogBuilder; @Inject Locale locale; @Inject ThemeCache themeCache; - @Inject PurchaseHelper purchaseHelper; + @Inject BillingClient billingClient; + @Inject Inventory inventory; private Bundle result; @@ -70,8 +65,10 @@ public class BasicPreferences extends InjectingPreferenceActivity result = savedInstanceState == null ? new Bundle() : savedInstanceState.getBundle(EXTRA_RESULT); addPreferencesFromResource(R.xml.preferences); - addPreferencesFromResource(R.xml.preferences_addons); addPreferencesFromResource(R.xml.preferences_privacy); + if (BuildConfig.DEBUG) { + addPreferencesFromResource(R.xml.preferences_debug); + } setupActivity(R.string.EPr_appearance_header, AppearancePreferences.class); setupActivity(R.string.notifications, ReminderPreferences.class); @@ -136,88 +133,6 @@ public class BasicPreferences extends InjectingPreferenceActivity return false; }); - findPreference(R.string.TLA_menu_donate) - .setOnPreferenceClickListener( - preference -> { - if (BuildConfig.FLAVOR.equals("googleplay")) { - newDonationDialog().show(getFragmentManager(), FRAG_TAG_DONATION); - } else { - startActivity( - new Intent(Intent.ACTION_VIEW).setData(Uri.parse("http://tasks.org/donate"))); - } - return false; - }); - - findPreference(R.string.p_purchased_themes) - .setOnPreferenceChangeListener( - (preference, newValue) -> { - if (newValue != null - && (boolean) newValue - && !preferences.hasPurchase(R.string.p_purchased_themes)) { - purchaseHelper.purchase( - BasicPreferences.this, - getString(R.string.sku_themes), - getString(R.string.p_purchased_themes), - REQUEST_PURCHASE, - BasicPreferences.this); - } - return false; - }); - - findPreference(R.string.p_purchased_tasker) - .setOnPreferenceChangeListener( - (preference, newValue) -> { - if (newValue != null - && (boolean) newValue - && !preferences.hasPurchase(R.string.p_purchased_tasker)) { - purchaseHelper.purchase( - BasicPreferences.this, - getString(R.string.sku_tasker), - getString(R.string.p_purchased_tasker), - REQUEST_PURCHASE, - BasicPreferences.this); - } - return false; - }); - - findPreference(R.string.p_purchased_dashclock) - .setOnPreferenceChangeListener( - (preference, newValue) -> { - if (newValue != null - && (boolean) newValue - && !preferences.hasPurchase(R.string.p_purchased_dashclock)) { - purchaseHelper.purchase( - BasicPreferences.this, - getString(R.string.sku_dashclock), - getString(R.string.p_purchased_dashclock), - REQUEST_PURCHASE, - BasicPreferences.this); - } - return false; - }); - - if (BuildConfig.DEBUG) { - addPreferencesFromResource(R.xml.preferences_debug); - - findPreference(getString(R.string.debug_unlock_purchases)) - .setOnPreferenceClickListener( - preference -> { - preferences.setBoolean(R.string.p_purchased_dashclock, true); - preferences.setBoolean(R.string.p_purchased_tasker, true); - preferences.setBoolean(R.string.p_purchased_themes, true); - recreate(); - return true; - }); - - findPreference(getString(R.string.debug_consume_purchases)) - .setOnPreferenceClickListener( - preference -> { - purchaseHelper.consumePurchases(); - recreate(); - return true; - }); - } - findPreference(R.string.backup_BAc_import) .setOnPreferenceClickListener( preference -> { @@ -237,15 +152,14 @@ public class BasicPreferences extends InjectingPreferenceActivity initializeBackupDirectory(); - requires(R.string.get_plugins, atLeastJellybeanMR1(), R.string.p_purchased_dashclock); requires( R.string.settings_localization, atLeastJellybeanMR1(), R.string.p_language, R.string.p_layout_direction); + //noinspection ConstantConditions if (!BuildConfig.FLAVOR.equals("googleplay")) { - requires(R.string.settings_general, false, R.string.synchronization); requires(R.string.privacy, false, R.string.p_collect_statistics); } } @@ -307,8 +221,6 @@ public class BasicPreferences extends InjectingPreferenceActivity newImportTasksDialog(data.getStringExtra(FileExplore.EXTRA_FILE)) .show(getFragmentManager(), FRAG_TAG_IMPORT_TASKS); } - } else if (requestCode == REQUEST_PURCHASE) { - purchaseHelper.handleActivityResult(this, requestCode, resultCode, data); } else { super.onActivityResult(requestCode, resultCode, data); } @@ -344,32 +256,6 @@ public class BasicPreferences extends InjectingPreferenceActivity super.finish(); } - @Override - public void purchaseCompleted(final boolean success, final String sku) { - runOnUiThread( - () -> { - if (getString(R.string.sku_tasker).equals(sku)) { - ((TwoStatePreference) findPreference(R.string.p_purchased_tasker)).setChecked(success); - } else if (getString(R.string.sku_dashclock).equals(sku)) { - ((TwoStatePreference) findPreference(R.string.p_purchased_dashclock)) - .setChecked(success); - } else if (getString(R.string.sku_themes).equals(sku)) { - ((TwoStatePreference) findPreference(R.string.p_purchased_themes)).setChecked(success); - } else { - Timber.d("Unhandled sku: %s", sku); - } - }); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - if (!isChangingConfigurations()) { - purchaseHelper.disposeIabHelper(); - } - } - private void initializeBackupDirectory() { findPreference(getString(R.string.p_backup_dir)) .setOnPreferenceClickListener( diff --git a/app/src/main/java/org/tasks/preferences/Preferences.java b/app/src/main/java/org/tasks/preferences/Preferences.java index 5b9c272c1..4d8410bb5 100644 --- a/app/src/main/java/org/tasks/preferences/Preferences.java +++ b/app/src/main/java/org/tasks/preferences/Preferences.java @@ -1,7 +1,10 @@ package org.tasks.preferences; import static android.content.SharedPreferences.Editor; +import static com.google.common.collect.Iterables.transform; +import static com.google.common.collect.Sets.newHashSet; import static com.todoroo.andlib.utility.AndroidUtilities.atLeastJellybean; +import static java.util.Collections.emptySet; import android.content.Context; import android.content.SharedPreferences; @@ -11,11 +14,14 @@ import android.net.Uri; import android.preference.PreferenceManager; import android.support.v4.app.NotificationCompat; import android.text.TextUtils; +import com.android.billingclient.api.Purchase; +import com.google.gson.GsonBuilder; import com.todoroo.astrid.activity.BeastModePreferences; import com.todoroo.astrid.api.AstridApiConstants; import com.todoroo.astrid.core.SortHelper; import com.todoroo.astrid.data.Task; import java.io.File; +import java.util.Collection; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import javax.inject.Inject; @@ -123,6 +129,30 @@ public class Preferences { return getMillisPerDayPref(R.string.p_date_shortcut_evening, R.integer.default_evening); } + public Iterable getPurchases() { + try { + return transform( + prefs.getStringSet(context.getString(R.string.p_purchases), emptySet()), + p -> + new GsonBuilder().create().fromJson(p, com.android.billingclient.api.Purchase.class)); + } catch (Exception e) { + Timber.e(e, e.getMessage()); + return emptySet(); + } + } + + public void setPurchases(Collection purchases) { + try { + Editor editor = prefs.edit(); + editor.putStringSet( + context.getString(R.string.p_purchases), + newHashSet(transform(purchases, p -> new GsonBuilder().create().toJson(p)))); + editor.apply(); + } catch (Exception e) { + Timber.e(e, e.getMessage()); + } + } + public int getDateShortcutNight() { return getMillisPerDayPref(R.string.p_date_shortcut_night, R.integer.default_night); } @@ -265,10 +295,6 @@ public class Preferences { && permissionChecker.canAccessMissedCallPermissions(); } - public boolean hasPurchase(int keyResource) { - return getBoolean(keyResource, false); - } - public boolean getBoolean(int keyResources, boolean defValue) { return getBoolean(context.getString(keyResources), defValue); } diff --git a/app/src/main/java/org/tasks/ui/NavigationDrawerFragment.java b/app/src/main/java/org/tasks/ui/NavigationDrawerFragment.java index a07bf3a4d..db186f893 100644 --- a/app/src/main/java/org/tasks/ui/NavigationDrawerFragment.java +++ b/app/src/main/java/org/tasks/ui/NavigationDrawerFragment.java @@ -65,12 +65,15 @@ public class NavigationDrawerFragment extends InjectingFragment { @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == FilterAdapter.REQUEST_SETTINGS - && resultCode == Activity.RESULT_OK - && data != null) { - if (data.getBooleanExtra(AppearancePreferences.EXTRA_RESTART, false)) { - TaskListActivity activity = (TaskListActivity) getActivity(); - activity.restart(); + if (requestCode == FilterAdapter.REQUEST_SETTINGS) { + if (resultCode == Activity.RESULT_OK && data != null) { + if (data.getBooleanExtra(AppearancePreferences.EXTRA_RESTART, false)) { + ((TaskListActivity) getActivity()).restart(); + } + } + } else if (requestCode == FilterAdapter.REQUEST_PURCHASE) { + if (resultCode == Activity.RESULT_OK) { + ((TaskListActivity) getActivity()).restart(); } } else if (requestCode == REQUEST_NEW_LIST || requestCode == ACTIVITY_REQUEST_NEW_FILTER diff --git a/app/src/main/res/drawable/ic_attach_money_black_24dp.xml b/app/src/main/res/drawable/ic_attach_money_black_24dp.xml new file mode 100644 index 000000000..4c88aeeb5 --- /dev/null +++ b/app/src/main/res/drawable/ic_attach_money_black_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_purchase.xml b/app/src/main/res/layout/activity_purchase.xml new file mode 100644 index 000000000..5876926fa --- /dev/null +++ b/app/src/main/res/layout/activity_purchase.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/loading_indicator.xml b/app/src/main/res/layout/loading_indicator.xml new file mode 100644 index 000000000..6e2823aa3 --- /dev/null +++ b/app/src/main/res/layout/loading_indicator.xml @@ -0,0 +1,25 @@ + + + diff --git a/app/src/main/res/layout/sku_details_row.xml b/app/src/main/res/layout/sku_details_row.xml new file mode 100644 index 000000000..5511a3ca2 --- /dev/null +++ b/app/src/main/res/layout/sku_details_row.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + +