Integrate amazon billing

pull/795/head
Alex Baker 5 years ago
parent 57ae4d15fe
commit 76812c00ba

@ -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}"

@ -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);
}
}

@ -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<List<SkuDetails>> 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<List<SkuDetails>> subscriptionObserver,
Observer<List<SkuDetails>> 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));
}
}
}

@ -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 + '}';
}
}

@ -1,6 +1,5 @@
package org.tasks.billing;
import com.android.billingclient.api.Purchase;
import javax.inject.Inject;
public class SignatureVerifier {

@ -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();
}
}

@ -4,4 +4,5 @@
<string name="common_google_play_services_notification_ticker">Google Play services error</string>
<string name="market_url">amzn://apps/android?p=org.tasks</string>
<string name="p_purchases">purchases_amazon</string>
<string name="manage_subscription_url">https://www.amazon.com/gp/mas/your-account/myapps/yoursubscriptions/ref=d2_ss_app_sb_myas</string>
</resources>

@ -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<List<SkuDetails>> subscriptionObserver,
Observer<List<SkuDetails>> iapObserver) {}
@Override
public void consume(String sku) {}
}

@ -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;
}
}

@ -1,6 +1,5 @@
package org.tasks.billing;
import com.android.billingclient.api.Purchase;
import javax.inject.Inject;
public class SignatureVerifier {

@ -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;
}
}

@ -4,4 +4,6 @@
<string name="common_google_play_services_notification_ticker">Google Play services error</string>
<string name="market_url">https://f-droid.org/en/packages/org.tasks/</string>
<string name="p_purchases">purchases_fdroid</string>
<!--suppress CheckTagEmptyBody -->
<string name="manage_subscription_url"></string>
</resources>

@ -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;

@ -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<String> DEBUG_SKUS =
ImmutableList.of(SKU_THEMES, SKU_TASKER, SKU_DASHCLOCK, SKU_VIP);
private final MutableLiveData<List<SkuDetails>> skuDetails = new MutableLiveData<>();
private final Inventory inventory;
private final Tracker tracker;
MutableLiveData<List<SkuDetails>> subscriptions = new MutableLiveData<>();
MutableLiveData<List<SkuDetails>> 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<PurchasesResult> 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<com.android.billingclient.api.Purchase> 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<com.android.billingclient.api.Purchase> 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
*
* <p>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<List<SkuDetails>> subscriptionObserver,
Observer<List<SkuDetails>> 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<com.android.billingclient.api.SkuDetails> 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<String> 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<com.android.billingclient.api.SkuDetails> 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;
}
}

@ -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 + '}';
}
}

@ -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;

@ -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<String> 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();
}
}

@ -4,4 +4,5 @@
<string name="play_services_available">play_services_available</string>
<string name="market_url">market://details?id=org.tasks</string>
<string name="p_purchases">purchases</string>
<string name="manage_subscription_url">https://play.google.com/store/account/subscriptions?sku=annual_499&amp;package=org.tasks</string>
</resources>

@ -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;

@ -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<PurchasesResult> 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<Purchase> 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<Purchase> 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
*
* <p>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<String> 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<List<SkuDetails>> subscriptionObserver,
Observer<List<SkuDetails>> 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);
}

@ -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<String> SKU_SUBS = ImmutableList.of(SKU_PRO);
private final Preferences preferences;
private final SignatureVerifier signatureVerifier;
private final LocalBroadcastManager localBroadcastManager;
private Map<String, Purchase> 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<Purchase> purchases) {
public void add(Purchase purchase) {
add(singletonList(purchase));
}
public void add(Iterable<Purchase> 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));

@ -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<String> 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<SkuDetails> iaps = Collections.emptyList();
private List<SkuDetails> 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<SkuRowData> 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<SkuDetails> iaps) {
this.iaps = iaps;
updateSkuDetails();
}
private void addSkuRows(
List<SkuRowData> data,
List<String> 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<SkuDetails> subscriptions) {
this.subscriptions = subscriptions;
updateSkuDetails();
}
private void updateSkuDetails() {
List<SkuRowData> 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")));
}
}

@ -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<RowViewHolder>
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)));

@ -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;
}
}

@ -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

@ -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);
}
}

@ -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;

@ -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 {

@ -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<Purchase> 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);

Loading…
Cancel
Save