Upgrade to Play Billing v3

pull/1451/head
Alex Baker 3 years ago
parent 4ea2e460c3
commit 775b5b56ca

@ -25,7 +25,6 @@ repositories {
jcenter {
content {
includeModule("com.twofortyfouram", "android-plugin-api-for-locale")
includeModule("com.android.billingclient", "billing")
}
}
}
@ -237,7 +236,7 @@ dependencies {
googleplayImplementation("com.google.firebase:firebase-config-ktx:${Versions.remote_config}")
googleplayImplementation("com.google.android.gms:play-services-location:18.0.0")
googleplayImplementation("com.google.android.gms:play-services-maps:17.0.0")
googleplayImplementation("com.android.billingclient:billing:1.2.2")
googleplayImplementation("com.android.billingclient:billing-ktx:3.0.3")
androidTestImplementation("com.google.dagger:hilt-android-testing:${Versions.hilt}")
kaptAndroidTest("com.google.dagger:hilt-compiler:${Versions.hilt}")

@ -2,9 +2,11 @@ package org.tasks.preferences.fragments
import android.os.Bundle
import androidx.annotation.StringRes
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import at.bitfire.cert4android.CustomCertManager.Companion.resetCertificates
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.R
import org.tasks.billing.BillingClient
import org.tasks.billing.Inventory
@ -59,13 +61,17 @@ class Debug : InjectingPreferenceFragment() {
if (inventory.getPurchase(sku) == null) {
preference.title = getString(R.string.debug_purchase, sku)
preference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
billingClient.initiatePurchaseFlow(requireActivity().parent, sku, "inapp" /*SkuType.INAPP*/, null)
lifecycleScope.launch {
billingClient.initiatePurchaseFlow(requireActivity().parent, "inapp" /*SkuType.INAPP*/, sku)
}
false
}
} else {
preference.title = getString(R.string.debug_consume, sku)
preference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
billingClient.consume(sku)
lifecycleScope.launch {
billingClient.consume(sku)
}
false
}
}

