diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b71149131..9b3146d1d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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}") 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 740f1a22c..6813c74e0 100644 --- a/app/src/debug/java/org/tasks/preferences/fragments/Debug.kt +++ b/app/src/debug/java/org/tasks/preferences/fragments/Debug.kt @@ -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 } } diff --git a/app/src/generic/java/org/tasks/billing/BillingClientImpl.kt b/app/src/generic/java/org/tasks/billing/BillingClientImpl.kt index 65ac3f27d..59fc9ed1b 100644 --- a/app/src/generic/java/org/tasks/billing/BillingClientImpl.kt +++ b/app/src/generic/java/org/tasks/billing/BillingClientImpl.kt @@ -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 = "" diff --git a/app/src/googleplay/java/org/tasks/analytics/Firebase.kt b/app/src/googleplay/java/org/tasks/analytics/Firebase.kt index 2be313407..ce236dcc6 100644 --- a/app/src/googleplay/java/org/tasks/analytics/Firebase.kt +++ b/app/src/googleplay/java/org/tasks/analytics/Firebase.kt @@ -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) }) } diff --git a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt index 0719f79f3..aab6407a7 100644 --- a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt +++ b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt @@ -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? + result: BillingResult, purchases: List? ) { - 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) { 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 + get() = purchasesList ?: emptyList() } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/billing/BillingClient.kt b/app/src/main/java/org/tasks/billing/BillingClient.kt index 42a4be194..47fb5de5a 100644 --- a/app/src/main/java/org/tasks/billing/BillingClient.kt +++ b/app/src/main/java/org/tasks/billing/BillingClient.kt @@ -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 + ) } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/billing/Inventory.kt b/app/src/main/java/org/tasks/billing/Inventory.kt index 7a2fc17e8..b446aa7c8 100644 --- a/app/src/main/java/org/tasks/billing/Inventory.kt +++ b/app/src/main/java/org/tasks/billing/Inventory.kt @@ -72,8 +72,6 @@ class Inventory @Inject constructor( } } - fun purchased(sku: String) = purchases.containsKey(sku) - fun getPurchase(sku: String) = purchases[sku] private fun updateSubscription() { diff --git a/app/src/main/java/org/tasks/billing/PurchaseActivity.kt b/app/src/main/java/org/tasks/billing/PurchaseActivity.kt index b759ff669..bf87a2025 100644 --- a/app/src/main/java/org/tasks/billing/PurchaseActivity.kt +++ b/app/src/main/java/org/tasks/billing/PurchaseActivity.kt @@ -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) { diff --git a/app/src/main/java/org/tasks/preferences/fragments/BaseAccountPreference.kt b/app/src/main/java/org/tasks/preferences/fragments/BaseAccountPreference.kt index b5cd182cf..59c86ae94 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/BaseAccountPreference.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/BaseAccountPreference.kt @@ -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) diff --git a/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt b/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt index 9d44d7ee1..bc5c326e2 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt @@ -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 } diff --git a/deps_googleplay.txt b/deps_googleplay.txt index 72ede53fe..010e03a66 100644 --- a/deps_googleplay.txt +++ b/deps_googleplay.txt @@ -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