deps(Android): Update to Android Billing v6.0

This commit is contained in:
Artem Chepurnoy 2024-07-06 22:08:38 +03:00
parent 0ca1a1c15e
commit be9807c5af
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
7 changed files with 128 additions and 51 deletions

View File

@ -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<RichResult<List<SkuDetails>>>
fun productDetailsFlow(params: QueryProductDetailsParams): Flow<RichResult<List<ProductDetails>>>
/**
* 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<RichResult<List<Purchase>>>
fun purchasesFlow(params: QueryPurchasesParams): Flow<RichResult<List<Purchase>>>
fun launchBillingFlow(activity: Activity, billingFlowParams: BillingFlowParams)

View File

@ -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<RichResult<List<SkuDetails>>> =
override fun productDetailsFlow(params: QueryProductDetailsParams): Flow<RichResult<List<ProductDetails>>> =
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<List<SkuDetails>>(exception)
ioRaise<List<ProductDetails>>(exception)
}
}
.retryIfNetworkIssue()
.attempt().bind().let { RichResult.invoke(it) }
override fun purchasesFlow(skuType: String): Flow<RichResult<List<Purchase>>> =
override fun purchasesFlow(params: QueryPurchasesParams): Flow<RichResult<List<Purchase>>> =
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<Either<BillingResponseException, List<SkuDetails>?>> { continuation ->
querySkuDetailsAsync(skuDetailsParams) { billingResult, skuDetailsList ->
private suspend fun BillingClient.querySkuDetailsSuspending(params: QueryProductDetailsParams) =
suspendCancellableCoroutine<Either<BillingResponseException, List<ProductDetails>?>> { 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<Either<BillingResponseException, List<Purchase>?>> { continuation ->
queryPurchasesAsync(skuType) { billingResult, purchases ->
queryPurchasesAsync(params) { billingResult, purchases ->
kotlin.runCatching {
val r = getBillingResultOrException(billingResult, purchases)
continuation.resume(r)

View File

@ -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<List<Subscription>?> = 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<List<Product>?> = 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()
connection.skuDetailsFlow(skuDetailsParams)
}
val skuDetailsParams = QueryProductDetailsParams.newBuilder()
.setProductList(productList)
.build()
connection.productDetailsFlow(skuDetailsParams)
}
}

View File

@ -1077,6 +1077,7 @@
<string name="pref_item_premium_membership_failed_to_load_subscriptions">Failed to load a list of subscriptions</string>
<string name="pref_item_premium_membership_failed_to_load_products">Failed to load a list of products</string>
<string name="pref_item_premium_status_active">Active</string>
<string name="pref_item_premium_status_free_trial">Free trial</string>
<string name="pref_item_premium_status_will_not_renew">Will not renew</string>
<string name="pref_item_premium_manage_subscription_on_play_store_title">Manage on Play Store</string>
<string name="pref_item_permission_post_notifications_title">Post notifications</string>

View File

@ -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,

View File

@ -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 = {

View File

@ -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