diff --git a/app/src/debug/java/org/tasks/preferences/fragments/Debug.kt b/app/src/debug/java/org/tasks/preferences/fragments/Debug.kt index 51bb9e316..740f1a22c 100644 --- a/app/src/debug/java/org/tasks/preferences/fragments/Debug.kt +++ b/app/src/debug/java/org/tasks/preferences/fragments/Debug.kt @@ -59,7 +59,7 @@ class Debug : InjectingPreferenceFragment() { if (inventory.getPurchase(sku) == null) { preference.title = getString(R.string.debug_purchase, sku) preference.onPreferenceClickListener = Preference.OnPreferenceClickListener { - billingClient.initiatePurchaseFlow(activity, sku, "inapp" /*SkuType.INAPP*/, null) + billingClient.initiatePurchaseFlow(requireActivity().parent, sku, "inapp" /*SkuType.INAPP*/, null) false } } else { diff --git a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.java b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.java deleted file mode 100644 index f012314d2..000000000 --- a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.java +++ /dev/null @@ -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 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 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 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 - * - *

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)); - } -} diff --git a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt new file mode 100644 index 000000000..0719f79f3 --- /dev/null +++ b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt @@ -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? + ) { + 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) { + 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" + } + } + } +} \ No newline at end of file diff --git a/app/src/googleplay/java/org/tasks/billing/Security.java b/app/src/googleplay/java/org/tasks/billing/Security.java deleted file mode 100644 index 557736dd2..000000000 --- a/app/src/googleplay/java/org/tasks/billing/Security.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/googleplay/java/org/tasks/billing/Security.kt b/app/src/googleplay/java/org/tasks/billing/Security.kt new file mode 100644 index 000000000..fe16fd5aa --- /dev/null +++ b/app/src/googleplay/java/org/tasks/billing/Security.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/billing/BillingClient.java b/app/src/main/java/org/tasks/billing/BillingClient.java deleted file mode 100644 index 253bfa73b..000000000 --- a/app/src/main/java/org/tasks/billing/BillingClient.java +++ /dev/null @@ -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); -} diff --git a/app/src/main/java/org/tasks/billing/BillingClient.kt b/app/src/main/java/org/tasks/billing/BillingClient.kt new file mode 100644 index 000000000..42a4be194 --- /dev/null +++ b/app/src/main/java/org/tasks/billing/BillingClient.kt @@ -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) +} \ No newline at end of file