From be9807c5af5b424fb804aab5297b7a0efa0ffc54 Mon Sep 17 00:00:00 2001 From: Artem Chepurnoy Date: Sat, 6 Jul 2024 22:08:38 +0300 Subject: [PATCH] deps(Android): Update to Android Billing v6.0 --- .../keyguard/billing/BillingConnection.kt | 9 +- .../keyguard/billing/BillingConnectionImpl.kt | 27 ++-- .../copy/SubscriptionServiceAndroid.kt | 121 +++++++++++++----- .../composeResources/values/strings.xml | 1 + .../keyguard/common/model/Subscription.kt | 4 +- .../component/SettingSubscriptions.kt | 15 +++ gradle/libs.versions.toml | 2 +- 7 files changed, 128 insertions(+), 51 deletions(-) diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/billing/BillingConnection.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/billing/BillingConnection.kt index d4eba703..296ad637 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/billing/BillingConnection.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/billing/BillingConnection.kt @@ -4,9 +4,10 @@ import android.app.Activity import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.Purchase -import com.android.billingclient.api.SkuDetails -import com.android.billingclient.api.SkuDetailsParams +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams import com.artemchep.keyguard.common.model.RichResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -21,7 +22,7 @@ interface BillingConnection { * Perform a network query to get SKU details and * return the result asynchronously. */ - fun skuDetailsFlow(skuDetailsParams: SkuDetailsParams): Flow>> + fun productDetailsFlow(params: QueryProductDetailsParams): Flow>> /** * Get purchases details for all the items bought within your app. @@ -32,7 +33,7 @@ interface BillingConnection { * https://developers.google.com/android-publisher/api-ref/purchases/products/get * https://developers.google.com/android-publisher/api-ref/purchases/subscriptions/get */ - fun purchasesFlow(skuType: String): Flow>> + fun purchasesFlow(params: QueryPurchasesParams): Flow>> fun launchBillingFlow(activity: Activity, billingFlowParams: BillingFlowParams) diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/billing/BillingConnectionImpl.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/billing/BillingConnectionImpl.kt index 07811356..cded1823 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/billing/BillingConnectionImpl.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/billing/BillingConnectionImpl.kt @@ -8,7 +8,10 @@ import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.Purchase +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.SkuDetails import com.android.billingclient.api.SkuDetailsParams import com.artemchep.keyguard.common.io.IO @@ -59,14 +62,14 @@ class BillingConnectionImpl( private const val TIMEOUT_LAUNCH_BILLING_FLOW = 1000L } - override fun skuDetailsFlow(skuDetailsParams: SkuDetailsParams): Flow>> = + override fun productDetailsFlow(params: QueryProductDetailsParams): Flow>> = mapClient { client -> - client.mapToSkuDetails(skuDetailsParams) + client.mapToSkuDetails(params) } - private suspend fun BillingClient.mapToSkuDetails(skuDetailsParams: SkuDetailsParams) = + private suspend fun BillingClient.mapToSkuDetails(params: QueryProductDetailsParams) = ioEffect(Dispatchers.IO) { - querySkuDetailsSuspending(skuDetailsParams) + querySkuDetailsSuspending(params) } .flattenMap() .flatMap { skuDetailsList -> @@ -76,16 +79,16 @@ class BillingConnectionImpl( ?: kotlin.run { val exception = IllegalArgumentException("Sku details list must not be null!") - ioRaise>(exception) + ioRaise>(exception) } } .retryIfNetworkIssue() .attempt().bind().let { RichResult.invoke(it) } - override fun purchasesFlow(skuType: String): Flow>> = + override fun purchasesFlow(params: QueryPurchasesParams): Flow>> = mapClient { client -> ioEffect(Dispatchers.IO) { - client.queryPurchasesSuspending(skuType) + client.queryPurchasesSuspending(params) } .flattenMap() .flatMap { purchasesList -> @@ -169,9 +172,9 @@ class BillingConnectionImpl( } } -private suspend fun BillingClient.querySkuDetailsSuspending(skuDetailsParams: SkuDetailsParams) = - suspendCancellableCoroutine?>> { continuation -> - querySkuDetailsAsync(skuDetailsParams) { billingResult, skuDetailsList -> +private suspend fun BillingClient.querySkuDetailsSuspending(params: QueryProductDetailsParams) = + suspendCancellableCoroutine?>> { continuation -> + queryProductDetailsAsync(params) { billingResult, skuDetailsList -> kotlin.runCatching { val r = getBillingResultOrException(billingResult, skuDetailsList) continuation.resume(r) @@ -179,9 +182,9 @@ private suspend fun BillingClient.querySkuDetailsSuspending(skuDetailsParams: Sk } } -private suspend fun BillingClient.queryPurchasesSuspending(skuType: String) = +private suspend fun BillingClient.queryPurchasesSuspending(params: QueryPurchasesParams) = suspendCancellableCoroutine?>> { continuation -> - queryPurchasesAsync(skuType) { billingResult, purchases -> + queryPurchasesAsync(params) { billingResult, purchases -> kotlin.runCatching { val r = getBillingResultOrException(billingResult, purchases) continuation.resume(r) diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/SubscriptionServiceAndroid.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/SubscriptionServiceAndroid.kt index 65d0c090..a802d441 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/SubscriptionServiceAndroid.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/SubscriptionServiceAndroid.kt @@ -2,10 +2,11 @@ package com.artemchep.keyguard.copy import android.app.Activity import com.android.billingclient.api.AcknowledgePurchaseParams -import com.android.billingclient.api.BillingClient.SkuType +import com.android.billingclient.api.BillingClient.ProductType import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.Purchase -import com.android.billingclient.api.SkuDetailsParams +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams import com.artemchep.keyguard.android.closestActivityOrNull import com.artemchep.keyguard.billing.BillingConnection import com.artemchep.keyguard.billing.BillingManager @@ -59,8 +60,8 @@ class SubscriptionServiceAndroid( .map { receiptsResult -> receiptsResult.map { receipts -> receipts.any { - val isSubscription = it.skus.intersect(SkuListSubscription).isNotEmpty() - val isProduct = it.skus.intersect(SkuListProduct).isNotEmpty() + val isSubscription = it.products.intersect(SkuListSubscription).isNotEmpty() + val isProduct = it.products.intersect(SkuListProduct).isNotEmpty() isSubscription || isProduct } } @@ -69,47 +70,80 @@ class SubscriptionServiceAndroid( override fun subscriptions(): Flow?> = combine( getReceiptFlow() .filter { it !is RichResult.Loading }, - getSkuDetailsFlow(SkuType.SUBS) + getProductDetailsFlow(ProductType.SUBS) .filter { it !is RichResult.Loading }, ) { receiptsResult, skuDetailsResult -> val receipts = receiptsResult.orNull() val skuDetails = skuDetailsResult.orNull() - skuDetails?.map { + skuDetails?.mapNotNull { + val bestOffer = it.subscriptionOfferDetails + ?.filter { it.offerId != null } + ?.minByOrNull { + val firstPrice = it.pricingPhases + .pricingPhaseList + .first() + firstPrice.priceAmountMicros + } + val baseOffer = it.subscriptionOfferDetails + ?.firstOrNull { it.offerId == null } + ?: return@mapNotNull null + val finalPrice = baseOffer.pricingPhases + .pricingPhaseList + .last() + val status = kotlin.run { - val skuReceipt = receipts?.firstOrNull { purchase -> it.sku in purchase.skus } - ?: return@run Subscription.Status.Inactive + val skuReceipt = + receipts?.firstOrNull { purchase -> it.productId in purchase.products } + ?: return@run kotlin.run { + val hasTrialAvailable = bestOffer?.pricingPhases + ?.pricingPhaseList + ?.firstOrNull() + ?.priceAmountMicros == 0L + Subscription.Status.Inactive( + hasTrialAvailable = hasTrialAvailable, + ) + } Subscription.Status.Active( willRenew = skuReceipt.isAutoRenewing, ) } Subscription( - id = it.sku, - title = it.title, + id = it.productId, + title = it.name, description = it.description, - price = it.price, + price = finalPrice.formattedPrice, status = status, purchase = { context -> val activity = context.context.closestActivityOrNull ?: return@Subscription + + val offerToken = bestOffer?.offerToken + ?: baseOffer.offerToken + val list = listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(it) + .setOfferToken(offerToken) + .build() + ) val params = BillingFlowParams.newBuilder() .run { val existingPurchase = receipts ?.firstOrNull { - SkuListSubscription.intersect(it.skus) + SkuListSubscription.intersect(it.products) .isNotEmpty() } - if (existingPurchase != null && it.sku !in existingPurchase.skus) { + if (existingPurchase != null && it.productId !in existingPurchase.products) { val params = BillingFlowParams.SubscriptionUpdateParams .newBuilder() - .setOldSkuPurchaseToken(existingPurchase.purchaseToken) + .setOldPurchaseToken(existingPurchase.purchaseToken) .build() setSubscriptionUpdateParams(params) } else { this } } - .setSkuDetails(it) + .setProductDetailsParamsList(list) .build() purchase(activity, params) .crashlyticsTap() @@ -123,29 +157,37 @@ class SubscriptionServiceAndroid( override fun products(): Flow?> = combine( getReceiptFlow() .filter { it !is RichResult.Loading }, - getSkuDetailsFlow(SkuType.INAPP) + getProductDetailsFlow(ProductType.INAPP) .filter { it !is RichResult.Loading }, ) { receiptsResult, skuDetailsResult -> val receipts = receiptsResult.orNull() val skuDetails = skuDetailsResult.orNull() - skuDetails?.map { + skuDetails?.mapNotNull { val status = kotlin.run { - receipts?.firstOrNull { purchase -> it.sku in purchase.skus } + receipts?.firstOrNull { purchase -> it.productId in purchase.products } ?: return@run Product.Status.Inactive Product.Status.Active } + val finalPrice = it.oneTimePurchaseOfferDetails + ?: return@mapNotNull null Product( - id = it.sku, - title = it.title, + id = it.productId, + title = it.name, description = it.description, - price = it.price, + price = finalPrice.formattedPrice, status = status, purchase = { context -> val activity = context.context.closestActivityOrNull ?: return@Product + + val list = listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(it) + .build() + ) val params = BillingFlowParams.newBuilder() - .setSkuDetails(it) + .setProductDetailsParamsList(list) .build() purchase(activity, params) .crashlyticsTap() @@ -180,10 +222,18 @@ class SubscriptionServiceAndroid( .billingConnectionFlow .flatMapLatest { connection -> val subscriptionsFlow = connection - .purchasesFlow(SkuType.SUBS) + .purchasesFlow( + QueryPurchasesParams.newBuilder() + .setProductType(ProductType.SUBS) + .build(), + ) .onEachAcknowledge(connection) val productsFlow = connection - .purchasesFlow(SkuType.INAPP) + .purchasesFlow( + QueryPurchasesParams.newBuilder() + .setProductType(ProductType.INAPP) + .build(), + ) .onEachAcknowledge(connection) combine( subscriptionsFlow, @@ -213,19 +263,24 @@ class SubscriptionServiceAndroid( connection.acknowledgePurchase(acknowledgePurchaseParams) } - private fun getSkuDetailsFlow(skuType: String) = billingManager + private fun getProductDetailsFlow(@ProductType productType: String) = billingManager .billingConnectionFlow .flatMapLatest { connection -> - val skusList = when (skuType) { - SkuType.SUBS -> SkuListSubscription - SkuType.INAPP -> SkuListProduct + val productIdList = when (productType) { + ProductType.SUBS -> SkuListSubscription + ProductType.INAPP -> SkuListProduct else -> error("Unknown SKU type!") } - - val skuDetailsParams = SkuDetailsParams.newBuilder() - .setType(skuType) - .setSkusList(skusList) + val productList = productIdList + .map { + QueryProductDetailsParams.Product.newBuilder() + .setProductId(it) + .setProductType(productType) + .build() + } + val skuDetailsParams = QueryProductDetailsParams.newBuilder() + .setProductList(productList) .build() - connection.skuDetailsFlow(skuDetailsParams) + connection.productDetailsFlow(skuDetailsParams) } } diff --git a/common/src/commonMain/composeResources/values/strings.xml b/common/src/commonMain/composeResources/values/strings.xml index 471d74fd..5eda7a41 100644 --- a/common/src/commonMain/composeResources/values/strings.xml +++ b/common/src/commonMain/composeResources/values/strings.xml @@ -1077,6 +1077,7 @@ Failed to load a list of subscriptions Failed to load a list of products Active + Free trial Will not renew Manage on Play Store Post notifications diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/Subscription.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/Subscription.kt index 4fb460f8..d7866403 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/Subscription.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/Subscription.kt @@ -11,7 +11,9 @@ data class Subscription( val purchase: (LeContext) -> Unit, ) { sealed interface Status { - data object Inactive : Status + data class Inactive( + val hasTrialAvailable: Boolean, + ) : Status data class Active( val willRenew: Boolean, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingSubscriptions.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingSubscriptions.kt index 676c8c36..c40e47c1 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingSubscriptions.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingSubscriptions.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -35,6 +36,7 @@ import com.artemchep.keyguard.common.model.Subscription import com.artemchep.keyguard.common.model.fold import com.artemchep.keyguard.common.usecase.GetProducts import com.artemchep.keyguard.common.usecase.GetSubscriptions +import com.artemchep.keyguard.feature.auth.common.TextFieldModel2 import com.artemchep.keyguard.feature.home.vault.component.Section import com.artemchep.keyguard.feature.onboarding.OnboardingCard import com.artemchep.keyguard.feature.onboarding.onboardingItemsPremium @@ -47,6 +49,7 @@ import com.artemchep.keyguard.ui.ExpandedIfNotEmpty import com.artemchep.keyguard.ui.FlatItemLayout import com.artemchep.keyguard.ui.FlatItemTextContent import com.artemchep.keyguard.ui.FlatSimpleNote +import com.artemchep.keyguard.ui.FlatTextFieldBadge import com.artemchep.keyguard.ui.MediumEmphasisAlpha import com.artemchep.keyguard.ui.SimpleNote import com.artemchep.keyguard.ui.icons.ChevronIcon @@ -268,6 +271,18 @@ private fun SettingSubscriptionItem( }, elevation = 2.dp, trailing = { + val status = subscription.status + if (status is Subscription.Status.Inactive && status.hasTrialAvailable) { + FlatTextFieldBadge( + type = TextFieldModel2.Vl.Type.INFO, + text = stringResource(Res.string.pref_item_premium_status_free_trial), + ) + Spacer( + modifier = Modifier + .width(16.dp), + ) + } + ChevronIcon() }, content = { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9513b91e..8ad22fdd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ appVersionName = "1.3.1" appVersionCode = "6" # https://github.com/google/accompanist accompanist = "0.34.0" -androidBillingClient = "6.2.1" +androidBillingClient = "7.0.0" # https://mvnrepository.com/artifact/com.android.tools/desugar_jdk_libs androidDesugar = "2.0.4" # https://mvnrepository.com/artifact/com.android.tools.build/gradle?repo=google