diff --git a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt index 029655832..b7d68fa28 100644 --- a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt +++ b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt @@ -4,27 +4,24 @@ 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.ProductType 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.ProductDetailsParams 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.PurchasesResult import com.android.billingclient.api.PurchasesUpdatedListener -import com.android.billingclient.api.SkuDetailsParams +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.consumePurchase -import com.android.billingclient.api.queryPurchasesAsync -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 kotlinx.serialization.json.Json import org.tasks.BuildConfig import org.tasks.analytics.Firebase import org.tasks.jobs.WorkManager @@ -49,32 +46,61 @@ class BillingClientImpl( override suspend fun getSkus(skus: List): List = executeServiceRequest { - val skuDetailsResult = withContext(Dispatchers.IO) { - billingClient.querySkuDetails( - SkuDetailsParams - .newBuilder() - .setType(SkuType.SUBS) - .setSkusList(skus) - .build() - ) + val productList = skus.map { + QueryProductDetailsParams.Product.newBuilder() + .setProductId(it) + .setProductType(ProductType.SUBS) + .build() + } + val params = QueryProductDetailsParams.newBuilder() + .setProductList(productList) + .build() + + val productDetailsResult = withContext(Dispatchers.IO) { + suspendCoroutine { cont -> + billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> + cont.resume(billingResult to productDetailsList) + } + } } - skuDetailsResult.billingResult.let { + + productDetailsResult.first.let { if (!it.success) { throw IllegalStateException(it.responseCodeString) } } - val json = Json { ignoreUnknownKeys = true } - skuDetailsResult - .skuDetailsList - ?.map { json.decodeFromString(it.originalJson) } - ?: emptyList() + + productDetailsResult.second?.map { productDetails -> + Sku( + productId = productDetails.productId, + price = productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice + ?: productDetails.oneTimePurchaseOfferDetails?.formattedPrice + ?: "" + ) + } ?: emptyList() } override suspend fun queryPurchases(throwError: Boolean) = try { executeServiceRequest { withContext(Dispatchers.IO + NonCancellable) { - val subs = billingClient.queryPurchasesAsync(SkuType.SUBS) - val iaps = billingClient.queryPurchasesAsync(SkuType.INAPP) + val subsParams = QueryPurchasesParams.newBuilder() + .setProductType(ProductType.SUBS) + .build() + val iapsParams = QueryPurchasesParams.newBuilder() + .setProductType(ProductType.INAPP) + .build() + + val subs = suspendCoroutine { cont -> + billingClient.queryPurchasesAsync(subsParams) { billingResult, purchases -> + cont.resume(PurchasesResult(billingResult, purchases)) + } + } + val iaps = suspendCoroutine { cont -> + billingClient.queryPurchasesAsync(iapsParams) { billingResult, purchases -> + cont.resume(PurchasesResult(billingResult, purchases)) + } + } + if (subs.success || iaps.success) { withContext(Dispatchers.Main) { inventory.clear() @@ -105,7 +131,7 @@ class BillingClientImpl( purchases?.forEach { firebase.reportIabResult( result.responseCodeString, - it.skus.joinToString(","), + it.products.joinToString(","), it.purchaseState.purchaseStateString ) } @@ -122,31 +148,57 @@ class BillingClientImpl( oldPurchase: Purchase? ) { executeServiceRequest { - val skuDetailsResult = withContext(Dispatchers.IO) { - billingClient.querySkuDetails( - SkuDetailsParams.newBuilder().setSkusList(listOf(sku)).setType(skuType) - .build() - ) + val productList = listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId(sku) + .setProductType(skuType) + .build() + ) + val queryParams = QueryProductDetailsParams.newBuilder() + .setProductList(productList) + .build() + + val productDetailsResult = withContext(Dispatchers.IO) { + suspendCoroutine { cont -> + billingClient.queryProductDetailsAsync(queryParams) { billingResult, productDetailsList -> + cont.resume(billingResult to productDetailsList) + } + } } - skuDetailsResult.billingResult.let { + + productDetailsResult.first.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) + + val productDetails = productDetailsResult.second?.firstOrNull() + ?: throw IllegalStateException("Product $sku not found") + + val productDetailsParamsBuilder = ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + + // For subscriptions (including legacy subscriptions), we need to provide an offer token + if (skuType == ProductType.SUBS) { + val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken + ?: throw IllegalStateException("No offer token found for subscription $sku") + productDetailsParamsBuilder.setOfferToken(offerToken) + } + + val productDetailsParams = productDetailsParamsBuilder.build() + + val params = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productDetailsParams)) + oldPurchase?.let { params.setSubscriptionUpdateParams( SubscriptionUpdateParams.newBuilder() - .setOldSkuPurchaseToken(it.purchaseToken) - .setReplaceSkusProrationMode(ProrationMode.IMMEDIATE_WITH_TIME_PRORATION) + .setOldPurchaseToken(it.purchaseToken) + .setSubscriptionReplacementMode(BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION) .build() ) } + if (activity is OnPurchasesUpdated) { onPurchasesUpdated = activity } @@ -214,17 +266,28 @@ class BillingClientImpl( ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build(), ) Timber.d("consume purchase: ${result.billingResult.responseCodeString}") - queryPurchases() + queryPurchases(throwError = false) } } + private data class PurchasesResult( + val billingResult: BillingResult, + val purchasesList: List + ) { + val success: Boolean + get() = billingResult.responseCode == BillingResponseCode.OK + + val responseCodeString: String + get() = billingResult.responseCodeString + + val purchases: List + get() = purchasesList + } + companion object { - const val TYPE_SUBS = SkuType.SUBS + const val TYPE_SUBS = ProductType.SUBS const val STATE_PURCHASED = PurchaseState.PURCHASED - private val PurchasesResult.success: Boolean - get() = billingResult.responseCode == BillingResponseCode.OK - private val BillingResult.success: Boolean get() = responseCode == BillingResponseCode.OK @@ -251,11 +314,5 @@ class BillingClientImpl( PurchaseState.PENDING -> "PENDING" else -> this.toString() } - - private val PurchasesResult.responseCodeString: String - get() = billingResult.responseCodeString - - private val PurchasesResult.purchases: List - get() = purchasesList } } \ No newline at end of file diff --git a/app/src/googleplay/java/org/tasks/billing/Purchase.kt b/app/src/googleplay/java/org/tasks/billing/Purchase.kt index 44bde11ad..e22b179d4 100644 --- a/app/src/googleplay/java/org/tasks/billing/Purchase.kt +++ b/app/src/googleplay/java/org/tasks/billing/Purchase.kt @@ -31,7 +31,7 @@ class Purchase(private val purchase: Purchase) { get() = purchase.signature val sku: String - get() = purchase.skus.first() + get() = purchase.products.first() val purchaseToken: String get() = purchase.purchaseToken @@ -55,7 +55,7 @@ class Purchase(private val purchase: Purchase) { get() { val matcher = PATTERN.matcher(sku) if (matcher.matches()) { - val price = matcher.group(2).toInt() + val price = matcher.group(2)?.toInt() return if (price == 499) 5 else price } return null diff --git a/deps_googleplay.txt b/deps_googleplay.txt index 2b874c0bc..d41481be8 100644 --- a/deps_googleplay.txt +++ b/deps_googleplay.txt @@ -632,16 +632,16 @@ +| +--- com.google.android.gms:play-services-base:18.5.0 -> 18.8.0 (*) +| +--- com.google.android.gms:play-services-basement:18.4.0 -> 18.8.0 (*) +| \--- com.google.android.gms:play-services-tasks:18.2.0 -> 18.4.0 (*) -++--- com.android.billingclient:billing-ktx:6.2.1 -+| +--- com.android.billingclient:billing:6.2.1 +++--- com.android.billingclient:billing-ktx:7.1.1 ++| +--- com.android.billingclient:billing:7.1.1 +| | +--- androidx.activity:activity:1.2.3 -> 1.11.0 (*) +| | +--- com.google.android.datatransport:transport-api:3.0.0 -> 3.2.0 (*) +| | +--- com.google.android.datatransport:transport-backend-cct:3.1.8 -> 3.3.0 (*) +| | +--- com.google.android.datatransport:transport-runtime:3.1.8 -> 3.3.0 (*) -+| | +--- com.google.android.gms:play-services-base:18.3.0 -> 18.8.0 (*) -+| | +--- com.google.android.gms:play-services-basement:18.3.0 -> 18.8.0 (*) ++| | +--- com.google.android.gms:play-services-base:18.5.0 -> 18.8.0 (*) ++| | +--- com.google.android.gms:play-services-basement:18.4.0 -> 18.8.0 (*) +| | +--- com.google.android.gms:play-services-location:19.0.0 -> 21.3.0 (*) -+| | \--- com.google.android.gms:play-services-tasks:18.1.0 -> 18.4.0 (*) ++| | \--- com.google.android.gms:play-services-tasks:18.2.0 -> 18.4.0 (*) +| +--- org.jetbrains.kotlin:kotlin-stdlib:1.4.0 -> 2.2.0 (*) +| \--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0 -> 1.10.2 (*) ++--- com.google.android.play:review-ktx:2.0.2 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 936854b28..157be0193 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -167,7 +167,7 @@ okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version. osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid" } oss-licenses-plugin = { module = "com.google.android.gms:oss-licenses-plugin", version.ref = "oss-licenses-plugin" } persistent-cookiejar = { module = "com.github.franmontiel:PersistentCookieJar", version.ref = "persistent-cookiejar" } -play-billing-ktx = { module = "com.android.billingclient:billing-ktx", version = "6.2.1" } +play-billing-ktx = { module = "com.android.billingclient:billing-ktx", version = "7.1.1" } play-review = { module = "com.google.android.play:review-ktx", version = "2.0.2" } play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "play-services-maps" } play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services-location" }