From 86b318a6b86b6f0bad565cecf4f5e2fc4e0ad464 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 29 Nov 2013 19:31:19 -0600 Subject: [PATCH] Add donate menu item --- astrid/proguard.cfg | 1 + astrid/src/main/AndroidManifest.xml | 14 +- .../billing/IMarketBillingService.aidl | 24 + .../astrid/activity/TaskListActivity.java | 34 ++ .../billing/AstridPurchaseObserver.java | 72 +++ .../astrid/billing/BillingConstants.java | 87 +++ .../astrid/billing/BillingReceiver.java | 61 ++ .../astrid/billing/BillingService.java | 534 ++++++++++++++++++ .../astrid/billing/PurchaseObserver.java | 102 ++++ .../astrid/billing/ResponseHandler.java | 61 ++ .../com/todoroo/astrid/billing/Security.java | 45 ++ .../src/main/res/menu/task_list_activity.xml | 5 + astrid/src/main/res/values/strings.xml | 1 + 13 files changed, 1040 insertions(+), 1 deletion(-) create mode 100644 astrid/src/main/aidl/com/android/vending/billing/IMarketBillingService.aidl create mode 100644 astrid/src/main/java/com/todoroo/astrid/billing/AstridPurchaseObserver.java create mode 100644 astrid/src/main/java/com/todoroo/astrid/billing/BillingConstants.java create mode 100644 astrid/src/main/java/com/todoroo/astrid/billing/BillingReceiver.java create mode 100644 astrid/src/main/java/com/todoroo/astrid/billing/BillingService.java create mode 100644 astrid/src/main/java/com/todoroo/astrid/billing/PurchaseObserver.java create mode 100644 astrid/src/main/java/com/todoroo/astrid/billing/ResponseHandler.java create mode 100644 astrid/src/main/java/com/todoroo/astrid/billing/Security.java diff --git a/astrid/proguard.cfg b/astrid/proguard.cfg index ec8622ee3..f532fdab1 100644 --- a/astrid/proguard.cfg +++ b/astrid/proguard.cfg @@ -41,6 +41,7 @@ -keep public class * extends android.content.ContentProvider -keep public class * extends android.app.backup.BackupAgentHelper -keep public class * extends android.preference.Preference +-keep public class com.android.vending.licensing.ILicensingService -keepclassmembers public class com.todoroo.astrid.data.* { *; diff --git a/astrid/src/main/AndroidManifest.xml b/astrid/src/main/AndroidManifest.xml index 8d37a4a92..57b3bdaa7 100644 --- a/astrid/src/main/AndroidManifest.xml +++ b/astrid/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ + android:versionCode="325"> @@ -94,6 +94,18 @@ android:hardwareAccelerated="false" android:manageSpaceActivity="com.todoroo.astrid.core.OldTaskPreferences"> + + + + + + + + + + + + diff --git a/astrid/src/main/aidl/com/android/vending/billing/IMarketBillingService.aidl b/astrid/src/main/aidl/com/android/vending/billing/IMarketBillingService.aidl new file mode 100644 index 000000000..6884b41f6 --- /dev/null +++ b/astrid/src/main/aidl/com/android/vending/billing/IMarketBillingService.aidl @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * 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; + +interface IMarketBillingService { + /** Given the arguments in bundle form, returns a bundle for results. */ + Bundle sendBillingRequest(in Bundle bundle); +} diff --git a/astrid/src/main/java/com/todoroo/astrid/activity/TaskListActivity.java b/astrid/src/main/java/com/todoroo/astrid/activity/TaskListActivity.java index f90465b28..a6051d549 100644 --- a/astrid/src/main/java/com/todoroo/astrid/activity/TaskListActivity.java +++ b/astrid/src/main/java/com/todoroo/astrid/activity/TaskListActivity.java @@ -30,6 +30,8 @@ import com.todoroo.astrid.actfm.TagViewFragment; import com.todoroo.astrid.api.AstridApiConstants; import com.todoroo.astrid.api.Filter; import com.todoroo.astrid.api.FilterListItem; +import com.todoroo.astrid.billing.BillingConstants; +import com.todoroo.astrid.billing.BillingService; import com.todoroo.astrid.core.CoreFilterExposer; import com.todoroo.astrid.core.CustomFilterActivity; import com.todoroo.astrid.core.PluginServices; @@ -73,6 +75,7 @@ public class TaskListActivity extends AstridActivity implements OnPageChangeList private int filterMode; private FilterModeSpec filterModeSpec; + private BillingService billingService; /** * @see android.app.Activity#onCreate(Bundle) @@ -137,6 +140,9 @@ public class TaskListActivity extends AstridActivity implements OnPageChangeList if (getIntent().hasExtra(TOKEN_SOURCE)) { trackActivitySource(); } + + billingService = new BillingService(); + billingService.setActivity(this); } @Override @@ -313,6 +319,7 @@ public class TaskListActivity extends AstridActivity implements OnPageChangeList @Override public boolean onPrepareOptionsMenu(Menu menu) { menuDrawer.closeMenu(); + menu.findItem(R.id.menu_donate).setVisible(billingService.showDonateOption()); return super.onPrepareOptionsMenu(menu); } @@ -539,6 +546,9 @@ public class TaskListActivity extends AstridActivity implements OnPageChangeList rename.putExtra(TagViewFragment.EXTRA_TAG_UUID, renameTag.uuid); startActivityForResult(rename, FilterListFragment.REQUEST_CUSTOM_INTENT); return true; + case R.id.menu_donate: + billingService.requestPurchase(BillingConstants.TASKS_DONATION_ITEM_ID, BillingConstants.ITEM_TYPE_INAPP, null); + return true; default: return super.onOptionsItemSelected(item); } @@ -573,4 +583,28 @@ public class TaskListActivity extends AstridActivity implements OnPageChangeList } return super.onKeyDown(keyCode, event); } + + @Override + protected void onStart() { + super.onStart(); + billingService.onStart(); + } + + @Override + protected void onStop() { + super.onStop(); + billingService.onStop(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + billingService.unbind(); + } + + @Override + protected void onResume() { + super.onResume(); + billingService.checkBillingSupported(); + } } diff --git a/astrid/src/main/java/com/todoroo/astrid/billing/AstridPurchaseObserver.java b/astrid/src/main/java/com/todoroo/astrid/billing/AstridPurchaseObserver.java new file mode 100644 index 000000000..39d7c62d7 --- /dev/null +++ b/astrid/src/main/java/com/todoroo/astrid/billing/AstridPurchaseObserver.java @@ -0,0 +1,72 @@ +package com.todoroo.astrid.billing; + +import android.app.Activity; +import android.os.Handler; +import android.util.Log; + +import com.todoroo.andlib.utility.Preferences; +import com.todoroo.astrid.actfm.sync.ActFmPreferenceService; +import com.todoroo.astrid.billing.BillingConstants.PurchaseState; +import com.todoroo.astrid.billing.BillingConstants.ResponseCode; +import com.todoroo.astrid.billing.BillingService.RequestPurchase; +import com.todoroo.astrid.billing.BillingService.RestoreTransactions; + +import static com.todoroo.andlib.utility.Preferences.getBoolean; +import static com.todoroo.andlib.utility.Preferences.getInt; +import static com.todoroo.andlib.utility.Preferences.getStringValue; + +public class AstridPurchaseObserver extends PurchaseObserver { + private static final String PREF_PRODUCT_ID = ActFmPreferenceService.IDENTIFIER + "_inapp_product_id"; + private static final String PREF_PURCHASE_STATE = ActFmPreferenceService.IDENTIFIER + "_inapp_purchase_state"; + private static final String PREF_TRANSACTIONS_INITIALIZED = "premium_transactions_initialized"; //$NON-NLS-1$ + + private boolean billingSupported; + private BillingService billingService; + + public AstridPurchaseObserver(Activity activity, BillingService billingService) { + super(activity, new Handler()); + this.billingService = billingService; + } + + public boolean isBillingSupported() { + return billingSupported; + } + + public boolean userDonated() { + return BillingConstants.TASKS_DONATION_ITEM_ID.equals(getStringValue(PREF_PRODUCT_ID)) && + getInt(PREF_PURCHASE_STATE, -1) == PurchaseState.PURCHASED.ordinal(); + } + + @Override + public void onBillingSupported(boolean supported, String type) { + Log.d(TAG, "onBillingSupported(" + supported + ", " + type + ")"); + if (BillingConstants.ITEM_TYPE_INAPP.equals(type)) { + billingSupported = supported; + if (supported && !getBoolean(PREF_TRANSACTIONS_INITIALIZED, false)) { + billingService.restoreTransactions(); + } + } + } + + @Override + public void onPurchaseStateChange(PurchaseState purchaseState, final String itemId) { + Log.d(TAG, "onPurchaseStateChange(" + purchaseState + ", " + itemId + ")"); + if (BillingConstants.TASKS_DONATION_ITEM_ID.equals(itemId)) { + Preferences.setString(PREF_PRODUCT_ID, itemId); + Preferences.setInt(PREF_PURCHASE_STATE, purchaseState.ordinal()); + } + } + + @Override + public void onRequestPurchaseResponse(RequestPurchase request, ResponseCode responseCode) { + Log.d(TAG, "onRequestPurchaseResponse(" + request + ", " + responseCode + ")"); + } + + @Override + public void onRestoreTransactionsResponse(RestoreTransactions request, ResponseCode responseCode) { + Log.d(TAG, "onRestoreTransactionsResponse(" + request + ", " + responseCode + ")"); + if (responseCode == ResponseCode.RESULT_OK) { + Preferences.setBoolean(PREF_TRANSACTIONS_INITIALIZED, true); + } + } +} diff --git a/astrid/src/main/java/com/todoroo/astrid/billing/BillingConstants.java b/astrid/src/main/java/com/todoroo/astrid/billing/BillingConstants.java new file mode 100644 index 000000000..caead1ddc --- /dev/null +++ b/astrid/src/main/java/com/todoroo/astrid/billing/BillingConstants.java @@ -0,0 +1,87 @@ +package com.todoroo.astrid.billing; + +@SuppressWarnings("nls") +public class BillingConstants { + + /** This is the action we use to bind to the MarketBillingService. */ + public static final String MARKET_BILLING_SERVICE_ACTION = "com.android.vending.billing.MarketBillingService.BIND"; + + // Intent actions that we send from the BillingReceiver to the + // BillingService. Defined by this application. + public static final String ACTION_CONFIRM_NOTIFICATION = "com.timsu.astrid.subscriptions.CONFIRM_NOTIFICATION"; + public static final String ACTION_GET_PURCHASE_INFORMATION = "com.timsu.astrid.subscriptions.GET_PURCHASE_INFORMATION"; + + // Intent actions that we receive in the BillingReceiver from Market. + // These are defined by Market and cannot be changed. + public static final String ACTION_NOTIFY = "com.android.vending.billing.IN_APP_NOTIFY"; + public static final String ACTION_RESPONSE_CODE = "com.android.vending.billing.RESPONSE_CODE"; + public static final String ACTION_PURCHASE_STATE_CHANGED = "com.android.vending.billing.PURCHASE_STATE_CHANGED"; + + // These are the names of the extras that are passed in an intent from + // Market to this application and cannot be changed. + public static final String NOTIFICATION_ID = "notification_id"; + public static final String INAPP_SIGNED_DATA = "inapp_signed_data"; + public static final String INAPP_SIGNATURE = "inapp_signature"; + public static final String INAPP_REQUEST_ID = "request_id"; + public static final String INAPP_RESPONSE_CODE = "response_code"; + + // These are the names of the fields in the request bundle. + public static final String BILLING_REQUEST_METHOD = "BILLING_REQUEST"; + public static final String BILLING_REQUEST_API_VERSION = "API_VERSION"; + public static final String BILLING_REQUEST_PACKAGE_NAME = "PACKAGE_NAME"; + public static final String BILLING_REQUEST_ITEM_ID = "ITEM_ID"; + public static final String BILLING_REQUEST_ITEM_TYPE = "ITEM_TYPE"; + public static final String BILLING_REQUEST_DEVELOPER_PAYLOAD = "DEVELOPER_PAYLOAD"; + public static final String BILLING_REQUEST_NOTIFY_IDS = "NOTIFY_IDS"; + public static final String BILLING_REQUEST_NONCE = "NONCE"; + + public static final String BILLING_RESPONSE_RESPONSE_CODE = "RESPONSE_CODE"; + public static final String BILLING_RESPONSE_PURCHASE_INTENT = "PURCHASE_INTENT"; + public static final String BILLING_RESPONSE_REQUEST_ID = "REQUEST_ID"; + public static final long BILLING_RESPONSE_INVALID_REQUEST_ID = -1; + + // These are the types supported in the IAB v2 + public static final String ITEM_TYPE_INAPP = "inapp"; + + public static final String TASKS_DONATION_ITEM_ID = "tasks_donation_4_6"; + + public static final boolean DEBUG = true; + + // The response codes for a request, defined by Android Market. + public enum ResponseCode { + RESULT_OK, + RESULT_USER_CANCELED, + RESULT_SERVICE_UNAVAILABLE, + RESULT_BILLING_UNAVAILABLE, + RESULT_ITEM_UNAVAILABLE, + RESULT_DEVELOPER_ERROR, + RESULT_ERROR; + + // Converts from an ordinal value to the ResponseCode + public static ResponseCode valueOf(int index) { + ResponseCode[] values = ResponseCode.values(); + if (index < 0 || index >= values.length) { + return RESULT_ERROR; + } + return values[index]; + } + } + + // The possible states of an in-app purchase, as defined by Android Market. + public enum PurchaseState { + // Responses to requestPurchase or restoreTransactions. + PURCHASED, // User was charged for the order. + CANCELED, // The charge failed on the server. (NOT THE SAME AS CANCELING A SUBSCRIPTION) + REFUNDED, // User received a refund for the order. + EXPIRED; // Subscription expired due to non-payment or cancellation + + // Converts from an ordinal value to the PurchaseState + public static PurchaseState valueOf(int index) { + PurchaseState[] values = PurchaseState.values(); + if (index < 0 || index >= values.length) { + return CANCELED; + } + return values[index]; + } + } +} diff --git a/astrid/src/main/java/com/todoroo/astrid/billing/BillingReceiver.java b/astrid/src/main/java/com/todoroo/astrid/billing/BillingReceiver.java new file mode 100644 index 000000000..7c502759d --- /dev/null +++ b/astrid/src/main/java/com/todoroo/astrid/billing/BillingReceiver.java @@ -0,0 +1,61 @@ +package com.todoroo.astrid.billing; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.todoroo.astrid.billing.BillingConstants.ResponseCode; + +public class BillingReceiver extends BroadcastReceiver { + private static final String TAG = "BillingReceiver"; //$NON-NLS-1$ + + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + switch (action) { + case BillingConstants.ACTION_PURCHASE_STATE_CHANGED: + String signedData = intent.getStringExtra(BillingConstants.INAPP_SIGNED_DATA); + String signature = intent.getStringExtra(BillingConstants.INAPP_SIGNATURE); + purchaseStateChanged(context, signedData, signature); + break; + case BillingConstants.ACTION_NOTIFY: + String notifyId = intent.getStringExtra(BillingConstants.NOTIFICATION_ID); + Log.i(TAG, "notifyId: " + notifyId); //$NON-NLS-1$ + notify(context, notifyId); + break; + case BillingConstants.ACTION_RESPONSE_CODE: + long requestId = intent.getLongExtra(BillingConstants.INAPP_REQUEST_ID, -1); + int responseCodeIndex = intent.getIntExtra(BillingConstants.INAPP_RESPONSE_CODE, + ResponseCode.RESULT_ERROR.ordinal()); + checkResponseCode(context, requestId, responseCodeIndex); + break; + default: + Log.w(TAG, "unexpected action: " + action); //$NON-NLS-1$ + break; + } + } + + private void purchaseStateChanged(Context context, String signedData, String signature) { + Intent intent = new Intent(BillingConstants.ACTION_PURCHASE_STATE_CHANGED); + intent.setClass(context, BillingService.class); + intent.putExtra(BillingConstants.INAPP_SIGNED_DATA, signedData); + intent.putExtra(BillingConstants.INAPP_SIGNATURE, signature); + context.startService(intent); + } + + private void notify(Context context, String notifyId) { + Intent intent = new Intent(BillingConstants.ACTION_GET_PURCHASE_INFORMATION); + intent.setClass(context, BillingService.class); + intent.putExtra(BillingConstants.NOTIFICATION_ID, notifyId); + context.startService(intent); + } + + private void checkResponseCode(Context context, long requestId, int responseCodeIndex) { + Intent intent = new Intent(BillingConstants.ACTION_RESPONSE_CODE); + intent.setClass(context, BillingService.class); + intent.putExtra(BillingConstants.INAPP_REQUEST_ID, requestId); + intent.putExtra(BillingConstants.INAPP_RESPONSE_CODE, responseCodeIndex); + context.startService(intent); + } +} diff --git a/astrid/src/main/java/com/todoroo/astrid/billing/BillingService.java b/astrid/src/main/java/com/todoroo/astrid/billing/BillingService.java new file mode 100644 index 000000000..48baf7662 --- /dev/null +++ b/astrid/src/main/java/com/todoroo/astrid/billing/BillingService.java @@ -0,0 +1,534 @@ +package com.todoroo.astrid.billing; + +import android.app.Activity; +import android.app.PendingIntent; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; + +import com.android.vending.billing.IMarketBillingService; +import com.todoroo.astrid.billing.BillingConstants.ResponseCode; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; + +import static com.todoroo.astrid.billing.Security.Purchase; + +@SuppressWarnings("nls") +public class BillingService extends Service implements ServiceConnection { + private static final String TAG = "BillingService"; + + private static IMarketBillingService marketBillingService; + + private static LinkedList pendingRequests = new LinkedList<>(); + + private static HashMap sentRequests = new HashMap<>(); + + private AstridPurchaseObserver purchaseObserver; + + public boolean showDonateOption() { + return purchaseObserver.isBillingSupported() && !purchaseObserver.userDonated(); + } + + abstract class BillingRequest { + private final int mStartId; + protected long mRequestId; + + public BillingRequest(int startId) { + mStartId = startId; + } + + public int getStartId() { + return mStartId; + } + + public boolean runRequest() { + if (runIfConnected()) { + return true; + } + + if (bindToMarketBillingService()) { + pendingRequests.add(this); + return true; + } + return false; + } + + /** + * Try running the request directly if the service is already connected. + * + * @return true if the request ran successfully; false if the service + * is not connected or there was an error when trying to use it + */ + public boolean runIfConnected() { + if (BillingConstants.DEBUG) { + Log.d(TAG, getClass().getSimpleName()); + } + if (marketBillingService != null) { + try { + mRequestId = run(); + if (BillingConstants.DEBUG) { + Log.d(TAG, "request id: " + mRequestId); + } + if (mRequestId >= 0) { + sentRequests.put(mRequestId, this); + } + return true; + } catch (RemoteException e) { + onRemoteException(e); + } + } + return false; + } + + /** + * Called when a remote exception occurs while trying to execute the + * {@link #run()} method. The derived class can override this to + * execute exception-handling code. + * + * @param e the exception + */ + protected void onRemoteException(RemoteException e) { + Log.w(TAG, "remote billing service crashed"); + marketBillingService = null; + } + + /** + * The derived class must implement this method. + * + * @throws android.os.RemoteException + */ + abstract protected long run() throws RemoteException; + + /** + * This is called when Android Market sends a response code for this + * request. + * + * @param responseCode the response code + */ + protected void responseCodeReceived(ResponseCode responseCode) { + // + } + + protected Bundle makeRequestBundle(String method) { + Bundle request = new Bundle(); + request.putString(BillingConstants.BILLING_REQUEST_METHOD, method); + request.putInt(BillingConstants.BILLING_REQUEST_API_VERSION, 2); + request.putString(BillingConstants.BILLING_REQUEST_PACKAGE_NAME, getPackageName()); + return request; + } + + protected void logResponseCode(String method, Bundle response) { + ResponseCode responseCode = ResponseCode.valueOf( + response.getInt(BillingConstants.BILLING_RESPONSE_RESPONSE_CODE)); + if (BillingConstants.DEBUG) { + Log.e(TAG, method + " received " + responseCode.toString()); + } + } + } + + class CheckBillingSupported extends BillingRequest { + public String mProductType = null; + + /** + * Constructor + *

+ * Note: Support for subscriptions implies support for one-time purchases. However, the + * opposite is not true. + *

+ * Developers may want to perform two checks if both one-time and subscription products are + * available. + * + * @param itemType Either BillingConstants.ITEM_TYPE_INAPP or BillingConstants.ITEM_TYPE_SUBSCRIPTION, indicating + * the type of item support is being checked for. + */ + public CheckBillingSupported(String itemType) { + super(-1); + mProductType = itemType; + } + + @Override + protected long run() throws RemoteException { + Bundle request = makeRequestBundle("CHECK_BILLING_SUPPORTED"); + if (mProductType != null) { + request.putString(BillingConstants.BILLING_REQUEST_ITEM_TYPE, mProductType); + } + Bundle response = marketBillingService.sendBillingRequest(request); + int responseCode = response.getInt(BillingConstants.BILLING_RESPONSE_RESPONSE_CODE); + if (BillingConstants.DEBUG) { + Log.i(TAG, "CheckBillingSupported response code: " + + ResponseCode.valueOf(responseCode)); + } + boolean billingSupported = (responseCode == ResponseCode.RESULT_OK.ordinal()); + ResponseHandler.checkBillingSupportedResponse(billingSupported, mProductType); + return BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID; + } + } + + class RequestPurchase extends BillingRequest { + public final String mProductId; + public final String mDeveloperPayload; + public final String mProductType; + + /** + * Constructor + * + * @param itemId The ID of the item to be purchased. Will be assumed to be a one-time + * purchase. + * @param itemType Either BillingConstants.ITEM_TYPE_INAPP or BillingConstants.ITEM_TYPE_SUBSCRIPTION, + * indicating the type of item type support is being checked for. + * @param developerPayload Optional data. + */ + public RequestPurchase(String itemId, String itemType, String developerPayload) { + // This object is never created as a side effect of starting this + // service so we pass -1 as the startId to indicate that we should + // not stop this service after executing this request. + super(-1); + mProductId = itemId; + mDeveloperPayload = developerPayload; + mProductType = itemType; + } + + @Override + protected long run() throws RemoteException { + Bundle request = makeRequestBundle("REQUEST_PURCHASE"); + request.putString(BillingConstants.BILLING_REQUEST_ITEM_ID, mProductId); + request.putString(BillingConstants.BILLING_REQUEST_ITEM_TYPE, mProductType); + // Note that the developer payload is optional. + if (mDeveloperPayload != null) { + request.putString(BillingConstants.BILLING_REQUEST_DEVELOPER_PAYLOAD, mDeveloperPayload); + } + Bundle response = marketBillingService.sendBillingRequest(request); + PendingIntent pendingIntent + = response.getParcelable(BillingConstants.BILLING_RESPONSE_PURCHASE_INTENT); + if (pendingIntent == null) { + Log.e(TAG, "Error with requestPurchase"); + return BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID; + } + + Intent intent = new Intent(); + ResponseHandler.buyPageIntentResponse(pendingIntent, intent); + return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID, + BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID); + } + + @Override + protected void responseCodeReceived(ResponseCode responseCode) { + ResponseHandler.responseCodeReceived(this, responseCode); + } + } + + class ConfirmNotifications extends BillingRequest { + final String[] mNotifyIds; + + public ConfirmNotifications(int startId, String[] notifyIds) { + super(startId); + mNotifyIds = notifyIds; + } + + @Override + protected long run() throws RemoteException { + Bundle request = makeRequestBundle("CONFIRM_NOTIFICATIONS"); + request.putStringArray(BillingConstants.BILLING_REQUEST_NOTIFY_IDS, mNotifyIds); + Bundle response = marketBillingService.sendBillingRequest(request); + logResponseCode("confirmNotifications", response); + return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID, + BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID); + } + } + + class GetPurchaseInformation extends BillingRequest { + long mNonce; + final String[] mNotifyIds; + + public GetPurchaseInformation(int startId, String[] notifyIds) { + super(startId); + mNotifyIds = notifyIds; + } + + @Override + protected long run() throws RemoteException { + mNonce = Security.generateNonce(); + + Bundle request = makeRequestBundle("GET_PURCHASE_INFORMATION"); + request.putLong(BillingConstants.BILLING_REQUEST_NONCE, mNonce); + request.putStringArray(BillingConstants.BILLING_REQUEST_NOTIFY_IDS, mNotifyIds); + Bundle response = marketBillingService.sendBillingRequest(request); + logResponseCode("getPurchaseInformation", response); + return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID, + BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID); + } + } + + class RestoreTransactions extends BillingRequest { + long mNonce; + + public RestoreTransactions() { + // This object is never created as a side effect of starting this + // service so we pass -1 as the startId to indicate that we should + // not stop this service after executing this request. + super(-1); + } + + @Override + protected long run() throws RemoteException { + mNonce = Security.generateNonce(); + + Bundle request = makeRequestBundle("RESTORE_TRANSACTIONS"); + request.putLong(BillingConstants.BILLING_REQUEST_NONCE, mNonce); + Bundle response = marketBillingService.sendBillingRequest(request); + logResponseCode("restoreTransactions", response); + return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID, + BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID); + } + + @Override + protected void responseCodeReceived(ResponseCode responseCode) { + ResponseHandler.responseCodeReceived(this, responseCode); + } + } + + public void setActivity(Activity activity) { + attachBaseContext(activity); + purchaseObserver = new AstridPurchaseObserver(activity, this); + } + + @Override + public IBinder onBind(Intent intent) { + return null; // binding not supported for this service + } + + @Override + public void onStart(Intent intent, int startId) { + handleCommand(intent, startId); + } + + /** + * The {@link BillingReceiver} sends messages to this service using intents. + * Each intent has an action and some extra arguments specific to that action. + * + * @param intent the intent containing one of the supported actions + * @param startId an identifier for the invocation instance of this service + */ + private void handleCommand(Intent intent, int startId) { + if (intent == null) { + return; + } + String action = intent.getAction(); + Log.d(TAG, "handleCommand(" + action + ")"); + switch (action) { + case BillingConstants.ACTION_CONFIRM_NOTIFICATION: + String[] notifyIds = intent.getStringArrayExtra(BillingConstants.NOTIFICATION_ID); + confirmNotifications(startId, notifyIds); + break; + case BillingConstants.ACTION_GET_PURCHASE_INFORMATION: + String notifyId = intent.getStringExtra(BillingConstants.NOTIFICATION_ID); + getPurchaseInformation(startId, new String[]{notifyId}); + break; + case BillingConstants.ACTION_PURCHASE_STATE_CHANGED: + String signedData = intent.getStringExtra(BillingConstants.INAPP_SIGNED_DATA); + purchaseStateChanged(startId, signedData); + break; + case BillingConstants.ACTION_RESPONSE_CODE: + long requestId = intent.getLongExtra(BillingConstants.INAPP_REQUEST_ID, -1); + int responseCodeIndex = intent.getIntExtra(BillingConstants.INAPP_RESPONSE_CODE, + ResponseCode.RESULT_ERROR.ordinal()); + ResponseCode responseCode = ResponseCode.valueOf(responseCodeIndex); + checkResponseCode(requestId, responseCode); + break; + } + } + + private boolean bindToMarketBillingService() { + Log.d(TAG, "bindToMarketBillingService()"); + try { + boolean bindResult = bindService( + new Intent(BillingConstants.MARKET_BILLING_SERVICE_ACTION), + this, + Context.BIND_AUTO_CREATE); + + if (bindResult) { + return true; + } else { + Log.e(TAG, "Could not bind to service."); + } + } catch (SecurityException e) { + Log.e(TAG, "Security exception: " + e); + } + return false; + } + + public boolean checkBillingSupported() { + return new CheckBillingSupported(BillingConstants.ITEM_TYPE_INAPP).runRequest(); + } + + /** + * Requests that the given item be offered to the user for purchase. When + * the purchase succeeds (or is canceled) the {@link BillingReceiver} + * receives an intent with the action {@link BillingConstants#ACTION_NOTIFY}. + * Returns false if there was an error trying to connect to Android Market. + * + * @param productId an identifier for the item being offered for purchase + * @param itemType Either BillingConstants.ITEM_TYPE_INAPP or BillingConstants.ITEM_TYPE_SUBSCRIPTION, indicating + * the type of item type support is being checked for. + * @param developerPayload a payload that is associated with a given + * purchase, if null, no payload is sent + * @return false if there was an error connecting to Android Market + */ + public boolean requestPurchase(String productId, String itemType, String developerPayload) { + return new RequestPurchase(productId, itemType, developerPayload).runRequest(); + } + + /** + * Requests transaction information for all managed items. Call this only when the + * application is first installed or after a database wipe. Do NOT call this + * every time the application starts up. + * + * @return false if there was an error connecting to Android Market + */ + public boolean restoreTransactions() { + return new RestoreTransactions().runRequest(); + } + + /** + * Confirms receipt of a purchase state change. Each {@code notifyId} is + * an opaque identifier that came from the server. This method sends those + * identifiers back to the MarketBillingService, which ACKs them to the + * server. Returns false if there was an error trying to connect to the + * MarketBillingService. + * + * @param startId an identifier for the invocation instance of this service + * @param notifyIds a list of opaque identifiers associated with purchase + * state changes. + * @return false if there was an error connecting to Market + */ + private boolean confirmNotifications(int startId, String[] notifyIds) { + return new ConfirmNotifications(startId, notifyIds).runRequest(); + } + + /** + * Gets the purchase information. This message includes a list of + * notification IDs sent to us by Android Market, which we include in + * our request. The server responds with the purchase information, + * encoded as a JSON string, and sends that to the {@link BillingReceiver} + * in an intent with the action {@link BillingConstants#ACTION_PURCHASE_STATE_CHANGED}. + * Returns false if there was an error trying to connect to the MarketBillingService. + * + * @param startId an identifier for the invocation instance of this service + * @param notifyIds a list of opaque identifiers associated with purchase + * state changes + * @return false if there was an error connecting to Android Market + */ + private boolean getPurchaseInformation(int startId, String[] notifyIds) { + return new GetPurchaseInformation(startId, notifyIds).runRequest(); + } + + /** + * Verifies that the data was signed with the given signature, and calls + * ResponseHandler.purchaseResponse(android.content.Context, PurchaseState, String, String, long) + * for each verified purchase. + * + * @param startId an identifier for the invocation instance of this service + * @param signedData the signed JSON string (signed, not encrypted) + */ + private void purchaseStateChanged(int startId, String signedData) { + ArrayList purchases; + purchases = Security.parse(signedData); + ArrayList notifyList = new ArrayList<>(); + for (Purchase vp : purchases) { + if (vp.notificationId != null) { + notifyList.add(vp.notificationId); + } + ResponseHandler.purchaseResponse(vp.purchaseState, vp.productId); + } + if (!notifyList.isEmpty()) { + String[] notifyIds = notifyList.toArray(new String[notifyList.size()]); + confirmNotifications(startId, notifyIds); + } + } + + private void checkResponseCode(long requestId, ResponseCode responseCode) { + BillingRequest request = sentRequests.get(requestId); + if (request != null) { + Log.d(TAG, request.getClass().getSimpleName() + ": " + responseCode); + request.responseCodeReceived(responseCode); + } + sentRequests.remove(requestId); + } + + /** + * Runs any pending requests that are waiting for a connection to the + * service to be established. This runs in the main UI thread. + */ + private void runPendingRequests() { + int maxStartId = -1; + BillingRequest request; + while ((request = pendingRequests.peek()) != null) { + if (request.runIfConnected()) { + // Remove the request + pendingRequests.remove(); + + // Remember the largest startId, which is the most recent + // request to start this service. + if (maxStartId < request.getStartId()) { + maxStartId = request.getStartId(); + } + } else { + // The service crashed, so restart it. Note that this leaves + // the current request on the queue. + bindToMarketBillingService(); + return; + } + } + + // If we get here then all the requests ran successfully. If maxStartId + // is not -1, then one of the requests started the service, so we can + // stop it now. + if (maxStartId >= 0) { + if (BillingConstants.DEBUG) { + Log.i(TAG, "stopping service, startId: " + maxStartId); + } + stopSelf(maxStartId); + } + } + + /** + * This is called when we are connected to the MarketBillingService. + * This runs in the main UI thread. + */ + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + Log.d(TAG, "Billing service connected"); + marketBillingService = IMarketBillingService.Stub.asInterface(service); + runPendingRequests(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + Log.w(TAG, "Billing service disconnected"); + marketBillingService = null; + } + + public void unbind() { + try { + unbindService(this); + } catch (IllegalArgumentException e) { + // This might happen if the service was disconnected + } + } + + public void onStart() { + ResponseHandler.register(purchaseObserver); + } + + public void onStop() { + ResponseHandler.unregister(); + } +} diff --git a/astrid/src/main/java/com/todoroo/astrid/billing/PurchaseObserver.java b/astrid/src/main/java/com/todoroo/astrid/billing/PurchaseObserver.java new file mode 100644 index 000000000..8723a71ba --- /dev/null +++ b/astrid/src/main/java/com/todoroo/astrid/billing/PurchaseObserver.java @@ -0,0 +1,102 @@ +// Copyright 2010 Google Inc. All Rights Reserved. + +package com.todoroo.astrid.billing; + +import android.app.Activity; +import android.app.PendingIntent; +import android.content.Intent; +import android.content.IntentSender; +import android.os.Handler; +import android.util.Log; + +import com.todoroo.astrid.billing.BillingConstants.PurchaseState; +import com.todoroo.astrid.billing.BillingConstants.ResponseCode; +import com.todoroo.astrid.billing.BillingService.RequestPurchase; +import com.todoroo.astrid.billing.BillingService.RestoreTransactions; + +import java.lang.reflect.Method; + +public abstract class PurchaseObserver { + protected static final String TAG = "purchase-observer"; //$NON-NLS-1$ + protected final Activity mActivity; + private final Handler mHandler; + private Method mStartIntentSender; + private final Object[] mStartIntentSenderArgs = new Object[5]; + private static final Class[] START_INTENT_SENDER_SIG = new Class[] { + IntentSender.class, Intent.class, int.class, int.class, int.class + }; + + public PurchaseObserver(Activity activity, Handler handler) { + mActivity = activity; + mHandler = handler; + initCompatibilityLayer(); + } + + public abstract void onBillingSupported(boolean supported, String type); + + public abstract void onPurchaseStateChange(PurchaseState purchaseState, String itemId); + + /** + * This is called when we receive a response code from Market for a + * RequestPurchase request that we made. This is NOT used for any + * purchase state changes. All purchase state changes are received in + * onPurchaseStateChange(PurchaseState, String, int, long). + * This is used for reporting various errors, or if the user backed out + * and didn't purchase the item. The possible response codes are: + * RESULT_OK means that the order was sent successfully to the server. + * The onPurchaseStateChange() will be invoked later (with a + * purchase state of PURCHASED or CANCELED) when the order is + * charged or canceled. This response code can also happen if an + * order for a Market-managed item was already sent to the server. + * RESULT_USER_CANCELED means that the user didn't buy the item. + * RESULT_SERVICE_UNAVAILABLE means that we couldn't connect to the + * Android Market server (for example if the data connection is down). + * RESULT_BILLING_UNAVAILABLE means that in-app billing is not + * supported yet. + * RESULT_ITEM_UNAVAILABLE means that the item this app offered for + * sale does not exist (or is not published) in the server-side + * catalog. + * RESULT_ERROR is used for any other errors (such as a server error). + */ + public abstract void onRequestPurchaseResponse(RequestPurchase request, + ResponseCode responseCode); + + /** + * This is called when we receive a response code from Android Market for a + * RestoreTransactions request that we made. A response code of + * RESULT_OK means that the request was successfully sent to the server. + */ + public abstract void onRestoreTransactionsResponse(RestoreTransactions request, + ResponseCode responseCode); + + private void initCompatibilityLayer() { + try { + mStartIntentSender = mActivity.getClass().getMethod("startIntentSender", //$NON-NLS-1$ + START_INTENT_SENDER_SIG); + } catch (SecurityException | NoSuchMethodException e) { + mStartIntentSender = null; + } + } + + void startBuyPageActivity(PendingIntent pendingIntent, Intent intent) { + try { + mStartIntentSenderArgs[0] = pendingIntent.getIntentSender(); + mStartIntentSenderArgs[1] = intent; + mStartIntentSenderArgs[2] = 0; + mStartIntentSenderArgs[3] = 0; + mStartIntentSenderArgs[4] = 0; + mStartIntentSender.invoke(mActivity, mStartIntentSenderArgs); + } catch (Exception e) { + Log.e(TAG, "error starting activity", e); //$NON-NLS-1$ + } + } + + void postPurchaseStateChange(final PurchaseState purchaseState, final String itemId) { + mHandler.post(new Runnable() { + @Override + public void run() { + onPurchaseStateChange(purchaseState, itemId); + } + }); + } +} diff --git a/astrid/src/main/java/com/todoroo/astrid/billing/ResponseHandler.java b/astrid/src/main/java/com/todoroo/astrid/billing/ResponseHandler.java new file mode 100644 index 000000000..7667956a6 --- /dev/null +++ b/astrid/src/main/java/com/todoroo/astrid/billing/ResponseHandler.java @@ -0,0 +1,61 @@ +package com.todoroo.astrid.billing; + +import android.app.PendingIntent; +import android.content.Intent; + +import com.todoroo.astrid.billing.BillingConstants.PurchaseState; +import com.todoroo.astrid.billing.BillingConstants.ResponseCode; +import com.todoroo.astrid.billing.BillingService.RequestPurchase; +import com.todoroo.astrid.billing.BillingService.RestoreTransactions; + +public class ResponseHandler { + + private static PurchaseObserver sPurchaseObserver; + + public static synchronized void register(PurchaseObserver observer) { + sPurchaseObserver = observer; + } + + public static synchronized void unregister() { + sPurchaseObserver = null; + } + + public static void checkBillingSupportedResponse(boolean supported, String type) { + if (sPurchaseObserver != null) { + sPurchaseObserver.onBillingSupported(supported, type); + } + } + + public static void buyPageIntentResponse(PendingIntent pendingIntent, Intent intent) { + if (sPurchaseObserver != null) { + sPurchaseObserver.startBuyPageActivity(pendingIntent, intent); + } + } + + public static void purchaseResponse(final PurchaseState purchaseState, final String productId) { + new Thread(new Runnable() { + @Override + public void run() { + // This needs to be synchronized because the UI thread can change the + // value of sPurchaseObserver. + synchronized (ResponseHandler.class) { + if (sPurchaseObserver != null) { + sPurchaseObserver.postPurchaseStateChange(purchaseState, productId); + } + } + } + }).start(); + } + + public static void responseCodeReceived(RequestPurchase request, ResponseCode responseCode) { + if (sPurchaseObserver != null) { + sPurchaseObserver.onRequestPurchaseResponse(request, responseCode); + } + } + + public static void responseCodeReceived(RestoreTransactions request, ResponseCode responseCode) { + if (sPurchaseObserver != null) { + sPurchaseObserver.onRestoreTransactionsResponse(request, responseCode); + } + } +} diff --git a/astrid/src/main/java/com/todoroo/astrid/billing/Security.java b/astrid/src/main/java/com/todoroo/astrid/billing/Security.java new file mode 100644 index 000000000..4593243bc --- /dev/null +++ b/astrid/src/main/java/com/todoroo/astrid/billing/Security.java @@ -0,0 +1,45 @@ +package com.todoroo.astrid.billing; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.todoroo.astrid.billing.BillingConstants.PurchaseState; + +import java.security.SecureRandom; +import java.util.ArrayList; + +public class Security { + private static final SecureRandom RANDOM = new SecureRandom(); + + public static class Purchase { + public PurchaseState purchaseState; + public String notificationId; + public String productId; + + public Purchase(PurchaseState purchaseState, String notificationId, String productId) { + this.purchaseState = purchaseState; + this.notificationId = notificationId; + this.productId = productId; + } + } + + public static long generateNonce() { + return RANDOM.nextLong(); + } + + public static ArrayList parse(String signedData) { + ArrayList purchases = new ArrayList<>(); + JsonElement jsonElement = new JsonParser().parse(signedData); + JsonObject jsonObject = jsonElement.getAsJsonObject(); + JsonArray orders = jsonObject.getAsJsonArray("orders"); + for (JsonElement orderElement : orders) { + JsonObject orderObject = orderElement.getAsJsonObject(); + purchases.add(new Purchase( + PurchaseState.valueOf(orderObject.get("purchaseState").getAsInt()), + orderObject.has("notificationId") ? orderObject.get("notificationId").getAsString() : null, + orderObject.get("productId").getAsString())); + } + return purchases; + } +} diff --git a/astrid/src/main/res/menu/task_list_activity.xml b/astrid/src/main/res/menu/task_list_activity.xml index 4578ca028..ed0662fa3 100644 --- a/astrid/src/main/res/menu/task_list_activity.xml +++ b/astrid/src/main/res/menu/task_list_activity.xml @@ -39,6 +39,11 @@ android:id="@+id/menu_new_filter" android:title="@string/FLA_new_filter" tasks:showAsAction="never" /> + Dark theme Dark widget theme Delete task + Donate \ No newline at end of file