mirror of https://github.com/tasks/tasks
Convert billing to Kotlin
parent
b923eae86b
commit
a5663cd299
@ -1,240 +0,0 @@
|
|||||||
package org.tasks.billing;
|
|
||||||
|
|
||||||
import static com.google.common.collect.Lists.newArrayList;
|
|
||||||
import static com.todoroo.andlib.utility.AndroidUtilities.assertMainThread;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.Context;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
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.BillingFlowParams.ProrationMode;
|
|
||||||
import com.android.billingclient.api.ConsumeResponseListener;
|
|
||||||
import com.android.billingclient.api.Purchase.PurchasesResult;
|
|
||||||
import com.android.billingclient.api.PurchasesUpdatedListener;
|
|
||||||
import com.google.common.base.Joiner;
|
|
||||||
import com.google.common.collect.Iterables;
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext;
|
|
||||||
import io.reactivex.Single;
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
|
||||||
import io.reactivex.schedulers.Schedulers;
|
|
||||||
import java.util.List;
|
|
||||||
import org.tasks.BuildConfig;
|
|
||||||
import org.tasks.analytics.Firebase;
|
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
@SuppressWarnings("all")
|
|
||||||
public class BillingClientImpl implements BillingClient, PurchasesUpdatedListener {
|
|
||||||
|
|
||||||
public static final String TYPE_SUBS = SkuType.SUBS;
|
|
||||||
|
|
||||||
private final Inventory inventory;
|
|
||||||
private final Firebase firebase;
|
|
||||||
private com.android.billingclient.api.BillingClient billingClient;
|
|
||||||
private boolean connected;
|
|
||||||
private OnPurchasesUpdated onPurchasesUpdated;
|
|
||||||
|
|
||||||
public BillingClientImpl(@ApplicationContext Context context, Inventory inventory, Firebase firebase) {
|
|
||||||
this.inventory = inventory;
|
|
||||||
this.firebase = firebase;
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
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 (iaps.getResponseCode() != BillingResponse.OK) {
|
|
||||||
return iaps;
|
|
||||||
}
|
|
||||||
if (subs.getResponseCode() != BillingResponse.OK) {
|
|
||||||
return subs;
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
boolean success = resultCode == BillingResponse.OK;
|
|
||||||
if (success) {
|
|
||||||
add(purchases);
|
|
||||||
}
|
|
||||||
if (onPurchasesUpdated != null) {
|
|
||||||
onPurchasesUpdated.onPurchasesUpdated(success);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
firebase.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, @Nullable String oldSku) {
|
|
||||||
executeServiceRequest(
|
|
||||||
() ->
|
|
||||||
billingClient.launchBillingFlow(
|
|
||||||
activity,
|
|
||||||
BillingFlowParams.newBuilder()
|
|
||||||
.setSku(skuId)
|
|
||||||
.setType(billingType)
|
|
||||||
.setOldSkus(oldSku == null ? null : newArrayList(oldSku))
|
|
||||||
.setReplaceSkusProrationMode(ProrationMode.IMMEDIATE_WITH_TIME_PRORATION)
|
|
||||||
.build()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void addPurchaseCallback(OnPurchasesUpdated onPurchasesUpdated) {
|
|
||||||
this.onPurchasesUpdated = onPurchasesUpdated;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,196 @@
|
|||||||
|
package org.tasks.billing
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import com.android.billingclient.api.BillingClient.*
|
||||||
|
import com.android.billingclient.api.BillingClientStateListener
|
||||||
|
import com.android.billingclient.api.BillingFlowParams
|
||||||
|
import com.android.billingclient.api.BillingFlowParams.ProrationMode
|
||||||
|
import com.android.billingclient.api.ConsumeResponseListener
|
||||||
|
import com.android.billingclient.api.Purchase.PurchasesResult
|
||||||
|
import com.android.billingclient.api.PurchasesUpdatedListener
|
||||||
|
import com.todoroo.andlib.utility.AndroidUtilities
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import io.reactivex.Single
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.tasks.BuildConfig
|
||||||
|
import org.tasks.analytics.Firebase
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class BillingClientImpl(
|
||||||
|
@ApplicationContext context: Context?,
|
||||||
|
private val inventory: Inventory,
|
||||||
|
private val firebase: Firebase
|
||||||
|
) : BillingClient, PurchasesUpdatedListener {
|
||||||
|
private val billingClient = newBuilder(context!!).setListener(this).build()
|
||||||
|
private var connected = false
|
||||||
|
private var onPurchasesUpdated: OnPurchasesUpdated? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query purchases across various use cases and deliver the result in a formalized way through a
|
||||||
|
* listener
|
||||||
|
*/
|
||||||
|
override fun queryPurchases() {
|
||||||
|
val queryToExecute = Runnable {
|
||||||
|
var purchases = Single.fromCallable { billingClient!!.queryPurchases(SkuType.INAPP) }
|
||||||
|
if (areSubscriptionsSupported()) {
|
||||||
|
purchases = Single.zip(
|
||||||
|
purchases,
|
||||||
|
Single.fromCallable { billingClient!!.queryPurchases(SkuType.SUBS) },
|
||||||
|
{ iaps: PurchasesResult, subs: PurchasesResult ->
|
||||||
|
if (iaps.responseCode != BillingResponse.OK) {
|
||||||
|
return@zip iaps
|
||||||
|
}
|
||||||
|
if (subs.responseCode != BillingResponse.OK) {
|
||||||
|
return@zip subs
|
||||||
|
}
|
||||||
|
iaps.purchasesList.addAll(subs.purchasesList)
|
||||||
|
iaps
|
||||||
|
})
|
||||||
|
}
|
||||||
|
purchases
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { result: PurchasesResult -> onQueryPurchasesFinished(result) }
|
||||||
|
}
|
||||||
|
executeServiceRequest(queryToExecute)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a result from querying of purchases and report an updated list to the listener */
|
||||||
|
private fun onQueryPurchasesFinished(result: PurchasesResult) {
|
||||||
|
AndroidUtilities.assertMainThread()
|
||||||
|
|
||||||
|
// Have we been disposed of in the meantime? If so, or bad result code, then quit
|
||||||
|
if (billingClient == null || result.responseCode != BillingResponse.OK) {
|
||||||
|
Timber.w(
|
||||||
|
"Billing client was null or result code (%s) was bad - quitting",
|
||||||
|
result.responseCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Timber.d("Query inventory was successful.")
|
||||||
|
|
||||||
|
// Update the UI and purchases inventory with new list of purchases
|
||||||
|
inventory.clear()
|
||||||
|
add(result.purchasesList)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPurchasesUpdated(
|
||||||
|
@BillingResponse resultCode: Int, purchases: List<com.android.billingclient.api.Purchase>?
|
||||||
|
) {
|
||||||
|
val success = resultCode == BillingResponse.OK
|
||||||
|
if (success) {
|
||||||
|
add(purchases ?: emptyList())
|
||||||
|
}
|
||||||
|
if (onPurchasesUpdated != null) {
|
||||||
|
onPurchasesUpdated!!.onPurchasesUpdated(success)
|
||||||
|
}
|
||||||
|
val skus = purchases?.joinToString(";") { it.sku } ?: "null"
|
||||||
|
Timber.i("onPurchasesUpdated(%s, %s)", BillingResponseToString(resultCode), skus)
|
||||||
|
firebase.reportIabResult(resultCode, skus)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun add(purchases: List<com.android.billingclient.api.Purchase>) {
|
||||||
|
inventory.add(purchases.map { Purchase(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initiatePurchaseFlow(
|
||||||
|
activity: Activity, skuId: String, billingType: String, oldSku: String?
|
||||||
|
) {
|
||||||
|
executeServiceRequest {
|
||||||
|
billingClient!!.launchBillingFlow(
|
||||||
|
activity,
|
||||||
|
BillingFlowParams.newBuilder()
|
||||||
|
.setSku(skuId)
|
||||||
|
.setType(billingType)
|
||||||
|
.setOldSku(oldSku)
|
||||||
|
.setReplaceSkusProrationMode(ProrationMode.IMMEDIATE_WITH_TIME_PRORATION)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addPurchaseCallback(onPurchasesUpdated: OnPurchasesUpdated) {
|
||||||
|
this.onPurchasesUpdated = onPurchasesUpdated
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startServiceConnection(executeOnSuccess: Runnable?) {
|
||||||
|
billingClient!!.startConnection(
|
||||||
|
object : BillingClientStateListener {
|
||||||
|
override fun onBillingSetupFinished(@BillingResponse billingResponseCode: Int) {
|
||||||
|
Timber.d("onBillingSetupFinished(%s)", billingResponseCode)
|
||||||
|
if (billingResponseCode == BillingResponse.OK) {
|
||||||
|
connected = true
|
||||||
|
executeOnSuccess?.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBillingServiceDisconnected() {
|
||||||
|
Timber.d("onBillingServiceDisconnected()")
|
||||||
|
connected = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun 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 fun areSubscriptionsSupported(): Boolean {
|
||||||
|
val responseCode = billingClient!!.isFeatureSupported(FeatureType.SUBSCRIPTIONS)
|
||||||
|
if (responseCode != BillingResponse.OK) {
|
||||||
|
Timber.d("areSubscriptionsSupported() got an error response: %s", responseCode)
|
||||||
|
}
|
||||||
|
return responseCode == BillingResponse.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun consume(sku: String) {
|
||||||
|
check(BuildConfig.DEBUG)
|
||||||
|
require(inventory.purchased(sku))
|
||||||
|
val onConsumeListener =
|
||||||
|
ConsumeResponseListener { responseCode: Int, purchaseToken1: String? ->
|
||||||
|
Timber.d("onConsumeResponse(%s, %s)", responseCode, purchaseToken1)
|
||||||
|
queryPurchases()
|
||||||
|
}
|
||||||
|
executeServiceRequest {
|
||||||
|
billingClient!!.consumeAsync(
|
||||||
|
inventory.getPurchase(sku)!!.purchaseToken, onConsumeListener
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TYPE_SUBS = SkuType.SUBS
|
||||||
|
fun BillingResponseToString(@BillingResponse response: Int): String {
|
||||||
|
return when (response) {
|
||||||
|
BillingResponse.FEATURE_NOT_SUPPORTED -> "FEATURE_NOT_SUPPORTED"
|
||||||
|
BillingResponse.SERVICE_DISCONNECTED -> "SERVICE_DISCONNECTED"
|
||||||
|
BillingResponse.OK -> "OK"
|
||||||
|
BillingResponse.USER_CANCELED -> "USER_CANCELED"
|
||||||
|
BillingResponse.SERVICE_UNAVAILABLE -> "SERVICE_UNAVAILABLE"
|
||||||
|
BillingResponse.BILLING_UNAVAILABLE -> "BILLING_UNAVAILABLE"
|
||||||
|
BillingResponse.ITEM_UNAVAILABLE -> "ITEM_UNAVAILABLE"
|
||||||
|
BillingResponse.DEVELOPER_ERROR -> "DEVELOPER_ERROR"
|
||||||
|
BillingResponse.ERROR -> "ERROR"
|
||||||
|
BillingResponse.ITEM_ALREADY_OWNED -> "ITEM_ALREADY_OWNED"
|
||||||
|
BillingResponse.ITEM_NOT_OWNED -> "ITEM_NOT_OWNED"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,121 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2012 Google Inc.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.tasks.billing;
|
|
||||||
|
|
||||||
import static org.tasks.Strings.isNullOrEmpty;
|
|
||||||
|
|
||||||
import android.util.Base64;
|
|
||||||
import com.android.billingclient.util.BillingHelper;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.KeyFactory;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.security.PublicKey;
|
|
||||||
import java.security.Signature;
|
|
||||||
import java.security.SignatureException;
|
|
||||||
import java.security.spec.InvalidKeySpecException;
|
|
||||||
import java.security.spec.X509EncodedKeySpec;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Security-related methods. For a secure implementation, all of this code should be implemented on
|
|
||||||
* a server that communicates with the application on the device.
|
|
||||||
*/
|
|
||||||
class Security {
|
|
||||||
private static final String TAG = "IABUtil/Security";
|
|
||||||
|
|
||||||
private static final String KEY_FACTORY_ALGORITHM = "RSA";
|
|
||||||
private static final String SIGNATURE_ALGORITHM = "SHA1withRSA";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that the data was signed with the given signature, and returns the verified purchase.
|
|
||||||
*
|
|
||||||
* @param base64PublicKey the base64-encoded public key to use for verifying.
|
|
||||||
* @param signedData the signed JSON string (signed, not encrypted)
|
|
||||||
* @param signature the signature for the data, signed with the private key
|
|
||||||
* @throws IOException if encoding algorithm is not supported or key specification is invalid
|
|
||||||
*/
|
|
||||||
static boolean verifyPurchase(String base64PublicKey, String signedData, String signature)
|
|
||||||
throws IOException {
|
|
||||||
if (isNullOrEmpty(signedData)
|
|
||||||
|| isNullOrEmpty(base64PublicKey)
|
|
||||||
|| isNullOrEmpty(signature)) {
|
|
||||||
BillingHelper.logWarn(TAG, "Purchase verification failed: missing data.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
PublicKey key = generatePublicKey(base64PublicKey);
|
|
||||||
return verify(key, signedData, signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a PublicKey instance from a string containing the Base64-encoded public key.
|
|
||||||
*
|
|
||||||
* @param encodedPublicKey Base64-encoded public key
|
|
||||||
* @throws IOException if encoding algorithm is not supported or key specification is invalid
|
|
||||||
*/
|
|
||||||
private static PublicKey generatePublicKey(String encodedPublicKey) throws IOException {
|
|
||||||
try {
|
|
||||||
byte[] decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT);
|
|
||||||
KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM);
|
|
||||||
return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey));
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
// "RSA" is guaranteed to be available.
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
} catch (InvalidKeySpecException e) {
|
|
||||||
String msg = "Invalid key specification: " + e;
|
|
||||||
BillingHelper.logWarn(TAG, msg);
|
|
||||||
throw new IOException(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that the signature from the server matches the computed signature on the data. Returns
|
|
||||||
* true if the data is correctly signed.
|
|
||||||
*
|
|
||||||
* @param publicKey public key associated with the developer account
|
|
||||||
* @param signedData signed data from server
|
|
||||||
* @param signature server signature
|
|
||||||
* @return true if the data and signature match
|
|
||||||
*/
|
|
||||||
private static boolean verify(PublicKey publicKey, String signedData, String signature) {
|
|
||||||
byte[] signatureBytes;
|
|
||||||
try {
|
|
||||||
signatureBytes = Base64.decode(signature, Base64.DEFAULT);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
BillingHelper.logWarn(TAG, "Base64 decoding failed.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Signature signatureAlgorithm = Signature.getInstance(SIGNATURE_ALGORITHM);
|
|
||||||
signatureAlgorithm.initVerify(publicKey);
|
|
||||||
signatureAlgorithm.update(signedData.getBytes());
|
|
||||||
if (!signatureAlgorithm.verify(signatureBytes)) {
|
|
||||||
BillingHelper.logWarn(TAG, "Signature verification failed.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
// "RSA" is guaranteed to be available.
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
} catch (InvalidKeyException e) {
|
|
||||||
BillingHelper.logWarn(TAG, "Invalid key specification.");
|
|
||||||
} catch (SignatureException e) {
|
|
||||||
BillingHelper.logWarn(TAG, "Signature exception.");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (C) 2018 Google Inc. All Rights Reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package org.tasks.billing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is an addendum. It shouldn't really be here: it should be on the secure server. But if
|
||||||
|
* a secure server does not exist, it's still good to check the signature of the purchases coming
|
||||||
|
* from Google Play. At the very least, it will combat man-in-the-middle attacks. Putting it
|
||||||
|
* on the server would provide the additional protection against hackers who may
|
||||||
|
* decompile/rebuild the app.
|
||||||
|
*
|
||||||
|
* Sigh... All sorts of attacks can befall your app, website, or platform. So when it comes to
|
||||||
|
* implementing security measures, you have to be realistic and judicious so that user experience
|
||||||
|
* does not suffer needlessly. And you should analyze that the money you will save (minus cost of labor)
|
||||||
|
* by implementing security measure X is greater than the money you would lose if you don't
|
||||||
|
* implement X. Talk to a UX designer if you find yourself obsessing over security.
|
||||||
|
*
|
||||||
|
* The good news is, in implementing [BillingRepository], a number of measures is taken to help
|
||||||
|
* prevent fraudulent activities in your app. We don't just focus on tech savvy hackers, but also
|
||||||
|
* on fraudulent users who may want to exploit loopholes. Just to name an obvious case:
|
||||||
|
* triangulation using Google Play, your secure server, and a local cache helps against non-techie
|
||||||
|
* frauds.
|
||||||
|
*/
|
||||||
|
import android.text.TextUtils
|
||||||
|
import android.util.Base64
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.InvalidKeyException
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.NoSuchAlgorithmException
|
||||||
|
import java.security.PublicKey
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.SignatureException
|
||||||
|
import java.security.spec.InvalidKeySpecException
|
||||||
|
import java.security.spec.X509EncodedKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security-related methods. For a secure implementation, all of this code should be implemented on
|
||||||
|
* a server that communicates with the application on the device.
|
||||||
|
*/
|
||||||
|
object Security {
|
||||||
|
private const val KEY_FACTORY_ALGORITHM = "RSA"
|
||||||
|
private const val SIGNATURE_ALGORITHM = "SHA1withRSA"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that the data was signed with the given signature
|
||||||
|
*
|
||||||
|
* @param base64PublicKey the base64-encoded public key to use for verifying.
|
||||||
|
* @param signedData the signed JSON string (signed, not encrypted)
|
||||||
|
* @param signature the signature for the data, signed with the private key
|
||||||
|
* @throws IOException if encoding algorithm is not supported or key specification
|
||||||
|
* is invalid
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun verifyPurchase(base64PublicKey: String, signedData: String, signature: String): Boolean {
|
||||||
|
if ((TextUtils.isEmpty(signedData) || TextUtils.isEmpty(base64PublicKey)
|
||||||
|
|| TextUtils.isEmpty(signature))
|
||||||
|
) {
|
||||||
|
Timber.w("Purchase verification failed: missing data.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val key = generatePublicKey(base64PublicKey)
|
||||||
|
return verify(key, signedData, signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a PublicKey instance from a string containing the Base64-encoded public key.
|
||||||
|
*
|
||||||
|
* @param encodedPublicKey Base64-encoded public key
|
||||||
|
* @throws IOException if encoding algorithm is not supported or key specification
|
||||||
|
* is invalid
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun generatePublicKey(encodedPublicKey: String): PublicKey {
|
||||||
|
try {
|
||||||
|
val decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT)
|
||||||
|
val keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM)
|
||||||
|
return keyFactory.generatePublic(X509EncodedKeySpec(decodedKey))
|
||||||
|
} catch (e: NoSuchAlgorithmException) {
|
||||||
|
// "RSA" is guaranteed to be available.
|
||||||
|
throw RuntimeException(e)
|
||||||
|
} catch (e: InvalidKeySpecException) {
|
||||||
|
val msg = "Invalid key specification: $e"
|
||||||
|
Timber.w(msg)
|
||||||
|
throw IOException(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that the signature from the server matches the computed signature on the data.
|
||||||
|
* Returns true if the data is correctly signed.
|
||||||
|
*
|
||||||
|
* @param publicKey public key associated with the developer account
|
||||||
|
* @param signedData signed data from server
|
||||||
|
* @param signature server signature
|
||||||
|
* @return true if the data and signature match
|
||||||
|
*/
|
||||||
|
private fun verify(publicKey: PublicKey, signedData: String, signature: String): Boolean {
|
||||||
|
val signatureBytes: ByteArray
|
||||||
|
try {
|
||||||
|
signatureBytes = Base64.decode(signature, Base64.DEFAULT)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Timber.w("Base64 decoding failed.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val signatureAlgorithm = Signature.getInstance(SIGNATURE_ALGORITHM)
|
||||||
|
signatureAlgorithm.initVerify(publicKey)
|
||||||
|
signatureAlgorithm.update(signedData.toByteArray())
|
||||||
|
if (!signatureAlgorithm.verify(signatureBytes)) {
|
||||||
|
Timber.w("Signature verification failed...")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (e: NoSuchAlgorithmException) {
|
||||||
|
// "RSA" is guaranteed to be available.
|
||||||
|
throw RuntimeException(e)
|
||||||
|
} catch (e: InvalidKeyException) {
|
||||||
|
Timber.w("Invalid key specification.")
|
||||||
|
} catch (e: SignatureException) {
|
||||||
|
Timber.w("Signature exception.")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +0,0 @@
|
|||||||
package org.tasks.billing;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
public interface BillingClient {
|
|
||||||
void queryPurchases();
|
|
||||||
|
|
||||||
void consume(String sku);
|
|
||||||
|
|
||||||
void initiatePurchaseFlow(Activity activity, String sku, String skuType, @Nullable String oldSku);
|
|
||||||
|
|
||||||
void addPurchaseCallback(OnPurchasesUpdated onPurchasesUpdated);
|
|
||||||
}
|
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package org.tasks.billing
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
|
||||||
|
interface BillingClient {
|
||||||
|
fun queryPurchases()
|
||||||
|
fun consume(sku: String)
|
||||||
|
fun initiatePurchaseFlow(activity: Activity, sku: String, skuType: String, oldSku: String?)
|
||||||
|
fun addPurchaseCallback(onPurchasesUpdated: OnPurchasesUpdated)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue