diff --git a/app/build.gradle b/app/build.gradle index ed058702a..39bcbfd37 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -172,7 +172,6 @@ dependencies { implementation 'com.google.apis:google-api-services-tasks:v1-rev55-1.25.0' implementation 'com.google.apis:google-api-services-drive:v3-rev136-1.25.0' implementation 'com.google.api-client:google-api-client-android:1.27.0' - implementation 'com.android.billingclient:billing:1.2.1' implementation("androidx.work:work-runtime:${WORK_VERSION}") { // https://groups.google.com/forum/#!topic/guava-announce/Km82fZG68Sw exclude group: 'com.google.guava', module: 'listenablefuture' @@ -186,6 +185,7 @@ dependencies { googleplayImplementation 'com.google.android.gms:play-services-maps:16.1.0' googleplayImplementation "com.google.android.gms:play-services-auth:16.0.1" googleplayImplementation 'com.google.android.libraries.places:places:1.1.0' + googleplayImplementation 'com.android.billingclient:billing:1.2.1' amazonImplementation fileTree(dir: 'libs', include: ['*.jar']) amazonImplementation "com.crashlytics.sdk.android:crashlytics:${CRASHLYTICS_VERSION}" diff --git a/app/src/amazon/java/org/tasks/analytics/Tracker.java b/app/src/amazon/java/org/tasks/analytics/Tracker.java index f291948d8..a3a051a9f 100644 --- a/app/src/amazon/java/org/tasks/analytics/Tracker.java +++ b/app/src/amazon/java/org/tasks/analytics/Tracker.java @@ -1,14 +1,8 @@ package org.tasks.analytics; -import static org.tasks.billing.BillingClient.BillingResponseToString; - import android.content.Context; -import android.os.Bundle; -import com.android.billingclient.api.BillingClient.BillingResponse; import com.crashlytics.android.Crashlytics; import com.google.firebase.analytics.FirebaseAnalytics; -import com.google.firebase.analytics.FirebaseAnalytics.Event; -import com.google.firebase.analytics.FirebaseAnalytics.Param; import io.fabric.sdk.android.Fabric; import javax.inject.Inject; import org.tasks.injection.ApplicationScope; @@ -68,15 +62,4 @@ public class Tracker { return; } } - - public void reportIabResult(@BillingResponse int response, String sku) { - if (!enabled) { - return; - } - - Bundle bundle = new Bundle(); - bundle.putString(Param.ITEM_ID, sku); - bundle.putString(Param.SUCCESS, BillingResponseToString(response)); - analytics.logEvent(Event.ECOMMERCE_PURCHASE, bundle); - } } diff --git a/app/src/amazon/java/org/tasks/billing/BillingClientImpl.java b/app/src/amazon/java/org/tasks/billing/BillingClientImpl.java new file mode 100644 index 000000000..fe0021ce0 --- /dev/null +++ b/app/src/amazon/java/org/tasks/billing/BillingClientImpl.java @@ -0,0 +1,103 @@ +package org.tasks.billing; + +import static com.google.common.collect.Iterables.transform; +import static com.google.common.collect.Lists.newArrayList; +import static com.google.common.collect.Lists.transform; +import static com.google.common.collect.Sets.newHashSet; + +import android.app.Activity; +import android.content.Context; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; +import com.amazon.device.iap.PurchasingListener; +import com.amazon.device.iap.PurchasingService; +import com.amazon.device.iap.model.FulfillmentResult; +import com.amazon.device.iap.model.ProductDataResponse; +import com.amazon.device.iap.model.PurchaseResponse; +import com.amazon.device.iap.model.PurchaseUpdatesResponse; +import com.amazon.device.iap.model.PurchaseUpdatesResponse.RequestStatus; +import com.amazon.device.iap.model.UserDataResponse; +import java.util.List; +import javax.inject.Inject; +import org.tasks.analytics.Tracker; +import org.tasks.injection.ForApplication; +import timber.log.Timber; + +public class BillingClientImpl implements BillingClient, PurchasingListener { + + private final MutableLiveData> skuDetails = new MutableLiveData<>(); + private final Inventory inventory; + + @Inject + public BillingClientImpl(@ForApplication Context context, Inventory inventory, Tracker tracker) { + this.inventory = inventory; + PurchasingService.registerListener(context, this); + } + + @Override + public void observeSkuDetails( + LifecycleOwner owner, + Observer> subscriptionObserver, + Observer> iapObserver) { + skuDetails.observe(owner, subscriptionObserver); + } + + @Override + public void queryPurchases() { + PurchasingService.getPurchaseUpdates(true); + } + + @Override + public void querySkuDetails() { + PurchasingService.getProductData(newHashSet(SkuDetails.SKU_PRO)); + } + + @Override + public void consume(String sku) { + throw new UnsupportedOperationException(); + } + + @Override + public void initiatePurchaseFlow(Activity activity, String sku, String skuType) { + PurchasingService.purchase(sku); + } + + @Override + public int getErrorMessage() { + return 0; + } + + @Override + public void onUserDataResponse(UserDataResponse userDataResponse) { + Timber.d("onUserDataResponse(%s)", userDataResponse); + } + + @Override + public void onProductDataResponse(ProductDataResponse productDataResponse) { + Timber.d("onProductDataResponse(%s)", productDataResponse); + if (productDataResponse.getRequestStatus() == ProductDataResponse.RequestStatus.SUCCESSFUL) { + skuDetails.setValue( + newArrayList(transform(productDataResponse.getProductData().values(), SkuDetails::new))); + } + } + + @Override + public void onPurchaseResponse(PurchaseResponse purchaseResponse) { + Timber.d("onPurchaseResponse(%s)", purchaseResponse); + if (purchaseResponse.getRequestStatus() == PurchaseResponse.RequestStatus.SUCCESSFUL) { + inventory.add(new Purchase(purchaseResponse.getReceipt())); + PurchasingService.notifyFulfillment( + purchaseResponse.getReceipt().getReceiptId(), FulfillmentResult.FULFILLED); + } + } + + @Override + public void onPurchaseUpdatesResponse(PurchaseUpdatesResponse purchaseUpdatesResponse) { + Timber.d("onPurchaseUpdatesResponse(%s)", purchaseUpdatesResponse); + if (purchaseUpdatesResponse.getRequestStatus() == RequestStatus.SUCCESSFUL) { + inventory.clear(); + inventory.add(transform(purchaseUpdatesResponse.getReceipts(), Purchase::new)); + } + } +} diff --git a/app/src/amazon/java/org/tasks/billing/Purchase.java b/app/src/amazon/java/org/tasks/billing/Purchase.java new file mode 100644 index 000000000..68532d83e --- /dev/null +++ b/app/src/amazon/java/org/tasks/billing/Purchase.java @@ -0,0 +1,30 @@ +package org.tasks.billing; + +import com.amazon.device.iap.model.Receipt; +import com.google.gson.GsonBuilder; + +public class Purchase { + + private final Receipt receipt; + + public Purchase(String json) { + this(new GsonBuilder().create().fromJson(json, Receipt.class)); + } + + public Purchase(Receipt receipt) { + this.receipt = receipt; + } + + public String getSku() { + return receipt.getSku(); + } + + public String toJson() { + return new GsonBuilder().create().toJson(receipt); + } + + @Override + public String toString() { + return "Purchase{" + "receipt=" + receipt + '}'; + } +} diff --git a/app/src/amazon/java/org/tasks/billing/SignatureVerifier.java b/app/src/amazon/java/org/tasks/billing/SignatureVerifier.java index 59db9f4b7..e5b3c2781 100644 --- a/app/src/amazon/java/org/tasks/billing/SignatureVerifier.java +++ b/app/src/amazon/java/org/tasks/billing/SignatureVerifier.java @@ -1,6 +1,5 @@ package org.tasks.billing; -import com.android.billingclient.api.Purchase; import javax.inject.Inject; public class SignatureVerifier { diff --git a/app/src/amazon/java/org/tasks/billing/SkuDetails.java b/app/src/amazon/java/org/tasks/billing/SkuDetails.java new file mode 100644 index 000000000..5bf2dc447 --- /dev/null +++ b/app/src/amazon/java/org/tasks/billing/SkuDetails.java @@ -0,0 +1,38 @@ +package org.tasks.billing; + +import com.amazon.device.iap.model.Product; +import com.amazon.device.iap.model.ProductType; + +public class SkuDetails { + + static final String SKU_PRO = "tasks_pro"; + + static final String TYPE_INAPP = ProductType.CONSUMABLE.name(); + static final String TYPE_SUBS = ProductType.SUBSCRIPTION.name(); + + private final Product product; + + public SkuDetails(Product product) { + this.product = product; + } + + public String getSku() { + return product.getSku(); + } + + public String getTitle() { + return product.getTitle(); + } + + public String getPrice() { + return product.getPrice(); + } + + public String getDescription() { + return product.getDescription(); + } + + public String getSkuType() { + return product.getProductType().name(); + } +} diff --git a/app/src/amazon/res/values/keys.xml b/app/src/amazon/res/values/keys.xml index a2e436059..b20340331 100644 --- a/app/src/amazon/res/values/keys.xml +++ b/app/src/amazon/res/values/keys.xml @@ -4,4 +4,5 @@ Google Play services error amzn://apps/android?p=org.tasks purchases_amazon + https://www.amazon.com/gp/mas/your-account/myapps/yoursubscriptions/ref=d2_ss_app_sb_myas \ No newline at end of file diff --git a/app/src/generic/java/org/tasks/billing/BillingClientImpl.java b/app/src/generic/java/org/tasks/billing/BillingClientImpl.java new file mode 100644 index 000000000..2969dc8f8 --- /dev/null +++ b/app/src/generic/java/org/tasks/billing/BillingClientImpl.java @@ -0,0 +1,37 @@ +package org.tasks.billing; + +import android.app.Activity; +import android.content.Context; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.Observer; +import java.util.List; +import javax.inject.Inject; +import org.tasks.analytics.Tracker; + +public class BillingClientImpl implements BillingClient { + @Inject + public BillingClientImpl(Context context, Inventory inventory, Tracker tracker) {} + + @Override + public void queryPurchases() {} + + @Override + public int getErrorMessage() { + return 0; + } + + @Override + public void initiatePurchaseFlow(Activity activity, String sku, String skuType) {} + + @Override + public void querySkuDetails() {} + + @Override + public void observeSkuDetails( + LifecycleOwner owner, + Observer> subscriptionObserver, + Observer> iapObserver) {} + + @Override + public void consume(String sku) {} +} diff --git a/app/src/generic/java/org/tasks/billing/Purchase.java b/app/src/generic/java/org/tasks/billing/Purchase.java new file mode 100644 index 000000000..253a83c24 --- /dev/null +++ b/app/src/generic/java/org/tasks/billing/Purchase.java @@ -0,0 +1,14 @@ +package org.tasks.billing; + +public class Purchase { + + public Purchase(String json) {} + + public String getSku() { + return null; + } + + public String toJson() { + return null; + } +} diff --git a/app/src/generic/java/org/tasks/billing/SignatureVerifier.java b/app/src/generic/java/org/tasks/billing/SignatureVerifier.java index 15e109805..b63a938b2 100644 --- a/app/src/generic/java/org/tasks/billing/SignatureVerifier.java +++ b/app/src/generic/java/org/tasks/billing/SignatureVerifier.java @@ -1,6 +1,5 @@ package org.tasks.billing; -import com.android.billingclient.api.Purchase; import javax.inject.Inject; public class SignatureVerifier { diff --git a/app/src/generic/java/org/tasks/billing/SkuDetails.java b/app/src/generic/java/org/tasks/billing/SkuDetails.java new file mode 100644 index 000000000..d99734a45 --- /dev/null +++ b/app/src/generic/java/org/tasks/billing/SkuDetails.java @@ -0,0 +1,28 @@ +package org.tasks.billing; + +public class SkuDetails { + + static final String SKU_PRO = ""; + static final String TYPE_SUBS = ""; + static final String TYPE_INAPP = ""; + + public String getSku() { + return null; + } + + public String getTitle() { + return null; + } + + public String getPrice() { + return null; + } + + public String getDescription() { + return null; + } + + public String getSkuType() { + return null; + } +} diff --git a/app/src/generic/res/values/keys.xml b/app/src/generic/res/values/keys.xml index da63eb69e..cea6fad3d 100644 --- a/app/src/generic/res/values/keys.xml +++ b/app/src/generic/res/values/keys.xml @@ -4,4 +4,6 @@ Google Play services error https://f-droid.org/en/packages/org.tasks/ purchases_fdroid + + \ No newline at end of file diff --git a/app/src/googleplay/java/org/tasks/analytics/Tracker.java b/app/src/googleplay/java/org/tasks/analytics/Tracker.java index f291948d8..288095ad6 100644 --- a/app/src/googleplay/java/org/tasks/analytics/Tracker.java +++ b/app/src/googleplay/java/org/tasks/analytics/Tracker.java @@ -1,6 +1,6 @@ package org.tasks.analytics; -import static org.tasks.billing.BillingClient.BillingResponseToString; +import static org.tasks.billing.BillingClientImpl.BillingResponseToString; import android.content.Context; import android.os.Bundle; diff --git a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.java b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.java new file mode 100644 index 000000000..29b0a68b4 --- /dev/null +++ b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.java @@ -0,0 +1,316 @@ +package org.tasks.billing; + +import static com.google.common.collect.Iterables.filter; +import static com.google.common.collect.Iterables.transform; +import static com.google.common.collect.Lists.newArrayList; +import static com.google.common.collect.Lists.transform; +import static com.todoroo.andlib.utility.AndroidUtilities.assertMainThread; +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.app.Activity; +import android.content.Context; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; +import com.android.billingclient.api.BillingClient.BillingResponse; +import com.android.billingclient.api.BillingClient.FeatureType; +import com.android.billingclient.api.BillingClient.SkuType; +import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.ConsumeResponseListener; +import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.PurchasesUpdatedListener; +import com.android.billingclient.api.SkuDetailsParams; +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; +import java.util.List; +import javax.inject.Inject; +import org.tasks.BuildConfig; +import org.tasks.R; +import org.tasks.analytics.Tracker; +import org.tasks.injection.ForApplication; +import timber.log.Timber; + +@SuppressWarnings("all") +public class BillingClientImpl implements BillingClient, PurchasesUpdatedListener { + + private static final List DEBUG_SKUS = + ImmutableList.of(SKU_THEMES, SKU_TASKER, SKU_DASHCLOCK, SKU_VIP); + + private final MutableLiveData> skuDetails = new MutableLiveData<>(); + private final Inventory inventory; + private final Tracker tracker; + MutableLiveData> subscriptions = new MutableLiveData<>(); + MutableLiveData> iaps = new MutableLiveData<>(); + private com.android.billingclient.api.BillingClient billingClient; + private boolean connected; + private int billingClientResponseCode = -1; + + @Inject + public BillingClientImpl(@ForApplication Context context, Inventory inventory, Tracker tracker) { + this.inventory = inventory; + this.tracker = tracker; + billingClient = + com.android.billingclient.api.BillingClient.newBuilder(context).setListener(this).build(); + } + + 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"; + } + } + + /** + * Query purchases across various use cases and deliver the result in a formalized way through a + * listener + */ + public void queryPurchases() { + Runnable queryToExecute = + () -> { + Single purchases = + Single.fromCallable(() -> billingClient.queryPurchases(SkuType.INAPP)); + if (areSubscriptionsSupported()) { + purchases = + Single.zip( + purchases, + Single.fromCallable(() -> billingClient.queryPurchases(SkuType.SUBS)), + (iaps, subs) -> { + if (subs.getResponseCode() == BillingResponse.OK) { + iaps.getPurchasesList().addAll(subs.getPurchasesList()); + } + return iaps; + }); + } + purchases + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::onQueryPurchasesFinished); + }; + + executeServiceRequest(queryToExecute); + } + + /** Handle a result from querying of purchases and report an updated list to the listener */ + private void onQueryPurchasesFinished(PurchasesResult result) { + assertMainThread(); + + // Have we been disposed of in the meantime? If so, or bad result code, then quit + if (billingClient == null || result.getResponseCode() != BillingResponse.OK) { + Timber.w( + "Billing client was null or result code (%s) was bad - quitting", + result.getResponseCode()); + return; + } + + Timber.d("Query inventory was successful."); + + // Update the UI and purchases inventory with new list of purchases + inventory.clear(); + add(result.getPurchasesList()); + } + + @Override + public void onPurchasesUpdated( + @BillingResponse int resultCode, List purchases) { + if (resultCode == BillingResponse.OK) { + add(purchases); + } + String skus = + purchases == null + ? "null" + : Joiner.on(";") + .join( + Iterables.transform(purchases, com.android.billingclient.api.Purchase::getSku)); + Timber.i("onPurchasesUpdated(%s, %s)", BillingResponseToString(resultCode), skus); + tracker.reportIabResult(resultCode, skus); + } + + private void add(List purchases) { + inventory.add(Iterables.transform(purchases, Purchase::new)); + } + + @Override + public void initiatePurchaseFlow(Activity activity, String skuId, String billingType) { + executeServiceRequest( + () -> { + billingClient.launchBillingFlow( + activity, + BillingFlowParams.newBuilder() + .setSku(skuId) + .setType(billingType) + .setOldSkus(null) + .build()); + }); + } + + public void destroy() { + Timber.d("Destroying the manager."); + + if (billingClient != null && billingClient.isReady()) { + billingClient.endConnection(); + billingClient = null; + } + } + + private void startServiceConnection(final Runnable executeOnSuccess) { + billingClient.startConnection( + new com.android.billingclient.api.BillingClientStateListener() { + @Override + public void onBillingSetupFinished(@BillingResponse int billingResponseCode) { + Timber.d("onBillingSetupFinished(%s)", billingResponseCode); + + if (billingResponseCode == BillingResponse.OK) { + connected = true; + if (executeOnSuccess != null) { + executeOnSuccess.run(); + } + } + billingClientResponseCode = billingResponseCode; + } + + @Override + public void onBillingServiceDisconnected() { + Timber.d("onBillingServiceDisconnected()"); + connected = false; + } + }); + } + + private void executeServiceRequest(Runnable runnable) { + if (connected) { + runnable.run(); + } else { + // If billing service was disconnected, we try to reconnect 1 time. + // (feel free to introduce your retry policy here). + startServiceConnection(runnable); + } + } + + /** + * Checks if subscriptions are supported for current client + * + *

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; + } + + @Override + public void observeSkuDetails( + LifecycleOwner owner, + Observer> subscriptionObserver, + Observer> iapObserver) { + subscriptions.observe(owner, subscriptionObserver); + iaps.observe(owner, iapObserver); + } + + @Override + public void querySkuDetails() { + executeServiceRequest(this::fetchSubscription); + } + + private void fetchSubscription() { + billingClient.querySkuDetailsAsync( + SkuDetailsParams.newBuilder().setSkusList(SkuDetails.SKU_SUBS).setType(SkuType.SUBS).build(), + new com.android.billingclient.api.SkuDetailsResponseListener() { + @Override + public void onSkuDetailsResponse( + int responseCode, List skuDetailsList) { + if (responseCode == BillingResponse.OK) { + subscriptions.setValue(transform(skuDetailsList, SkuDetails::new)); + } else { + Timber.e( + "Query for subs failed: %s (%s)", + BillingResponseToString(responseCode), responseCode); + } + + executeServiceRequest(BillingClientImpl.this::fetchIAPs); + } + }); + } + + private void fetchIAPs() { + Iterable purchased = + transform(filter(inventory.getPurchases(), Purchase::isIap), Purchase::getSku); + billingClient.querySkuDetailsAsync( + SkuDetailsParams.newBuilder() + .setSkusList(BuildConfig.DEBUG ? DEBUG_SKUS : newArrayList(purchased)) + .setType(SkuType.INAPP) + .build(), + new com.android.billingclient.api.SkuDetailsResponseListener() { + @Override + public void onSkuDetailsResponse( + int responseCode, List skuDetailsList) { + if (responseCode == BillingResponse.OK) { + iaps.setValue(transform(skuDetailsList, SkuDetails::new)); + } else { + Timber.e( + "Query for iaps failed: %s (%s)", + BillingResponseToString(responseCode), responseCode); + } + } + }); + } + + @Override + 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(); + }; + + executeServiceRequest( + () -> + billingClient.consumeAsync( + inventory.getPurchase(sku).getPurchaseToken(), onConsumeListener)); + } + + @Override + public int getErrorMessage() { + return billingClientResponseCode == BillingResponse.BILLING_UNAVAILABLE + ? R.string.error_billing_unavailable + : R.string.error_billing_default; + } +} diff --git a/app/src/googleplay/java/org/tasks/billing/Purchase.java b/app/src/googleplay/java/org/tasks/billing/Purchase.java new file mode 100644 index 000000000..1e4a0fbed --- /dev/null +++ b/app/src/googleplay/java/org/tasks/billing/Purchase.java @@ -0,0 +1,45 @@ +package org.tasks.billing; + +import com.google.gson.GsonBuilder; + +public class Purchase { + + private final com.android.billingclient.api.Purchase purchase; + + public Purchase(String json) { + this(new GsonBuilder().create().fromJson(json, com.android.billingclient.api.Purchase.class)); + } + + public Purchase(com.android.billingclient.api.Purchase purchase) { + this.purchase = purchase; + } + + public String toJson() { + return new GsonBuilder().create().toJson(purchase); + } + + String getOriginalJson() { + return purchase.getOriginalJson(); + } + + String getSignature() { + return purchase.getSignature(); + } + + public String getSku() { + return purchase.getSku(); + } + + String getPurchaseToken() { + return purchase.getPurchaseToken(); + } + + boolean isIap() { + return !SkuDetails.SKU_SUBS.contains(getSku()); + } + + @Override + public String toString() { + return "Purchase{" + "purchase=" + purchase + '}'; + } +} diff --git a/app/src/main/java/org/tasks/billing/Security.java b/app/src/googleplay/java/org/tasks/billing/Security.java similarity index 100% rename from app/src/main/java/org/tasks/billing/Security.java rename to app/src/googleplay/java/org/tasks/billing/Security.java diff --git a/app/src/googleplay/java/org/tasks/billing/SignatureVerifier.java b/app/src/googleplay/java/org/tasks/billing/SignatureVerifier.java index cf2103c1c..809f5e1b2 100644 --- a/app/src/googleplay/java/org/tasks/billing/SignatureVerifier.java +++ b/app/src/googleplay/java/org/tasks/billing/SignatureVerifier.java @@ -1,7 +1,6 @@ package org.tasks.billing; import android.content.Context; -import com.android.billingclient.api.Purchase; import java.io.IOException; import javax.inject.Inject; import org.tasks.R; diff --git a/app/src/googleplay/java/org/tasks/billing/SkuDetails.java b/app/src/googleplay/java/org/tasks/billing/SkuDetails.java new file mode 100644 index 000000000..80c0c3cfb --- /dev/null +++ b/app/src/googleplay/java/org/tasks/billing/SkuDetails.java @@ -0,0 +1,40 @@ +package org.tasks.billing; + +import com.android.billingclient.api.BillingClient.SkuType; +import com.google.common.collect.ImmutableList; +import java.util.List; + +public class SkuDetails { + + static final String SKU_PRO = "annual_499"; + static final List SKU_SUBS = ImmutableList.of(SKU_PRO); + + static final String TYPE_INAPP = SkuType.INAPP; + static final String TYPE_SUBS = SkuType.SUBS; + + private final com.android.billingclient.api.SkuDetails skuDetails; + + SkuDetails(com.android.billingclient.api.SkuDetails skuDetails) { + this.skuDetails = skuDetails; + } + + public String getSku() { + return skuDetails.getSku(); + } + + public String getTitle() { + return skuDetails.getTitle(); + } + + public String getPrice() { + return skuDetails.getPrice(); + } + + public String getDescription() { + return skuDetails.getDescription(); + } + + public String getSkuType() { + return skuDetails.getType(); + } +} diff --git a/app/src/googleplay/res/values/keys.xml b/app/src/googleplay/res/values/keys.xml index 8ead59106..3585a24b3 100644 --- a/app/src/googleplay/res/values/keys.xml +++ b/app/src/googleplay/res/values/keys.xml @@ -4,4 +4,5 @@ play_services_available market://details?id=org.tasks purchases + https://play.google.com/store/account/subscriptions?sku=annual_499&package=org.tasks \ No newline at end of file diff --git a/app/src/main/java/org/tasks/activities/ColorPickerActivity.java b/app/src/main/java/org/tasks/activities/ColorPickerActivity.java index fd437af97..f02d1a701 100644 --- a/app/src/main/java/org/tasks/activities/ColorPickerActivity.java +++ b/app/src/main/java/org/tasks/activities/ColorPickerActivity.java @@ -7,7 +7,6 @@ import android.os.Bundle; import java.util.List; import javax.inject.Inject; import org.tasks.R; -import org.tasks.billing.BillingClient; import org.tasks.billing.Inventory; import org.tasks.billing.PurchaseActivity; import org.tasks.dialogs.ColorPickerDialog; @@ -27,7 +26,6 @@ public class ColorPickerActivity extends ThemedInjectingAppCompatActivity private static final int REQUEST_SUBSCRIPTION = 10101; @Inject Theme theme; @Inject ThemeCache themeCache; - @Inject BillingClient billingClient; @Inject Inventory inventory; @Inject Preferences preferences; diff --git a/app/src/main/java/org/tasks/billing/BillingClient.java b/app/src/main/java/org/tasks/billing/BillingClient.java index 083d443d0..66ac8051f 100644 --- a/app/src/main/java/org/tasks/billing/BillingClient.java +++ b/app/src/main/java/org/tasks/billing/BillingClient.java @@ -1,261 +1,23 @@ package org.tasks.billing; -import static com.google.common.collect.Iterables.transform; -import static com.todoroo.andlib.utility.AndroidUtilities.assertMainThread; - import android.app.Activity; -import android.content.Context; -import com.android.billingclient.api.BillingClient.BillingResponse; -import com.android.billingclient.api.BillingClient.FeatureType; -import com.android.billingclient.api.BillingClient.SkuType; -import com.android.billingclient.api.BillingFlowParams; -import com.android.billingclient.api.ConsumeResponseListener; -import com.android.billingclient.api.Purchase; -import com.android.billingclient.api.Purchase.PurchasesResult; -import com.android.billingclient.api.PurchasesUpdatedListener; -import com.android.billingclient.api.SkuDetailsParams; -import com.android.billingclient.api.SkuDetailsParams.Builder; -import com.android.billingclient.api.SkuDetailsResponseListener; -import com.google.common.base.Joiner; -import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.Observer; import java.util.List; -import javax.inject.Inject; -import org.tasks.BuildConfig; -import org.tasks.LocalBroadcastManager; -import org.tasks.analytics.Tracker; -import org.tasks.injection.ForApplication; -import timber.log.Timber; - -@SuppressWarnings("all") -public class BillingClient implements PurchasesUpdatedListener { - - private final Inventory inventory; - private final LocalBroadcastManager localBroadcastManager; - private final Tracker tracker; - - private com.android.billingclient.api.BillingClient billingClient; - private boolean connected; - private int billingClientResponseCode = -1; - - @Inject - public BillingClient( - @ForApplication Context context, - Inventory inventory, - LocalBroadcastManager localBroadcastManager, - Tracker tracker) { - this.inventory = inventory; - this.localBroadcastManager = localBroadcastManager; - this.tracker = tracker; - billingClient = - com.android.billingclient.api.BillingClient.newBuilder(context).setListener(this).build(); - } - - 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"; - } - } - - /** - * Query purchases across various use cases and deliver the result in a formalized way through a - * listener - */ - public void queryPurchases() { - Runnable queryToExecute = - () -> { - Single purchases = - Single.fromCallable(() -> billingClient.queryPurchases(SkuType.INAPP)); - if (areSubscriptionsSupported()) { - purchases = - Single.zip( - purchases, - Single.fromCallable(() -> billingClient.queryPurchases(SkuType.SUBS)), - (iaps, subs) -> { - if (subs.getResponseCode() == BillingResponse.OK) { - iaps.getPurchasesList().addAll(subs.getPurchasesList()); - } - return iaps; - }); - } - purchases - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onQueryPurchasesFinished); - }; - - executeServiceRequest(queryToExecute); - } - - /** Handle a result from querying of purchases and report an updated list to the listener */ - private void onQueryPurchasesFinished(PurchasesResult result) { - assertMainThread(); - - // Have we been disposed of in the meantime? If so, or bad result code, then quit - if (billingClient == null || result.getResponseCode() != BillingResponse.OK) { - Timber.w( - "Billing client was null or result code (%s) was bad - quitting", - result.getResponseCode()); - return; - } - - Timber.d("Query inventory was successful."); - - // Update the UI and purchases inventory with new list of purchases - inventory.clear(); - add(result.getPurchasesList()); - } - - @Override - public void onPurchasesUpdated(@BillingResponse int resultCode, List purchases) { - if (resultCode == BillingResponse.OK) { - add(purchases); - } - String skus = - purchases == null ? "null" : Joiner.on(";").join(transform(purchases, Purchase::getSku)); - Timber.i("onPurchasesUpdated(%s, %s)", BillingResponseToString(resultCode), skus); - tracker.reportIabResult(resultCode, skus); - } - - private void add(List purchases) { - inventory.add(purchases); - localBroadcastManager.broadcastPurchasesUpdated(); - } - - /** Start a purchase flow */ - void initiatePurchaseFlow( - Activity activity, final String skuId, final @SkuType String billingType) { - Runnable purchaseFlowRequest = - () -> { - Timber.d("Launching in-app purchase flow"); - BillingFlowParams purchaseParams = - BillingFlowParams.newBuilder() - .setSku(skuId) - .setType(billingType) - .setOldSkus(null) - .build(); - billingClient.launchBillingFlow(activity, purchaseParams); - }; - - executeServiceRequest(purchaseFlowRequest); - } - - public void destroy() { - Timber.d("Destroying the manager."); - - if (billingClient != null && billingClient.isReady()) { - billingClient.endConnection(); - billingClient = null; - } - } - - private void startServiceConnection(final Runnable executeOnSuccess) { - billingClient.startConnection( - new com.android.billingclient.api.BillingClientStateListener() { - @Override - public void onBillingSetupFinished(@BillingResponse int billingResponseCode) { - Timber.d("onBillingSetupFinished(%s)", billingResponseCode); - - if (billingResponseCode == BillingResponse.OK) { - connected = true; - if (executeOnSuccess != null) { - executeOnSuccess.run(); - } - } - billingClientResponseCode = billingResponseCode; - } - - @Override - public void onBillingServiceDisconnected() { - Timber.d("onBillingServiceDisconnected()"); - connected = false; - } - }); - } - private void executeServiceRequest(Runnable runnable) { - if (connected) { - runnable.run(); - } else { - // If billing service was disconnected, we try to reconnect 1 time. - // (feel free to introduce your retry policy here). - startServiceConnection(runnable); - } - } +public interface BillingClient { + void queryPurchases(); - /** - * Checks if subscriptions are supported for current client - * - *

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; - } + void querySkuDetails(); - 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); - } + void observeSkuDetails( + LifecycleOwner owner, + Observer> subscriptionObserver, + Observer> iapObserver); - 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(); - }; + int getErrorMessage(); - Runnable request = - () -> - billingClient.consumeAsync( - inventory.getPurchase(sku).getPurchaseToken(), onConsumeListener); - executeServiceRequest(request); - } + void consume(String sku); - public int getBillingClientResponseCode() { - return billingClientResponseCode; - } + void initiatePurchaseFlow(Activity activity, String sku, String skuType); } diff --git a/app/src/main/java/org/tasks/billing/Inventory.java b/app/src/main/java/org/tasks/billing/Inventory.java index 1d4225088..32acccc92 100644 --- a/app/src/main/java/org/tasks/billing/Inventory.java +++ b/app/src/main/java/org/tasks/billing/Inventory.java @@ -1,12 +1,14 @@ package org.tasks.billing; -import com.android.billingclient.api.Purchase; +import static java.util.Collections.singletonList; + import com.google.common.collect.ImmutableList; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.inject.Inject; import org.tasks.BuildConfig; +import org.tasks.LocalBroadcastManager; import org.tasks.R; import org.tasks.injection.ApplicationScope; import org.tasks.preferences.Preferences; @@ -19,20 +21,23 @@ public class Inventory { static final String SKU_TASKER = "tasker"; static final String SKU_THEMES = "themes"; static final String SKU_DASHCLOCK = "dashclock"; - private static final String SKU_PRO = "annual_499"; - public static final List SKU_SUBS = ImmutableList.of(SKU_PRO); private final Preferences preferences; private final SignatureVerifier signatureVerifier; + private final LocalBroadcastManager localBroadcastManager; private Map purchases = new HashMap<>(); @Inject - public Inventory(Preferences preferences, SignatureVerifier signatureVerifier) { + public Inventory( + Preferences preferences, + SignatureVerifier signatureVerifier, + LocalBroadcastManager localBroadcastManager) { this.preferences = preferences; this.signatureVerifier = signatureVerifier; + this.localBroadcastManager = localBroadcastManager; for (Purchase purchase : preferences.getPurchases()) { - add(purchase); + verifyAndAdd(purchase); } } @@ -41,14 +46,19 @@ public class Inventory { purchases.clear(); } - public void add(List purchases) { + public void add(Purchase purchase) { + add(singletonList(purchase)); + } + + public void add(Iterable purchases) { for (Purchase purchase : purchases) { - add(purchase); + verifyAndAdd(purchase); } preferences.setPurchases(this.purchases.values()); + localBroadcastManager.broadcastPurchasesUpdated(); } - private void add(Purchase purchase) { + private void verifyAndAdd(Purchase purchase) { if (signatureVerifier.verifySignature(purchase)) { Timber.d("add(%s)", purchase); purchases.put(purchase.getSku(), purchase); @@ -73,7 +83,7 @@ public class Inventory { public boolean hasPro() { //noinspection ConstantConditions - return purchases.containsKey(SKU_PRO) + return purchases.containsKey(SkuDetails.SKU_PRO) || purchases.containsKey(SKU_VIP) || BuildConfig.FLAVOR.equals("generic") || (BuildConfig.DEBUG && preferences.getBoolean(R.string.p_debug_pro, false)); diff --git a/app/src/main/java/org/tasks/billing/PurchaseActivity.java b/app/src/main/java/org/tasks/billing/PurchaseActivity.java index f133afd6b..af6a62629 100644 --- a/app/src/main/java/org/tasks/billing/PurchaseActivity.java +++ b/app/src/main/java/org/tasks/billing/PurchaseActivity.java @@ -1,14 +1,6 @@ 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; @@ -25,12 +17,8 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; 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.Collections; import java.util.List; import javax.inject.Inject; import org.tasks.BuildConfig; @@ -42,14 +30,10 @@ import org.tasks.injection.ActivityComponent; import org.tasks.injection.ForApplication; import org.tasks.injection.ThemedInjectingAppCompatActivity; 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; @@ -72,9 +56,11 @@ public class PurchaseActivity extends ThemedInjectingAppCompatActivity new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { - querySkuDetails(); + billingClient.querySkuDetails(); } }; + private List iaps = Collections.emptyList(); + private List subscriptions = Collections.emptyList(); @Override protected void onCreate(Bundle savedInstanceState) { @@ -101,14 +87,14 @@ public class PurchaseActivity extends ThemedInjectingAppCompatActivity (int) res.getDimension(R.dimen.row_gap))); recyclerView.setLayoutManager(new LinearLayoutManager(context)); setWaitScreen(true); - querySkuDetails(); } @Override protected void onResume() { super.onResume(); - querySkuDetails(); + billingClient.observeSkuDetails(this, this::onSubscriptionsUpdated, this::onIapsUpdated); + billingClient.querySkuDetails(); } @Override @@ -125,83 +111,35 @@ public class PurchaseActivity extends ThemedInjectingAppCompatActivity 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 onIapsUpdated(List iaps) { + this.iaps = iaps; + updateSkuDetails(); } - 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 onSubscriptionsUpdated(List subscriptions) { + this.subscriptions = subscriptions; + updateSkuDetails(); + } + + private void updateSkuDetails() { + List data = new ArrayList<>(transform(subscriptions, SkuRowData::new)); + if (iaps.size() > 0) { + data.add(new SkuRowData(context.getString(R.string.owned))); + data.addAll(transform(iaps, SkuRowData::new)); + } + if (data.isEmpty()) { + displayAnErrorIfNeeded(); + } else { + adapter.setData(data); + setWaitScreen(false); + } } 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); + errorTextView.setText(billingClient.getErrorMessage()); } } @@ -217,7 +155,7 @@ public class PurchaseActivity extends ThemedInjectingAppCompatActivity @Override public void clickAux(SkuRowData skuRowData) { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("http://tasks.org/subscribe"))); + startSubscribeActivity(); } @Override @@ -225,7 +163,7 @@ public class PurchaseActivity extends ThemedInjectingAppCompatActivity String sku = skuRowData.getSku(); String skuType = skuRowData.getSkuType(); if (inventory.purchased(sku)) { - if (BuildConfig.DEBUG && SkuType.INAPP.equals(skuType)) { + if (BuildConfig.DEBUG && SkuDetails.TYPE_INAPP.equals(skuType)) { billingClient.consume(sku); } } else { @@ -237,7 +175,7 @@ public class PurchaseActivity extends ThemedInjectingAppCompatActivity public boolean onMenuItemClick(MenuItem item) { switch (item.getItemId()) { case R.id.menu_help: - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("http://tasks.org/subscribe"))); + startSubscribeActivity(); return true; case R.id.menu_refresh_purchases: billingClient.queryPurchases(); @@ -246,4 +184,8 @@ public class PurchaseActivity extends ThemedInjectingAppCompatActivity return false; } } + + private void startSubscribeActivity() { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("http://tasks.org/subscribe"))); + } } diff --git a/app/src/main/java/org/tasks/billing/SkusAdapter.java b/app/src/main/java/org/tasks/billing/SkusAdapter.java index cc26a264b..03827908b 100644 --- a/app/src/main/java/org/tasks/billing/SkusAdapter.java +++ b/app/src/main/java/org/tasks/billing/SkusAdapter.java @@ -26,7 +26,6 @@ import android.view.View; import android.view.ViewGroup; import androidx.annotation.IntDef; import androidx.recyclerview.widget.RecyclerView; -import com.android.billingclient.api.BillingClient.SkuType; import com.google.common.base.Joiner; import com.google.common.collect.ImmutableList; import java.lang.annotation.Retention; @@ -90,7 +89,7 @@ public class SkusAdapter extends RecyclerView.Adapter if (getItemViewType(position) != SkusAdapter.TYPE_HEADER) { String sku = data.getSku(); - if (SkuType.SUBS.equals(data.getSkuType())) { + if (SkuDetails.TYPE_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))); diff --git a/app/src/main/java/org/tasks/billing/row/SkuRowData.java b/app/src/main/java/org/tasks/billing/row/SkuRowData.java index 028b5cf90..f9070d98d 100644 --- a/app/src/main/java/org/tasks/billing/row/SkuRowData.java +++ b/app/src/main/java/org/tasks/billing/row/SkuRowData.java @@ -16,8 +16,7 @@ package org.tasks.billing.row; -import com.android.billingclient.api.BillingClient.SkuType; -import com.android.billingclient.api.SkuDetails; +import org.tasks.billing.SkuDetails; import org.tasks.billing.SkusAdapter; import org.tasks.billing.SkusAdapter.RowTypeDef; @@ -25,20 +24,20 @@ import org.tasks.billing.SkusAdapter.RowTypeDef; public class SkuRowData { private String sku, title, price, description; private @RowTypeDef int type; - private @SkuType String billingType; + private 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(SkuDetails details) { + sku = details.getSku(); + title = details.getTitle(); + price = details.getPrice(); + description = details.getDescription(); + type = SkusAdapter.TYPE_NORMAL; + billingType = details.getSkuType(); } public SkuRowData(String title) { this.title = title; - this.type = SkusAdapter.TYPE_HEADER; + type = SkusAdapter.TYPE_HEADER; } public String getSku() { @@ -61,7 +60,7 @@ public class SkuRowData { return type; } - public @SkuType String getSkuType() { + public String getSkuType() { return billingType; } } diff --git a/app/src/main/java/org/tasks/dashclock/DashClockSettings.java b/app/src/main/java/org/tasks/dashclock/DashClockSettings.java index d1233f888..153a5a541 100644 --- a/app/src/main/java/org/tasks/dashclock/DashClockSettings.java +++ b/app/src/main/java/org/tasks/dashclock/DashClockSettings.java @@ -8,7 +8,6 @@ import javax.inject.Inject; import org.tasks.LocalBroadcastManager; import org.tasks.R; import org.tasks.activities.FilterSelectionActivity; -import org.tasks.billing.BillingClient; import org.tasks.billing.Inventory; import org.tasks.billing.PurchaseActivity; import org.tasks.injection.ActivityComponent; @@ -22,7 +21,6 @@ public class DashClockSettings extends InjectingPreferenceActivity { @Inject DefaultFilterProvider defaultFilterProvider; @Inject LocalBroadcastManager localBroadcastManager; - @Inject BillingClient billingClient; @Inject Inventory inventory; @Override diff --git a/app/src/main/java/org/tasks/injection/ApplicationModule.java b/app/src/main/java/org/tasks/injection/ApplicationModule.java index 6f5233bb1..f3bb1eb6f 100644 --- a/app/src/main/java/org/tasks/injection/ApplicationModule.java +++ b/app/src/main/java/org/tasks/injection/ApplicationModule.java @@ -8,6 +8,10 @@ import com.todoroo.astrid.dao.Database; import com.todoroo.astrid.dao.TaskDao; import dagger.Module; import dagger.Provides; +import org.tasks.analytics.Tracker; +import org.tasks.billing.BillingClient; +import org.tasks.billing.BillingClientImpl; +import org.tasks.billing.Inventory; import org.tasks.data.AlarmDao; import org.tasks.data.CaldavDao; import org.tasks.data.DeletionDao; @@ -148,4 +152,9 @@ public class ApplicationModule { public Encryption getEncryption() { return atLeastMarshmallow() ? new KeyStoreEncryption() : new NoEncryption(); } + + @Provides + public BillingClient getBillingClient(Inventory inventory, Tracker tracker) { + return new BillingClientImpl(context, inventory, tracker); + } } 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 d0fa449b7..efb81cc4d 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 @@ -13,7 +13,6 @@ import javax.inject.Inject; import net.dinglisch.android.tasker.TaskerPlugin; import org.tasks.LocalBroadcastManager; import org.tasks.R; -import org.tasks.billing.BillingClient; import org.tasks.billing.Inventory; import org.tasks.billing.PurchaseActivity; import org.tasks.injection.ActivityComponent; @@ -27,7 +26,6 @@ public final class TaskerCreateTaskActivity extends AbstractFragmentPluginAppCom private static final int REQUEST_SUBSCRIPTION = 10101; @Inject Preferences preferences; - @Inject BillingClient billingClient; @Inject Inventory inventory; @Inject LocalBroadcastManager localBroadcastManager; diff --git a/app/src/main/java/org/tasks/preferences/BasicPreferences.java b/app/src/main/java/org/tasks/preferences/BasicPreferences.java index 38d09d9dd..d1099a8be 100644 --- a/app/src/main/java/org/tasks/preferences/BasicPreferences.java +++ b/app/src/main/java/org/tasks/preferences/BasicPreferences.java @@ -210,10 +210,7 @@ public class BasicPreferences extends InjectingPreferenceActivity upgradeToPro.setOnPreferenceClickListener( p -> { startActivity( - new Intent( - Intent.ACTION_VIEW, - Uri.parse( - "https://play.google.com/store/account/subscriptions?sku=annual_499&package=org.tasks"))); + new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.manage_subscription_url)))); return false; }); } else { diff --git a/app/src/main/java/org/tasks/preferences/Preferences.java b/app/src/main/java/org/tasks/preferences/Preferences.java index 4bbf9f364..ea65666d6 100644 --- a/app/src/main/java/org/tasks/preferences/Preferences.java +++ b/app/src/main/java/org/tasks/preferences/Preferences.java @@ -19,9 +19,7 @@ import android.preference.PreferenceManager; import android.text.TextUtils; import androidx.core.app.NotificationCompat; import androidx.documentfile.provider.DocumentFile; -import com.android.billingclient.api.Purchase; import com.google.common.base.Strings; -import com.google.gson.GsonBuilder; import com.todoroo.astrid.activity.BeastModePreferences; import com.todoroo.astrid.api.AstridApiConstants; import com.todoroo.astrid.core.SortHelper; @@ -33,6 +31,7 @@ import javax.inject.Inject; import org.jetbrains.annotations.Nullable; import org.tasks.BuildConfig; import org.tasks.R; +import org.tasks.billing.Purchase; import org.tasks.data.TaskAttachment; import org.tasks.injection.ForApplication; import org.tasks.time.DateTime; @@ -125,9 +124,7 @@ public class Preferences { 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)); + prefs.getStringSet(context.getString(R.string.p_purchases), emptySet()), Purchase::new); } catch (Exception e) { Timber.e(e); return emptySet(); @@ -139,7 +136,7 @@ public class Preferences { Editor editor = prefs.edit(); editor.putStringSet( context.getString(R.string.p_purchases), - newHashSet(transform(purchases, p -> new GsonBuilder().create().toJson(p)))); + newHashSet(transform(purchases, Purchase::toJson))); editor.apply(); } catch (Exception e) { Timber.e(e);