package com.ianhanniballake.contractiontimer.ui; 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.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.os.RemoteException; import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.v7.app.AppCompatActivity; import android.text.TextUtils; import android.util.Log; import android.view.MenuItem; import android.view.View; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.RelativeLayout; import android.widget.Spinner; import android.widget.Toast; import com.android.vending.billing.IInAppBillingService; import com.google.firebase.analytics.FirebaseAnalytics; import com.google.firebase.crash.FirebaseCrash; import com.ianhanniballake.contractiontimer.BuildConfig; import com.ianhanniballake.contractiontimer.R; import com.ianhanniballake.contractiontimer.inappbilling.Inventory; import com.ianhanniballake.contractiontimer.inappbilling.Purchase; import com.ianhanniballake.contractiontimer.inappbilling.Security; import com.ianhanniballake.contractiontimer.inappbilling.SkuDetails; import org.json.JSONException; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; /** * Activity controlling donations, including Paypal and In-App Billing */ public class DonateActivity extends AppCompatActivity { private final static String TAG = DonateActivity.class.getSimpleName(); private final static String ITEM_TYPE_INAPP = "inapp"; private final static String publicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApmwDry4kZ8n3DulD1UxcJ89+TRI/DGSvFbhtjNkO1yWki16Q3MzOHwZ4Opyykn3cfiuexMNQYWZfQBqrvkdWWXf+iwBmG6PlOPzgYHV/0ohQhADCUb71SPihmf2WX2zejyNt71sMMUuIklB9HgXukO2uspdWYjKy8CkaMSHK+pQZdG2reACtLjgLMIm1tOlU2C7kGbsL+xodGyh29bO/6cn1/IPrnLZVgAfMm3UDGrqrK2PlgRlLZsoVQKvdi2vbQ8e4LH90rYlXrqEHHgRQw4ozXsj0QmaUx2b2EzRu4q17yvKvhmlFzZSShCkAJgPCOLds0A2SBbOAAX15lB8RmQIDAQAB"; private final static String PURCHASED_SKU = "com.ianhanniballake.contractiontimer.PURCHASED_SKU"; private final static int RC_REQUEST = 1; private static final String RESPONSE_CODE = "RESPONSE_CODE"; private static final String RESPONSE_GET_SKU_DETAILS_LIST = "DETAILS_LIST"; private static final String RESPONSE_INAPP_ITEM_LIST = "INAPP_PURCHASE_ITEM_LIST"; private static final String RESPONSE_INAPP_PURCHASE_DATA_LIST = "INAPP_PURCHASE_DATA_LIST"; private static final String RESPONSE_INAPP_SIGNATURE_LIST = "INAPP_DATA_SIGNATURE_LIST"; /** * SKU Product Names */ final HashMap<String, String> skuNames = new HashMap<>(); /** * US Prices for SKUs in micro-currency */ final HashMap<String, Long> skuPrices = new HashMap<>(); /** * InAppBillingService connection */ IInAppBillingService mService; /** * Recently purchased SKU, if any. Should be saved in the instance state */ String purchasedSku = ""; /** * List of valid SKUs */ String[] skus = new String[0]; private ServiceConnection mServiceConn; /** * Gets the response code from the given Bundle. Workaround to bug where sometimes response codes come as Long * instead of Integer * * @param b Bundle to get response code * @return response code */ static int getResponseCodeFromBundle(final Bundle b) { final Object o = b.get(RESPONSE_CODE); if (o == null) return 0; else if (o instanceof Integer) return (Integer) o; else if (o instanceof Long) return ((Long) o).intValue(); else throw new RuntimeException("Unexpected type for bundle response code: " + o.getClass().getName()); } /** * Gets the response code from the given Intent. Workaround to bug where sometimes response codes come as Long * instead of Integer * * @param i Intent to get response code * @return response code */ static int getResponseCodeFromIntent(final Intent i) { final Object o = i.getExtras() == null ? null : i.getExtras().get(RESPONSE_CODE); if (o == null) return 0; else if (o instanceof Integer) return (Integer) o; else if (o instanceof Long) return ((Long) o).intValue(); else throw new RuntimeException("Unexpected type for intent response code: " + o.getClass().getName()); } @Override protected void onActivityResult(final int requestCode, final int resultCode, final Intent data) { if (BuildConfig.DEBUG) Log.d(TAG, "onActivityResult(" + requestCode + "," + resultCode + "," + data + ")"); if (requestCode != RC_REQUEST) { super.onActivityResult(requestCode, resultCode, data); return; } if (data == null) { FirebaseCrash.logcat(Log.ERROR, TAG, "Purchase: null intent"); return; } final int responseCode = getResponseCodeFromIntent(data); final String purchaseData = data.getStringExtra("INAPP_PURCHASE_DATA"); final String dataSignature = data.getStringExtra("INAPP_DATA_SIGNATURE"); if (resultCode == Activity.RESULT_OK && responseCode == 0) { if (purchaseData == null || dataSignature == null) { FirebaseCrash.logcat(Log.ERROR, TAG, "Purchase: Invalid data fields"); return; } Purchase purchase; try { purchase = new Purchase(ITEM_TYPE_INAPP, purchaseData, dataSignature); final String sku = purchase.getSku(); // Verify signature if (!Security.verifyPurchase(publicKey, purchaseData, dataSignature)) { FirebaseCrash.logcat(Log.ERROR, TAG, "Purchase: Signature verification failed " + sku); return; } } catch (final JSONException e) { FirebaseCrash.logcat(Log.ERROR, TAG, "Purchase: Parsing error"); FirebaseCrash.report(e); return; } new ConsumeAsyncTask(mService, true).execute(purchase); } else if (resultCode == Activity.RESULT_OK) { FirebaseCrash.logcat(Log.ERROR, TAG, "Purchase: bad response " + responseCode); } else if (resultCode == Activity.RESULT_CANCELED) { if (BuildConfig.DEBUG) Log.d(TAG, "Purchase: canceled"); } else { FirebaseCrash.logcat(Log.ERROR, TAG, "Purchase: Unknown response"); } } @Override protected void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Set up SKUs final ArrayList<String> allSkus = new ArrayList<>(); if (BuildConfig.DEBUG) { allSkus.add("android.test.purchased"); allSkus.add("android.test.canceled"); allSkus.add("android.test.refunded"); allSkus.add("android.test.item_unavailable"); } final String[] skuArray = getResources().getStringArray(R.array.donate_in_app_sku_array); allSkus.addAll(Arrays.asList(skuArray)); skus = allSkus.toArray(new String[allSkus.size()]); final int[] skuPriceArray = getResources().getIntArray(R.array.donate_in_app_price_array); for (int h = 0; h < skuPriceArray.length; h++) skuPrices.put(skuArray[h], (long) skuPriceArray[h]); // Set up the UI setContentView(R.layout.activity_donate); final Button paypal_button = (Button) findViewById(R.id.paypal_button); paypal_button.setOnClickListener(new View.OnClickListener() { /** * Donate button with PayPal by opening browser with defined URL For possible parameters see: * https://cms.paypal.com/us/cgi-bin/?cmd=_render -content&content_ID= * developer/e_howto_html_Appx_websitestandard_htmlvariables * * @param v * View that was clicked */ @Override public void onClick(final View v) { if (BuildConfig.DEBUG) Log.d(TAG, "Clicked Paypal"); FirebaseAnalytics.getInstance(DonateActivity.this).logEvent("paypal", null); final Uri.Builder uriBuilder = new Uri.Builder(); uriBuilder.scheme("https").authority("www.paypal.com").path("cgi-bin/webscr"); uriBuilder.appendQueryParameter("cmd", "_donations"); uriBuilder.appendQueryParameter("business", "ian.hannibal.lake@gmail.com"); uriBuilder.appendQueryParameter("lc", "US"); uriBuilder.appendQueryParameter("item_name", "Contraction Timer Donation"); uriBuilder.appendQueryParameter("no_note", "1"); uriBuilder.appendQueryParameter("no_shipping", "1"); uriBuilder.appendQueryParameter("currency_code", "USD"); final Uri payPalUri = uriBuilder.build(); // Start your favorite browser final Intent viewIntent = new Intent(Intent.ACTION_VIEW, payPalUri); startActivity(viewIntent); // Close this activity finish(); } }); final Button inAppButton = (Button) findViewById(R.id.donate__in_app_button); inAppButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(final View v) { final Spinner inAppSpinner = (Spinner) findViewById(R.id.donate_in_app_spinner); final int selectedInAppAmount = inAppSpinner.getSelectedItemPosition(); if (selectedInAppAmount == AdapterView.INVALID_POSITION) { return; } purchasedSku = skus[selectedInAppAmount]; if (BuildConfig.DEBUG) Log.d(TAG, "Clicked " + purchasedSku); Bundle bundle = new Bundle(); bundle.putString(FirebaseAnalytics.Param.ITEM_ID, purchasedSku); bundle.putString(FirebaseAnalytics.Param.ITEM_NAME, inAppSpinner.getItemAtPosition(selectedInAppAmount).toString()); bundle.putString(FirebaseAnalytics.Param.ITEM_CATEGORY, "donate"); FirebaseAnalytics.getInstance(DonateActivity.this).logEvent(FirebaseAnalytics.Event.VIEW_ITEM, bundle); try { final Bundle buyIntentBundle = mService.getBuyIntent(3, getPackageName(), purchasedSku, ITEM_TYPE_INAPP, ""); final int response = getResponseCodeFromBundle(buyIntentBundle); if (response != 0) { FirebaseCrash.logcat(Log.ERROR, TAG, "Buy bad response " + response); return; } final PendingIntent pendingIntent = buyIntentBundle.getParcelable("BUY_INTENT"); startIntentSenderForResult(pendingIntent.getIntentSender(), RC_REQUEST, new Intent(), 0, 0, 0); } catch (final SendIntentException e) { FirebaseCrash.logcat(Log.ERROR, TAG, "Buy: Send intent failed"); FirebaseCrash.report(e); } catch (final RemoteException e) { FirebaseCrash.logcat(Log.ERROR, TAG, "Buy: Remote exception"); FirebaseCrash.report(e); } } }); // Start the In-App Billing process, only if on Froyo or higher if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO) { mServiceConn = new ServiceConnection() { @Override public void onServiceConnected(final ComponentName name, final IBinder service) { mService = IInAppBillingService.Stub.asInterface(service); final String packageName = getPackageName(); try { // check for in-app billing v3 support final int response = mService.isBillingSupported(3, packageName, ITEM_TYPE_INAPP); if (response == 0) new InventoryQueryAsyncTask(mService).execute(skus); else { FirebaseCrash.logcat(Log.WARN, TAG, "Initialize: In app not supported"); } } catch (final RemoteException e) { FirebaseCrash.logcat(Log.ERROR, TAG, "Initialize: Remote exception"); FirebaseCrash.report(e); } } @Override public void onServiceDisconnected(final ComponentName name) { mService = null; } }; final Intent serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND"); serviceIntent.setPackage("com.android.vending"); PackageManager packageManager = getPackageManager(); List<ResolveInfo> services = packageManager != null ? packageManager.queryIntentServices(serviceIntent, 0) : null; if (services != null && !services.isEmpty()) // service available to handle that Intent bindService(serviceIntent, mServiceConn, Context.BIND_AUTO_CREATE); else { // no service available to handle that Intent FirebaseCrash.logcat(Log.WARN, TAG, "Initialize: Billing unavailable"); } } } @Override protected void onDestroy() { super.onDestroy(); if (mServiceConn != null) { try { unbindService(mServiceConn); } catch (final IllegalArgumentException e) { FirebaseCrash.logcat(Log.WARN, TAG, "Error unbinding service"); FirebaseCrash.report(e); } mServiceConn = null; mService = null; } } @Override protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); purchasedSku = savedInstanceState.containsKey(PURCHASED_SKU) ? savedInstanceState.getString(PURCHASED_SKU) : ""; } @Override protected void onResume() { super.onResume(); final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); final boolean isLockPortrait = preferences.getBoolean(Preferences.LOCK_PORTRAIT_PREFERENCE_KEY, getResources() .getBoolean(R.bool.pref_lock_portrait_default)); if (BuildConfig.DEBUG) Log.d(TAG, "Lock Portrait: " + isLockPortrait); if (isLockPortrait) setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); else setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR); } @Override protected void onSaveInstanceState(final Bundle outState) { super.onSaveInstanceState(outState); outState.putString(PURCHASED_SKU, purchasedSku); } @Override protected void onStart() { super.onStart(); getSupportActionBar().setDisplayHomeAsUpEnabled(true); } @Override public boolean onOptionsItemSelected(final MenuItem item) { if (item.getItemId() == android.R.id.home) { if (BuildConfig.DEBUG) Log.d(TAG, "Donate selected home"); } return super.onOptionsItemSelected(item); } private class ConsumeAsyncTask extends AsyncTask<Purchase, Void, List<Purchase>> { private final boolean finishActivity; private final WeakReference<IInAppBillingService> mBillingService; ConsumeAsyncTask(final IInAppBillingService service, final boolean finishActivity) { mBillingService = new WeakReference<>(service); this.finishActivity = finishActivity; } @Override protected List<Purchase> doInBackground(final Purchase... purchases) { if (BuildConfig.DEBUG) Log.d(TAG, "Starting Consume of " + Arrays.toString(purchases)); final List<Purchase> consumedPurchases = new ArrayList<>(); for (final Purchase purchase : purchases) { final String sku = purchase.getSku(); try { final String token = purchase.getToken(); if (TextUtils.isEmpty(token)) { FirebaseCrash.logcat(Log.ERROR, TAG, "Consume: Invalid token " + token); break; } final IInAppBillingService service = mBillingService.get(); if (service == null) { Log.w(TAG, "Consume: Billing service is null"); break; } final int response = service.consumePurchase(3, getPackageName(), token); if (response == 0) consumedPurchases.add(purchase); else { FirebaseCrash.logcat(Log.ERROR, TAG, "Consume: Bad response " + response); } } catch (final RemoteException e) { FirebaseCrash.logcat(Log.ERROR, TAG, "Consume: Remote exception " + sku); FirebaseCrash.report(e); } } return consumedPurchases; } @Override protected void onPostExecute(final List<Purchase> result) { if (result == null || result.isEmpty()) { Log.w(TAG, "Consume: No purchases consumed"); return; } for (final Purchase purchase : result) { final String sku = purchase.getSku(); if (BuildConfig.DEBUG) Log.d(TAG, "Consume completed successfully " + sku); } Toast.makeText(DonateActivity.this, R.string.donate_thank_you, Toast.LENGTH_LONG).show(); if (finishActivity) { if (BuildConfig.DEBUG) Log.d(TAG, "Finishing Donate Activity"); finish(); } } } private class InventoryQueryAsyncTask extends AsyncTask<String, Void, Inventory> { private final WeakReference<IInAppBillingService> mBillingService; InventoryQueryAsyncTask(final IInAppBillingService service) { mBillingService = new WeakReference<>(service); } @Override protected Inventory doInBackground(final String... moreSkus) { try { final Inventory inv = new Inventory(); if (BuildConfig.DEBUG) Log.d(TAG, "Starting query inventory"); int r = queryPurchases(inv); if (r != 0) return null; if (BuildConfig.DEBUG) Log.d(TAG, "Starting sku details query"); r = querySkuDetails(inv, moreSkus); if (r != 0) return null; return inv; } catch (final RemoteException e) { FirebaseCrash.logcat(Log.ERROR, TAG, "Inventory: Remote exception"); FirebaseCrash.report(e); } catch (final JSONException e) { FirebaseCrash.logcat(Log.ERROR, TAG, "Inventory: Parsing error"); FirebaseCrash.report(e); } return null; } @Override protected void onPostExecute(final Inventory inv) { if (BuildConfig.DEBUG) Log.d(TAG, "Inventory Returned: " + inv); // If we failed to get the inventory, then leave the in-app billing UI hidden if (inv == null) return; // Make sure we've consumed any previous purchases final List<Purchase> purchases = inv.getAllPurchases(); if (!purchases.isEmpty()) { final IInAppBillingService service = mBillingService.get(); if (service != null) new ConsumeAsyncTask(service, false).execute(purchases.toArray(new Purchase[purchases.size()])); else Log.w(TAG, "Inventory: Billing service is null"); } ArrayList<String> inAppName = new ArrayList<>(); for (final String currentSku : skus) { final SkuDetails sku = inv.getSkuDetails(currentSku); if (sku != null) { skuNames.put(currentSku, sku.getTitle()); inAppName.add(sku.getDescription() + " (" + sku.getPrice() + ")"); } } if (inAppName.isEmpty()) { return; } final Spinner inAppSpinner = (Spinner) findViewById(R.id.donate_in_app_spinner); final ArrayAdapter<String> adapter = new ArrayAdapter<>(DonateActivity.this, android.R.layout.simple_spinner_item, inAppName); // Specify the layout to use when the list of choices appears adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); // Apply the adapter to the spinner inAppSpinner.setAdapter(adapter); // And finally show the In-App Billing UI final RelativeLayout inAppLayout = (RelativeLayout) findViewById(R.id.in_app_layout); inAppLayout.setVisibility(View.VISIBLE); Bundle bundle = new Bundle(); bundle.putString(FirebaseAnalytics.Param.ITEM_CATEGORY, "donate"); FirebaseAnalytics.getInstance(DonateActivity.this).logEvent(FirebaseAnalytics.Event.VIEW_ITEM_LIST, bundle); } int queryPurchases(final Inventory inv) throws JSONException, RemoteException { // Query purchases boolean verificationFailed = false; String continueToken = null; do { final IInAppBillingService service = mBillingService.get(); if (service == null) { Log.w(TAG, "Purchases: Billing service is null"); return -1; } final Bundle ownedItems = service.getPurchases(3, getPackageName(), ITEM_TYPE_INAPP, continueToken); final int response = getResponseCodeFromBundle(ownedItems); if (response != 0) { FirebaseCrash.logcat(Log.ERROR, TAG, "Purchases: Bad response " + response); return response; } if (!ownedItems.containsKey(RESPONSE_INAPP_ITEM_LIST) || !ownedItems.containsKey(RESPONSE_INAPP_PURCHASE_DATA_LIST) || !ownedItems.containsKey(RESPONSE_INAPP_SIGNATURE_LIST)) { FirebaseCrash.logcat(Log.ERROR, TAG, "Purchases: Invalid data"); return -1; } final ArrayList<String> purchaseDataList = ownedItems .getStringArrayList(RESPONSE_INAPP_PURCHASE_DATA_LIST); final ArrayList<String> signatureList = ownedItems.getStringArrayList(RESPONSE_INAPP_SIGNATURE_LIST); for (int i = 0; i < purchaseDataList.size(); ++i) { final String purchaseData = purchaseDataList.get(i); final String signature = signatureList.get(i); final Purchase purchase = new Purchase(ITEM_TYPE_INAPP, purchaseData, signature); if (purchase.getSku().startsWith("android.test") || Security.verifyPurchase(publicKey, purchaseData, signature)) { // Record ownership and token inv.addPurchase(purchase); } else verificationFailed = true; } continueToken = ownedItems.getString("INAPP_CONTINUATION_TOKEN"); } while (!TextUtils.isEmpty(continueToken)); return verificationFailed ? -1 : 0; } int querySkuDetails(final Inventory inv, final String[] moreSkus) throws RemoteException, JSONException { final ArrayList<String> skuList = new ArrayList<>(); skuList.addAll(inv.getAllOwnedSkus(ITEM_TYPE_INAPP)); if (moreSkus != null) skuList.addAll(Arrays.asList(moreSkus)); if (skuList.size() == 0) return 0; final Bundle querySkus = new Bundle(); querySkus.putStringArrayList("ITEM_ID_LIST", skuList); final IInAppBillingService service = mBillingService.get(); if (service == null) { Log.w(TAG, "SkuDetails: Billing service is null"); return -1; } final Bundle skuDetails = service.getSkuDetails(3, getPackageName(), ITEM_TYPE_INAPP, querySkus); if (!skuDetails.containsKey(RESPONSE_GET_SKU_DETAILS_LIST)) { final int response = getResponseCodeFromBundle(skuDetails); if (response != 0) { FirebaseCrash.logcat(Log.ERROR, TAG, "SkuDetails: Bad response " + response); return response; } return -1; } final ArrayList<String> responseList = skuDetails.getStringArrayList(RESPONSE_GET_SKU_DETAILS_LIST); for (final String thisResponse : responseList) { final SkuDetails d = new SkuDetails(ITEM_TYPE_INAPP, thisResponse); inv.addSkuDetails(d); } return 0; } } }