Convert billing to Kotlin

pull/1451/head
Alex Baker 5 years ago
parent b923eae86b
commit a5663cd299

@ -59,7 +59,7 @@ class Debug : InjectingPreferenceFragment() {
if (inventory.getPurchase(sku) == null) { if (inventory.getPurchase(sku) == null) {
preference.title = getString(R.string.debug_purchase, sku) preference.title = getString(R.string.debug_purchase, sku)
preference.onPreferenceClickListener = Preference.OnPreferenceClickListener { preference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
billingClient.initiatePurchaseFlow(activity, sku, "inapp" /*SkuType.INAPP*/, null) billingClient.initiatePurchaseFlow(requireActivity().parent, sku, "inapp" /*SkuType.INAPP*/, null)
false false
} }
} else { } else {

@ -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…
Cancel
Save