diff --git a/WordPress/src/main/java/org/wordpress/android/WordPress.java b/WordPress/src/main/java/org/wordpress/android/WordPress.java index d1b93119621c..a8cb55dbea5b 100644 --- a/WordPress/src/main/java/org/wordpress/android/WordPress.java +++ b/WordPress/src/main/java/org/wordpress/android/WordPress.java @@ -49,6 +49,7 @@ import org.wordpress.android.ui.accounts.helpers.UpdateBlogListTask.GenericUpdateBlogListTask; import org.wordpress.android.ui.notifications.utils.NotificationsUtils; import org.wordpress.android.ui.notifications.utils.SimperiumUtils; +import org.wordpress.android.ui.plans.PlansUtils; import org.wordpress.android.ui.prefs.AppPrefs; import org.wordpress.android.ui.stats.StatsWidgetProvider; import org.wordpress.android.ui.stats.datasets.StatsDatabaseHelper; @@ -822,6 +823,7 @@ public void onAppComesFromBackground() { sUpdateCurrentBlogOption.runIfNotLimited(); } sDeleteExpiredStats.runIfNotLimited(); + PlansUtils.synchIAPsWordPressCom(); } @Override diff --git a/WordPress/src/main/java/org/wordpress/android/ui/plans/PlansActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/plans/PlansActivity.java index 40bdd43c167f..813b8195125e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/plans/PlansActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/plans/PlansActivity.java @@ -21,13 +21,18 @@ import android.widget.TextView; import android.widget.Toast; +import org.json.JSONException; +import org.json.JSONObject; import org.wordpress.android.BuildConfig; import org.wordpress.android.R; import org.wordpress.android.WordPress; +import org.wordpress.android.models.AccountHelper; +import org.wordpress.android.models.Blog; import org.wordpress.android.ui.plans.adapters.PlansPagerAdapter; import org.wordpress.android.ui.plans.models.Plan; import org.wordpress.android.ui.plans.util.IabHelper; import org.wordpress.android.ui.plans.util.IabResult; +import org.wordpress.android.ui.plans.util.Purchase; import org.wordpress.android.ui.prefs.AppPrefs; import org.wordpress.android.util.AniUtils; import org.wordpress.android.util.AppLog; @@ -43,6 +48,7 @@ public class PlansActivity extends AppCompatActivity { public static final String ARG_LOCAL_TABLE_BLOG_ID = "ARG_LOCAL_TABLE_BLOG_ID"; private static final String ARG_LOCAL_AVAILABLE_PLANS = "ARG_LOCAL_AVAILABLE_PLANS"; + private static final int PURCHASE_PLAN_REQUEST = 0; private int mLocalBlogID = -1; private Plan[] mAvailablePlans; @@ -52,6 +58,7 @@ public class PlansActivity extends AppCompatActivity { private TabLayout mTabLayout; private IabHelper mIabHelper; + private boolean mIABSetupDone = false; @Override public void onCreate(Bundle savedInstanceState) { @@ -135,18 +142,59 @@ public void onPause() { EventBus.getDefault().unregister(this); } - private void updatePurchaseUI(int position) { - Plan plan = getPageAdapter().getPlan(position); - boolean showPurchaseButton; - if (plan.isCurrentPlan()) { - showPurchaseButton = false; - } else { - // don't show the purchase button unless the plan at this position is "greater" than - // the current plan for this site - long currentPlanProductId = WordPress.wpDB.getPlanIdForLocalTableBlogId(mLocalBlogID); - showPurchaseButton = plan.isAvailable() && plan.getProductID() > currentPlanProductId; + /** + * The 'Buy' button should be available if the current plan is free, and the selected plan upgrade is available. + * @param position + * @return boolean - true if the current plan could be added to the blog. + */ + private boolean isBuyButtonAvailable(int position) { + if (!mIABSetupDone) { + return false; + } + long currentPlanProductId = WordPress.wpDB.getPlanIdForLocalTableBlogId(mLocalBlogID); + if (!PlansUtils.isFreePlan(currentPlanProductId)) { + return false; + } + final Plan plan = getPageAdapter().getPlan(position); + return plan.isAvailable() && !plan.isCurrentPlan(); + } + + /** + * The 'Upgrade' button should be available if the current plan is NOT free, + * and the selected plan upgrade is available, and the current plan ID < new plan ID. + * + * Note: Not used now, but will be when we'll implement upgrade of plan within the app. + * + * @param position + * @return boolean - true if the current plan could be upgraded to the blog. + */ + private boolean isUpgradeButtonAvailable(int position) { + long currentPlanProductId = WordPress.wpDB.getPlanIdForLocalTableBlogId(mLocalBlogID); + if (isBuyButtonAvailable(position) || PlansUtils.isFreePlan(currentPlanProductId)) { + return false; + } + + Plan currentPlan = PlansUtils.getPlan(mAvailablePlans, currentPlanProductId); + if (currentPlan == null) { + // Blog's current plan is not available anymore. Weird, it should be available, but not purchasable. + AppLog.w(AppLog.T.PLANS, "Blog's current plan with ID " + currentPlanProductId + "is not available anymore on wpcom!"); + return false; + } + + final Plan selectedPlan = getPageAdapter().getPlan(position); + + // No downgrade! + if (PlansUtils.isGreaterEquals(currentPlan, selectedPlan)) { + return false; } + return selectedPlan.isAvailable() && !selectedPlan.isCurrentPlan(); + } + + private void updatePurchaseUI(final int position) { + final Plan plan = getPageAdapter().getPlan(position); + boolean showPurchaseButton = isBuyButtonAvailable(position); + ViewGroup framePurchase = (ViewGroup) findViewById(R.id.frame_purchase); ViewGroup containerPurchase = (ViewGroup) findViewById(R.id.purchase_container); if (showPurchaseButton) { @@ -155,7 +203,7 @@ private void updatePurchaseUI(int position) { containerPurchase.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - startPurchaseProcess(); + startPurchaseProcess(position); } }); } else { @@ -170,7 +218,7 @@ public void onClick(View v) { } private void setupPlansUI() { - if (mAvailablePlans == null || mAvailablePlans.length == 0) { + if (mAvailablePlans == null || mAvailablePlans.length == 0) { // This should never be called with empty plans. Toast.makeText(PlansActivity.this, R.string.plans_loading_error, Toast.LENGTH_LONG).show(); finish(); @@ -301,14 +349,57 @@ public boolean onOptionsItemSelected(MenuItem item) { return super.onOptionsItemSelected(item); } - private void startPurchaseProcess() { - // TODO: this should start the Google Play purchase process, for now it shows the - // post-purchase on-boarding - boolean isBusinessPlan = (mViewPager.getCurrentItem() == mViewPager.getAdapter().getCount() - 1); - Intent intent = new Intent(this, PlanPostPurchaseActivity.class); - intent.putExtra(PlanPostPurchaseActivity.ARG_IS_BUSINESS_PLAN, isBusinessPlan); - startActivity(intent); - finish(); + private void startPurchaseProcess(int position) { + final Plan plan = getPageAdapter().getPlan(position); + String sku = plan.getAndroidSKU(); + Blog currentBlog = WordPress.getBlog(mLocalBlogID); + JSONObject extraData = new JSONObject(); + try { + extraData.put("blog_id", currentBlog.getDotComBlogId()); + extraData.put("user_id", AccountHelper.getDefaultAccount().getUserId()); + } catch (JSONException e) { + AppLog.e(AppLog.T.PLANS, "Can't add extra info to purchase data!", e); + return; + } + + mIabHelper.launchSubscriptionPurchaseFlow(this, sku, PURCHASE_PLAN_REQUEST, + new IabHelper.OnIabPurchaseFinishedListener() { + public void onIabPurchaseFinished(IabResult result, Purchase info) { + if (result != null) { + AppLog.d(AppLog.T.PLANS, "IabResult: " + result.toString()); + if (result.isSuccess()) { + if (info != null) { + /* + Sync the purchase info with the wpcom backend, and enabled the product on the site. + We need to use an app setting here for security reasons. + If something bad happens during this sync, we need to re-sync it later (See onAppComesFromBackground in WordPress.java) + Without this initial sync the backend doesn't have any info about the purchase, and the product will NOT be enabled + without a manual action on backend side. + */ + AppPrefs.setInAppPurchaseRefreshRequired(true); + PlansUtils.synchIAPsWordPressCom(); + AppLog.d(AppLog.T.PLANS, "Purchase: " + info.toString()); + AppLog.d(AppLog.T.PLANS, "You have bought the " + info.getSku() + ". Excellent choice, adventurer!"); + boolean isBusinessPlan = (mViewPager.getCurrentItem() == mViewPager.getAdapter().getCount() - 1); + Intent intent = new Intent(PlansActivity.this, PlanPostPurchaseActivity.class); + intent.putExtra(PlanPostPurchaseActivity.ARG_IS_BUSINESS_PLAN, isBusinessPlan); + startActivity(intent); + } + } else { + AppLog.e(AppLog.T.PLANS, "Purchase failure: " + result.getMessage()); + // Not a success. It seems that the buy activity already shows an error. + // Or at least, it shows an error if you try to purchase a subscription you already own. + } + } + } + }, + extraData.toString()); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + mIabHelper.handleActivityResult(requestCode, resultCode, data); } /* @@ -319,8 +410,7 @@ private void startPurchaseProcess() { private void startInAppBillingHelper() { mIabHelper = new IabHelper(this, BuildConfig.APP_LICENSE_KEY); if (BuildConfig.DEBUG) { - String tag = AppLog.TAG + "-" + AppLog.T.PLANS.toString(); - mIabHelper.enableDebugLogging(true, tag); + mIabHelper.enableDebugLogging(true); } try { mIabHelper.startSetup(new IabHelper.OnIabSetupFinishedListener() { @@ -328,6 +418,7 @@ private void startInAppBillingHelper() { public void onIabSetupFinished(IabResult result) { if (result.isSuccess()) { AppLog.d(AppLog.T.PLANS, "IAB started successfully"); + mIABSetupDone = true; } else { AppLog.w(AppLog.T.PLANS, "IAB failed with " + result); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/plans/PlansUtils.java b/WordPress/src/main/java/org/wordpress/android/ui/plans/PlansUtils.java index 10299596a95e..d50d11a35d58 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/plans/PlansUtils.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/plans/PlansUtils.java @@ -1,5 +1,6 @@ package org.wordpress.android.ui.plans; +import android.os.AsyncTask; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.TextUtils; @@ -7,6 +8,8 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import org.wordpress.android.WordPress; +import org.wordpress.android.models.AccountHelper; import org.wordpress.android.ui.plans.models.Feature; import org.wordpress.android.ui.plans.models.Plan; import org.wordpress.android.ui.prefs.AppPrefs; @@ -94,6 +97,43 @@ public static String getIconUrlForPlan(Plan plan, int iconSize) { return PhotonUtils.getPhotonImageUrl(plan.getIconUrl(), iconSize, iconSize); } + public static boolean isFreePlan(Plan plan) { + return isFreePlan(plan.getProductID()); + } + + /** + * Weather the plan ID is a free plan. + * + * @param planID - The plan ID + * @return boolean - true if the current blog is on a free plan. + */ + public static boolean isFreePlan(long planID) { + return planID == PlansConstants.JETPACK_FREE_PLAN_ID || planID == PlansConstants.FREE_PLAN_ID; + } + + /** + * Weather the plan A is "greater" than or "equal to" the plan B + * + * TODO: Improve this, since we're assuming that a greater plan ID meant a more expensive plan. + */ + public static boolean isGreaterEquals(Plan planA, Plan planB) { + return planA.getProductID() >= planB.getProductID(); + } + + public static Plan getPlan(Plan[] plans, long planID) { + if (plans == null) { + AppLog.w(AppLog.T.PLANS, "The passed plans list is null!!"); + return null; + } + for (Plan currentPlan: plans) { + if (currentPlan.getProductID() == planID) { + return currentPlan; + } + } + AppLog.w(AppLog.T.PLANS, "Plan with ID " + planID + " wasn't found in the plans list"); + return null; + } + /** * Removes stored plan data - for testing purposes */ @@ -102,4 +142,16 @@ public static void clearPlanData() { AppPrefs.setGlobalPlansFeatures(null); } + + /** + * Synch IAPs with wpcom backend. This need to be called to add/remove upgrades on wpcom side. + * Those upgrades the user has already bought/cancelled on mobile side (from the Google Store). + */ + public static boolean synchIAPsWordPressCom() { + if (AccountHelper.isSignedInWordPressDotCom() && AppPrefs.isInAppPurchaseRefreshRequired()) { + new UpdateIAPTask(WordPress.getContext()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + return true; + } + return false; + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/plans/UpdateIAPTask.java b/WordPress/src/main/java/org/wordpress/android/ui/plans/UpdateIAPTask.java new file mode 100644 index 000000000000..2e4ab2c200e5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/plans/UpdateIAPTask.java @@ -0,0 +1,138 @@ +package org.wordpress.android.ui.plans; + +import android.content.Context; +import android.os.AsyncTask; + +import com.android.volley.VolleyError; +import com.wordpress.rest.RestRequest; + +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.BuildConfig; +import org.wordpress.android.WordPress; +import org.wordpress.android.models.AccountHelper; +import org.wordpress.android.ui.plans.util.IabException; +import org.wordpress.android.ui.plans.util.IabHelper; +import org.wordpress.android.ui.plans.util.IabResult; +import org.wordpress.android.ui.plans.util.Inventory; +import org.wordpress.android.ui.plans.util.Purchase; +import org.wordpress.android.ui.prefs.AppPrefs; +import org.wordpress.android.util.AppLog; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Synch IAPs on the wpcom backend. This need to be called to add/remove upgrades on wpcom side. + * + */ +public class UpdateIAPTask extends AsyncTask { + private static final int GET_IAP_BINDER_TIMEOUT = 30000; + private static final String IAP_ENDPOINT = "/iap/validate"; + private final Context mContext; + private IabHelper mIabHelper; + private boolean mIABSetupDone = false; + + public UpdateIAPTask(Context context) { + mContext = context; + } + + @Override + protected Void doInBackground(Void... args) { + if (!AccountHelper.isSignedInWordPressDotCom()) { + return null; + } + + final CountDownLatch countDownLatch = new CountDownLatch(1); + + mIabHelper = new IabHelper(this.mContext, BuildConfig.APP_LICENSE_KEY); + if (BuildConfig.DEBUG) { + mIabHelper.enableDebugLogging(true); + } + try { + mIabHelper.startSetup(new IabHelper.OnIabSetupFinishedListener() { + @Override + public void onIabSetupFinished(IabResult result) { + if (result.isSuccess()) { + mIABSetupDone = true; + AppLog.d(AppLog.T.PLANS, "IAB started successfully"); + } else { + AppLog.w(AppLog.T.PLANS, "IAB failed with " + result); + } + countDownLatch.countDown(); + } + }); + try { + countDownLatch.await(GET_IAP_BINDER_TIMEOUT, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + AppLog.e(AppLog.T.PLANS, "IAP setup took too long! > " + GET_IAP_BINDER_TIMEOUT + " msecs!", e); + return null; + } + } catch (NullPointerException e) { + // will happen when play store isn't available on device + //AppLog.e(AppLog.T.PLANS, e); + AppLog.w(AppLog.T.PLANS, "Unable to start IAB helper. Happen when Play Store isn't available on device."); + return null; + } + + listIAPs(); + stopInAppBillingHelper(); + return null; + } + + private void listIAPs() { + if (mIabHelper == null || !mIABSetupDone) { + return; + } + try { + Inventory inventory = mIabHelper.queryInventory(true, null, null); + List iaps = inventory.getAllPurchases(); + for (Purchase purchase : iaps) { + AppLog.d(AppLog.T.PLANS, "Original purchase JSON " + purchase.getOriginalJson()); + try { + JSONObject developerPayload = new JSONObject(purchase.getDeveloperPayload()); + Map params = new HashMap<>(); + params.put("blog_id", developerPayload.getString("blog_id")); + params.put("iap_sku", purchase.getSku()); + params.put("iap_token", purchase.getToken()); + WordPress.getRestClientUtilsV1_1().post(IAP_ENDPOINT, params, null, + new RestRequest.Listener() { + @Override + public void onResponse(JSONObject response) { + if (response != null) { + AppLog.d(AppLog.T.PLANS, "Response from the server: " + response.toString()); + } + AppPrefs.setInAppPurchaseRefreshRequired(false); + } + }, + new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + AppLog.e(AppLog.T.PLANS, "Response from the server", error); + } + }); + } catch (JSONException e) { + AppLog.e(AppLog.T.PLANS, "Unable to parse IAP info - " + purchase.getOriginalJson(), e); + } + } + } catch (IabException e) { + AppLog.e(AppLog.T.PLANS, "Unable to refresh the inventory", e); + } + } + + private void stopInAppBillingHelper() { + if (mIabHelper != null) { + try { + mIabHelper.dispose(); + } catch (IllegalArgumentException e) { + // this can happen if the IAB helper was created but failed to bind to its service + // when started, which will occur on emulators + AppLog.w(AppLog.T.PLANS, "Unable to dispose IAB helper"); + } + mIabHelper = null; + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/plans/util/IabHelper.java b/WordPress/src/main/java/org/wordpress/android/ui/plans/util/IabHelper.java index 86bff92d00f8..0b14aae8fc4e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/plans/util/IabHelper.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/plans/util/IabHelper.java @@ -32,6 +32,7 @@ import com.android.vending.billing.IInAppBillingService; import org.json.JSONException; +import org.wordpress.android.util.AppLog; import java.util.ArrayList; import java.util.List; @@ -1037,14 +1038,14 @@ public void run() { } void logDebug(String msg) { - if (mDebugLog) Log.d(mDebugTag, msg); + if (mDebugLog) AppLog.d(AppLog.T.PLANS, msg); } void logError(String msg) { - Log.e(mDebugTag, "In-app billing error: " + msg); + AppLog.e(AppLog.T.PLANS, "In-app billing error: " + msg); } void logWarn(String msg) { - Log.w(mDebugTag, "In-app billing warning: " + msg); + AppLog.w(AppLog.T.PLANS, "In-app billing warning: " + msg); } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/plans/util/Inventory.java b/WordPress/src/main/java/org/wordpress/android/ui/plans/util/Inventory.java index b4508beecb99..9c8467e984f2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/plans/util/Inventory.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/plans/util/Inventory.java @@ -63,12 +63,12 @@ public void erasePurchase(String sku) { } /** Returns a list of all owned product IDs. */ - List getAllOwnedSkus() { + public List getAllOwnedSkus() { return new ArrayList(mPurchaseMap.keySet()); } /** Returns a list of all owned product IDs of a given type */ - List getAllOwnedSkus(String itemType) { + public List getAllOwnedSkus(String itemType) { List result = new ArrayList(); for (Purchase p : mPurchaseMap.values()) { if (p.getItemType().equals(itemType)) result.add(p.getSku()); @@ -77,7 +77,7 @@ List getAllOwnedSkus(String itemType) { } /** Returns a list of all purchases. */ - List getAllPurchases() { + public List getAllPurchases() { return new ArrayList(mPurchaseMap.values()); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java index a7e5b172ad09..b52231f479fe 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java @@ -82,6 +82,9 @@ public enum UndeletablePrefKey implements PrefKey { // Global plans features GLOBAL_PLANS_PLANS_FEATURES, + // When we need to sync IAP data with the wpcom backend + IAP_SYNC_REQUIRED, + // When we need to show the Gravatar Change Promo Tooltip GRAVATAR_CHANGE_PROMO_REQUIRED, } @@ -377,4 +380,11 @@ public static void setGlobalPlansFeatures(String jsonOfFeatures) { public static String getGlobalPlansFeatures() { return getString(UndeletablePrefKey.GLOBAL_PLANS_PLANS_FEATURES, ""); } + + public static boolean isInAppPurchaseRefreshRequired() { + return getBoolean(UndeletablePrefKey.IAP_SYNC_REQUIRED, false); + } + public static void setInAppPurchaseRefreshRequired(boolean required) { + setBoolean(UndeletablePrefKey.IAP_SYNC_REQUIRED, required); + } }