You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tasks/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt

236 lines
9.1 KiB
Kotlin

package org.tasks.billing
import android.app.Activity
import android.content.Context
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClient.SkuType
import com.android.billingclient.api.BillingClient.newBuilder
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingFlowParams.ProrationMode
import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ConsumeParams
import com.android.billingclient.api.Purchase.PurchaseState
import com.android.billingclient.api.Purchase.PurchasesResult
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.SkuDetailsParams
import com.android.billingclient.api.consumePurchase
import com.android.billingclient.api.querySkuDetails
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.tasks.BuildConfig
import org.tasks.analytics.Firebase
import org.tasks.jobs.WorkManager
import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class BillingClientImpl(
@ApplicationContext context: Context?,
private val inventory: Inventory,
private val firebase: Firebase,
private val workManager: WorkManager
) : BillingClient, PurchasesUpdatedListener {
private val billingClient =
newBuilder(context!!)
.setListener(this)
.enablePendingPurchases()
.build()
private var connected = false
private var onPurchasesUpdated: OnPurchasesUpdated? = null
override suspend fun queryPurchases(throwError: Boolean) = 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}")
}
}
}
} catch (e: IllegalStateException) {
if (throwError) {
throw e
} else {
Timber.e(e.message)
}
}
override fun onPurchasesUpdated(
result: BillingResult, purchases: List<com.android.billingclient.api.Purchase>?
) {
val success = result.success
if (success) {
add(purchases ?: emptyList())
}
workManager.updatePurchases()
onPurchasesUpdated?.onPurchasesUpdated(success)
purchases?.forEach {
firebase.reportIabResult(
result.responseCodeString,
it.skus.joinToString(","),
it.purchaseState.purchaseStateString
)
}
}
private fun add(purchases: List<com.android.billingclient.api.Purchase>) {
inventory.add(purchases.map { Purchase(it) })
}
override suspend fun initiatePurchaseFlow(
activity: Activity,
sku: String,
skuType: String,
oldPurchase: Purchase?
) {
executeServiceRequest {
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
?.firstOrNull()
?: throw IllegalStateException("Sku $sku not found")
val params = BillingFlowParams.newBuilder().setSkuDetails(skuDetails)
oldPurchase?.let {
params.setSubscriptionUpdateParams(
SubscriptionUpdateParams.newBuilder()
.setOldSkuPurchaseToken(it.purchaseToken)
.setReplaceSkusProrationMode(ProrationMode.IMMEDIATE_WITH_TIME_PRORATION)
.build()
)
}
if (activity is OnPurchasesUpdated) {
onPurchasesUpdated = activity
}
billingClient.launchBillingFlow(activity, params.build())
}
}
override suspend fun acknowledge(purchase: Purchase) {
if (purchase.needsAcknowledgement) {
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
withContext(Dispatchers.IO) {
suspendCoroutine { cont ->
billingClient.acknowledgePurchase(params) {
Timber.d("acknowledge: ${it.responseCodeString} $purchase")
cont.resume(it)
}
}
}
}
}
private suspend fun connect(): BillingResult =
suspendCancellableCoroutine { cont ->
billingClient.startConnection(
object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.success) {
connected = true
if (cont.isActive) {
cont.resumeWith(Result.success(result))
}
} else {
connected = false
if (cont.isActive) {
cont.resumeWithException(
IllegalStateException(result.responseCodeString)
)
}
}
}
override fun onBillingServiceDisconnected() {
Timber.d("onBillingServiceDisconnected()")
connected = false
}
}
)
}
private suspend fun executeServiceRequest(runnable: suspend () -> Unit) {
if (!connected) {
connect()
}
runnable()
}
override suspend fun consume(sku: String) {
check(BuildConfig.DEBUG)
val purchase = inventory.getPurchase(sku)
require(purchase != null)
executeServiceRequest {
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
const val STATE_PURCHASED = PurchaseState.PURCHASED
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"
}
val Int.purchaseStateString: String
get() = when (this) {
PurchaseState.UNSPECIFIED_STATE -> "UNSPECIFIED_STATE"
PurchaseState.PURCHASED -> "PURCHASED"
PurchaseState.PENDING -> "PENDING"
else -> this.toString()
}
private val PurchasesResult.responseCodeString: String
get() = billingResult.responseCodeString
private val PurchasesResult.purchases: List<com.android.billingclient.api.Purchase>
get() = purchasesList ?: emptyList()
}
}