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