Upgrade to Play Billing v3

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

@ -25,7 +25,6 @@ repositories {
jcenter { jcenter {
content { content {
includeModule("com.twofortyfouram", "android-plugin-api-for-locale") 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.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-location:18.0.0")
googleplayImplementation("com.google.android.gms:play-services-maps:17.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}") androidTestImplementation("com.google.dagger:hilt-android-testing:${Versions.hilt}")
kaptAndroidTest("com.google.dagger:hilt-compiler:${Versions.hilt}") kaptAndroidTest("com.google.dagger:hilt-compiler:${Versions.hilt}")

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

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

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

@ -6,123 +6,121 @@ import com.android.billingclient.api.BillingClient.*
import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingFlowParams.ProrationMode 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.Purchase.PurchasesResult
import com.android.billingclient.api.PurchasesUpdatedListener 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 dagger.hilt.android.qualifiers.ApplicationContext
import io.reactivex.Single import kotlinx.coroutines.Dispatchers
import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.coroutines.NonCancellable
import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.withContext
import org.tasks.BuildConfig import org.tasks.BuildConfig
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import timber.log.Timber import timber.log.Timber
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class BillingClientImpl( class BillingClientImpl(
@ApplicationContext context: Context?, @ApplicationContext context: Context?,
private val inventory: Inventory, private val inventory: Inventory,
private val firebase: Firebase private val firebase: Firebase
) : BillingClient, PurchasesUpdatedListener { ) : 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 connected = false
private var onPurchasesUpdated: OnPurchasesUpdated? = null private var onPurchasesUpdated: OnPurchasesUpdated? = null
/** override suspend fun queryPurchases() = try {
* Query purchases across various use cases and deliver the result in a formalized way through a executeServiceRequest {
* listener withContext(Dispatchers.IO + NonCancellable) {
*/ val subs = billingClient.queryPurchases(SkuType.SUBS)
override fun queryPurchases() { val iaps = billingClient.queryPurchases(SkuType.INAPP)
val queryToExecute = Runnable { if (subs.success || iaps.success) {
var purchases = Single.fromCallable { billingClient!!.queryPurchases(SkuType.INAPP) } withContext(Dispatchers.Main) {
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() inventory.clear()
add(result.purchasesList) add(subs.purchases + iaps.purchases)
}
} else {
Timber.e("SUBS: ${subs.responseCodeString} IAPs: ${iaps.responseCodeString}")
}
}
}
} catch (e: IllegalStateException) {
Timber.e(e.message)
} }
override fun onPurchasesUpdated( 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) { if (success) {
add(purchases ?: emptyList()) add(purchases ?: emptyList())
} }
if (onPurchasesUpdated != null) { onPurchasesUpdated?.onPurchasesUpdated(success)
onPurchasesUpdated!!.onPurchasesUpdated(success)
}
val skus = purchases?.joinToString(";") { it.sku } ?: "null" val skus = purchases?.joinToString(";") { it.sku } ?: "null"
Timber.i("onPurchasesUpdated(%s, %s)", BillingResponseToString(resultCode), skus) Timber.i("onPurchasesUpdated(${result.responseCodeString}, $skus)")
firebase.reportIabResult(resultCode, skus) firebase.reportIabResult(result, skus)
} }
private fun add(purchases: List<com.android.billingclient.api.Purchase>) { private fun add(purchases: List<com.android.billingclient.api.Purchase>) {
inventory.add(purchases.map { Purchase(it) }) inventory.add(purchases.map { Purchase(it) })
} }
override fun initiatePurchaseFlow( override suspend fun initiatePurchaseFlow(
activity: Activity, skuId: String, billingType: String, oldSku: String? activity: Activity,
sku: String,
skuType: String,
oldPurchase: Purchase?
) { ) {
executeServiceRequest { executeServiceRequest {
billingClient!!.launchBillingFlow( val skuDetailsResult = withContext(Dispatchers.IO) {
activity, billingClient.querySkuDetails(
BillingFlowParams.newBuilder() SkuDetailsParams.newBuilder().setSkusList(listOf(sku)).setType(skuType)
.setSku(skuId)
.setType(billingType)
.setOldSku(oldSku)
.setReplaceSkusProrationMode(ProrationMode.IMMEDIATE_WITH_TIME_PRORATION)
.build() .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)
}
if (activity is OnPurchasesUpdated) {
onPurchasesUpdated = activity
}
billingClient.launchBillingFlow(activity, params.build())
} }
override fun addPurchaseCallback(onPurchasesUpdated: OnPurchasesUpdated) {
this.onPurchasesUpdated = onPurchasesUpdated
} }
private fun startServiceConnection(executeOnSuccess: Runnable?) { private suspend fun connect(): BillingResult =
billingClient!!.startConnection( suspendCoroutine { cont ->
billingClient.startConnection(
object : BillingClientStateListener { object : BillingClientStateListener {
override fun onBillingSetupFinished(@BillingResponse billingResponseCode: Int) { override fun onBillingSetupFinished(result: BillingResult) {
Timber.d("onBillingSetupFinished(%s)", billingResponseCode) if (result.success) {
if (billingResponseCode == BillingResponse.OK) {
connected = true connected = true
executeOnSuccess?.run() cont.resumeWith(Result.success(result))
} else {
cont.resumeWithException(
IllegalStateException(result.responseCodeString)
)
} }
} }
@ -130,67 +128,59 @@ class BillingClientImpl(
Timber.d("onBillingServiceDisconnected()") Timber.d("onBillingServiceDisconnected()")
connected = false 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)
} }
)
} }
/** private suspend fun executeServiceRequest(runnable: suspend () -> Unit) {
* Checks if subscriptions are supported for current client if (!connected) {
* connect()
*
* 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 runnable()
} }
override fun consume(sku: String) { override suspend fun consume(sku: String) {
check(BuildConfig.DEBUG) check(BuildConfig.DEBUG)
require(inventory.purchased(sku)) val purchase = inventory.getPurchase(sku)
val onConsumeListener = require(purchase != null)
ConsumeResponseListener { responseCode: Int, purchaseToken1: String? ->
Timber.d("onConsumeResponse(%s, %s)", responseCode, purchaseToken1)
queryPurchases()
}
executeServiceRequest { executeServiceRequest {
billingClient!!.consumeAsync( val result = billingClient.consumePurchase(
inventory.getPurchase(sku)!!.purchaseToken, onConsumeListener ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build(),
) )
Timber.d("consume purchase: ${result.billingResult.responseCodeString}")
queryPurchases()
} }
} }
companion object { companion object {
const val TYPE_SUBS = SkuType.SUBS const val TYPE_SUBS = SkuType.SUBS
fun BillingResponseToString(@BillingResponse response: Int): String {
return when (response) { private val PurchasesResult.success: Boolean
BillingResponse.FEATURE_NOT_SUPPORTED -> "FEATURE_NOT_SUPPORTED" get() = responseCode == BillingResponseCode.OK
BillingResponse.SERVICE_DISCONNECTED -> "SERVICE_DISCONNECTED"
BillingResponse.OK -> "OK" private val BillingResult.success: Boolean
BillingResponse.USER_CANCELED -> "USER_CANCELED" get() = responseCode == BillingResponseCode.OK
BillingResponse.SERVICE_UNAVAILABLE -> "SERVICE_UNAVAILABLE"
BillingResponse.BILLING_UNAVAILABLE -> "BILLING_UNAVAILABLE" val BillingResult.responseCodeString: String
BillingResponse.ITEM_UNAVAILABLE -> "ITEM_UNAVAILABLE" get() = when (responseCode) {
BillingResponse.DEVELOPER_ERROR -> "DEVELOPER_ERROR" BillingResponseCode.FEATURE_NOT_SUPPORTED -> "FEATURE_NOT_SUPPORTED"
BillingResponse.ERROR -> "ERROR" BillingResponseCode.SERVICE_DISCONNECTED -> "SERVICE_DISCONNECTED"
BillingResponse.ITEM_ALREADY_OWNED -> "ITEM_ALREADY_OWNED" BillingResponseCode.OK -> "OK"
BillingResponse.ITEM_NOT_OWNED -> "ITEM_NOT_OWNED" BillingResponseCode.USER_CANCELED -> "USER_CANCELED"
else -> "Unknown" 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 import android.app.Activity
interface BillingClient { interface BillingClient {
fun queryPurchases() suspend fun queryPurchases()
fun consume(sku: String) suspend fun consume(sku: String)
fun initiatePurchaseFlow(activity: Activity, sku: String, skuType: String, oldSku: String?) suspend fun initiatePurchaseFlow(
fun addPurchaseCallback(onPurchasesUpdated: OnPurchasesUpdated) 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] fun getPurchase(sku: String) = purchases[sku]
private fun updateSubscription() { private fun updateSubscription() {

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

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

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

@ -220,7 +220,10 @@
+| +--- androidx.fragment:fragment:1.0.0 -> 1.3.2 (*) +| +--- 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-base:17.0.0 -> 17.5.0 (*)
+| \--- com.google.android.gms:play-services-basement: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 ++--- com.gitlab.abaker:dav4jvm:deb2c9aef8
+| +--- org.jetbrains.kotlin:kotlin-stdlib:1.4.10 -> 1.4.31 (*) +| +--- org.jetbrains.kotlin:kotlin-stdlib:1.4.10 -> 1.4.31 (*)
+| \--- org.apache.commons:commons-lang3:3.8.1 +| \--- org.apache.commons:commons-lang3:3.8.1

Loading…
Cancel
Save