@ -5,14 +5,16 @@ import android.content.Context
import org.tasks.analytics.Firebase
@Suppress("UNUSED_PARAMETER")
class BillingClientImpl(context: Context?, inventory: Inventory?, firebase: Firebase?) : BillingClient {
override fun queryPurchases() {}
override fun initiatePurchaseFlow(
activity: Activity, sku: String, skuType: String, oldSku: String?) {
}
class BillingClientImpl(context: Context, inventory: Inventory, firebase: Firebase) : BillingClient {
override suspend fun queryPurchases() {}
override suspend fun initiatePurchaseFlow(
activity: Activity,
sku: String,
skuType: String,
oldPurchase: Purchase?
) {}
override fun addPurchaseCallback(onPurchasesUpdated: OnPurchasesUpdated) {}
override fun consume(sku: String) {}
override suspend fun consume(sku: String) {}
companion object {
const val TYPE_SUBS = ""

@ -3,14 +3,14 @@ package org.tasks.analytics
import android.content.Context
import android.os.Bundle
import androidx.annotation.StringRes
import com.android.billingclient.api.BillingClient.BillingResponse
import com.android.billingclient.api.BillingResult
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.ktx.remoteConfigSettings
import dagger.hilt.android.qualifiers.ApplicationContext
import org.tasks.R
import org.tasks.billing.BillingClientImpl
import org.tasks.billing.BillingClientImpl.Companion.responseCodeString
import org.tasks.jobs.WorkManager
import org.tasks.preferences.Preferences
import timber.log.Timber
@ -33,10 +33,10 @@ class Firebase @Inject constructor(
crashlytics?.recordException(t)
}
fun reportIabResult(@BillingResponse response: Int, sku: String?) {
fun reportIabResult(response: BillingResult, sku: String?) {
analytics?.logEvent(FirebaseAnalytics.Event.ECOMMERCE_PURCHASE, Bundle().apply {
putString(FirebaseAnalytics.Param.ITEM_ID, sku)
putString(FirebaseAnalytics.Param.SUCCESS, BillingClientImpl.BillingResponseToString(response))
putString(FirebaseAnalytics.Param.SUCCESS, response.responseCodeString)
})
}

@ -6,191 +6,181 @@ 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.BillingResult
import com.android.billingclient.api.ConsumeParams
import com.android.billingclient.api.Purchase.PurchasesResult
import com.android.billingclient.api.PurchasesUpdatedListener
import com.todoroo.andlib.utility.AndroidUtilities
import com.android.billingclient.api.SkuDetailsParams
import com.android.billingclient.api.querySkuDetails
import com.android.billingclient.api.consumePurchase
import dagger.hilt.android.qualifiers.ApplicationContext
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import org.tasks.BuildConfig
import org.tasks.analytics.Firebase
import timber.log.Timber
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class BillingClientImpl(
@ApplicationContext context: Context?,
private val inventory: Inventory,
private val firebase: Firebase
) : BillingClient, PurchasesUpdatedListener {
private val billingClient = newBuilder(context!!).setListener(this).build()
private val billingClient =
newBuilder(context!!)
.setListener(this)
.enablePendingPurchases()
.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
})
override suspend fun queryPurchases() = try {
executeServiceRequest {
withContext(Dispatchers.IO + NonCancellable) {
val subs = billingClient.queryPurchases(SkuType.SUBS)
val iaps = billingClient.queryPurchases(SkuType.INAPP)
if (subs.success || iaps.success) {
withContext(Dispatchers.Main) {
inventory.clear()
add(subs.purchases + iaps.purchases)
}
} else {
Timber.e("SUBS: ${subs.responseCodeString} IAPs: ${iaps.responseCodeString}")
}
}
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)
} catch (e: IllegalStateException) {
Timber.e(e.message)
}
override fun onPurchasesUpdated(
@BillingResponse resultCode: Int, purchases: List<com.android.billingclient.api.Purchase>?
result: BillingResult, purchases: List<com.android.billingclient.api.Purchase>?
) {
val success = resultCode == BillingResponse.OK
val success = result.success
if (success) {
add(purchases ?: emptyList())
}
if (onPurchasesUpdated != null) {
onPurchasesUpdated!!.onPurchasesUpdated(success)
}
onPurchasesUpdated?.onPurchasesUpdated(success)
val skus = purchases?.joinToString(";") { it.sku } ?: "null"
Timber.i("onPurchasesUpdated(%s, %s)", BillingResponseToString(resultCode), skus)
firebase.reportIabResult(resultCode, skus)
Timber.i("onPurchasesUpdated(${result.responseCodeString}, $skus)")
firebase.reportIabResult(result, 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?
override suspend fun initiatePurchaseFlow(
activity: Activity,
sku: String,
skuType: String,
oldPurchase: Purchase?
) {
executeServiceRequest {
billingClient!!.launchBillingFlow(
activity,
BillingFlowParams.newBuilder()
.setSku(skuId)
.setType(billingType)
.setOldSku(oldSku)
val skuDetailsResult = withContext(Dispatchers.IO) {
billingClient.querySkuDetails(
SkuDetailsParams.newBuilder().setSkusList(listOf(sku)).setType(skuType)
.build()
)
}
skuDetailsResult.billingResult.let {
if (!it.success) {
throw IllegalStateException(it.responseCodeString)
}
}
val skuDetails =
skuDetailsResult
.skuDetailsList
?.takeIf { it.isNotEmpty() }
?.firstOrNull()
?: throw IllegalStateException("Sku $sku not found")
val params = BillingFlowParams.newBuilder().setSkuDetails(skuDetails)
oldPurchase?.let {
params
.setOldSku(it.sku, it.purchaseToken)
.setReplaceSkusProrationMode(ProrationMode.IMMEDIATE_WITH_TIME_PRORATION)
.build()
)
}
if (activity is OnPurchasesUpdated) {
onPurchasesUpdated = activity
}
billingClient.launchBillingFlow(activity, params.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()
private suspend fun connect(): BillingResult =
suspendCoroutine { cont ->
billingClient.startConnection(
object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.success) {
connected = true
cont.resumeWith(Result.success(result))
} else {
cont.resumeWithException(
IllegalStateException(result.responseCodeString)
)
}
}
}
override fun onBillingServiceDisconnected() {
Timber.d("onBillingServiceDisconnected()")
connected = false
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)
private suspend fun executeServiceRequest(runnable: suspend () -> Unit) {
if (!connected) {
connect()
}
return responseCode == BillingResponse.OK
runnable()
}
override fun consume(sku: String) {
override suspend 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()
}
val purchase = inventory.getPurchase(sku)
require(purchase != null)
executeServiceRequest {
billingClient!!.consumeAsync(
inventory.getPurchase(sku)!!.purchaseToken, onConsumeListener
val result = billingClient.consumePurchase(
ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build(),
)
Timber.d("consume purchase: ${result.billingResult.responseCodeString}")
queryPurchases()
}
}
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"
private val PurchasesResult.success: Boolean
get() = responseCode == BillingResponseCode.OK
private val BillingResult.success: Boolean
get() = responseCode == BillingResponseCode.OK
val BillingResult.responseCodeString: String
get() = when (responseCode) {
BillingResponseCode.FEATURE_NOT_SUPPORTED -> "FEATURE_NOT_SUPPORTED"
BillingResponseCode.SERVICE_DISCONNECTED -> "SERVICE_DISCONNECTED"
BillingResponseCode.OK -> "OK"
BillingResponseCode.USER_CANCELED -> "USER_CANCELED"
BillingResponseCode.SERVICE_UNAVAILABLE -> "SERVICE_UNAVAILABLE"
BillingResponseCode.BILLING_UNAVAILABLE -> "BILLING_UNAVAILABLE"
BillingResponseCode.ITEM_UNAVAILABLE -> "ITEM_UNAVAILABLE"
BillingResponseCode.DEVELOPER_ERROR -> "DEVELOPER_ERROR"
BillingResponseCode.ERROR -> "ERROR"
BillingResponseCode.ITEM_ALREADY_OWNED -> "ITEM_ALREADY_OWNED"
BillingResponseCode.ITEM_NOT_OWNED -> "ITEM_NOT_OWNED"
else -> "UNKNOWN"
}
}
private val PurchasesResult.responseCodeString: String
get() = billingResult.responseCodeString
private val PurchasesResult.purchases: List<com.android.billingclient.api.Purchase>
get() = purchasesList ?: emptyList()
}
}

@ -3,8 +3,12 @@ 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)
suspend fun queryPurchases()
suspend fun consume(sku: String)
suspend fun initiatePurchaseFlow(
activity: Activity,
sku: String,
skuType: String,
oldPurchase: Purchase? = null
)
}

@ -72,8 +72,6 @@ class Inventory @Inject constructor(
}
}
fun purchased(sku: String) = purchases.containsKey(sku)
fun getPurchase(sku: String) = purchases[sku]
private fun updateSubscription() {

@ -7,11 +7,14 @@ import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.lifecycleScope
import com.google.android.material.composethemeadapter.MdcTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager
import org.tasks.Tasks.Companion.IS_GENERIC
import org.tasks.compose.PurchaseText.PurchaseText
import org.tasks.extensions.Context.toast
import org.tasks.injection.InjectingAppCompatActivity
import org.tasks.themes.Theme
import javax.inject.Inject
@ -62,7 +65,9 @@ class PurchaseActivity : InjectingAppCompatActivity(), OnPurchasesUpdated {
super.onStart()
localBroadcastManager.registerPurchaseReceiver(purchaseReceiver)
billingClient.queryPurchases()
lifecycleScope.launch {
billingClient.queryPurchases()
}
}
override fun onStop() {
@ -81,14 +86,18 @@ class PurchaseActivity : InjectingAppCompatActivity(), OnPurchasesUpdated {
}
}
private fun purchase(price: Int, monthly: Boolean) {
private fun purchase(price: Int, monthly: Boolean) = lifecycleScope.launch {
val newSku = String.format("%s_%02d", if (monthly) "monthly" else "annual", price)
billingClient.initiatePurchaseFlow(
this,
newSku,
BillingClientImpl.TYPE_SUBS,
currentSubscription?.sku?.takeIf { it != newSku })
billingClient.addPurchaseCallback(this)
try {
billingClient.initiatePurchaseFlow(
this@PurchaseActivity,
newSku,
BillingClientImpl.TYPE_SUBS,
currentSubscription?.takeIf { it.sku != newSku }
)
} catch (e: Exception) {
this@PurchaseActivity.toast(e.message)
}
}
override fun onPurchasesUpdated(success: Boolean) {

@ -49,7 +49,9 @@ abstract class BaseAccountPreference : InjectingPreferenceFragment() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_PURCHASE) {
if (resultCode == Activity.RESULT_OK) {
billingClient.queryPurchases()
lifecycleScope.launch {
billingClient.queryPurchases()
}
}
} else {
super.onActivityResult(requestCode, resultCode, data)

@ -4,10 +4,12 @@ import android.content.Intent
import android.os.Bundle
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceScreen
import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.BuildConfig
import org.tasks.R
import org.tasks.billing.BillingClient
@ -56,7 +58,9 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
}
findPreference(R.string.refresh_purchases).setOnPreferenceClickListener {
billingClient.queryPurchases()
lifecycleScope.launch {
billingClient.queryPurchases()
}
false
}

@ -220,7 +220,10 @@
+| +--- androidx.fragment:fragment:1.0.0 -> 1.3.2 (*)
+| +--- com.google.android.gms:play-services-base:17.0.0 -> 17.5.0 (*)
+| \--- com.google.android.gms:play-services-basement:17.0.0 -> 17.5.0 (*)
++--- com.android.billingclient:billing:1.2.2
++--- com.android.billingclient:billing-ktx:3.0.3
+| +--- com.android.billingclient:billing:3.0.3
+| +--- org.jetbrains.kotlin:kotlin-stdlib:1.4.0 -> 1.4.31 (*)
+| \--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9 -> 1.4.1 (*)
++--- com.gitlab.abaker:dav4jvm:deb2c9aef8
+| +--- org.jetbrains.kotlin:kotlin-stdlib:1.4.10 -> 1.4.31 (*)
+| \--- org.apache.commons:commons-lang3:3.8.1

Loading…
Cancel
Save