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.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase import com.android.billingclient.api.Purchase
import com.android.billingclient.api.SkuDetails import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.SkuDetailsParams import com.android.billingclient.api.QueryPurchasesParams
import com.artemchep.keyguard.common.model.RichResult import com.artemchep.keyguard.common.model.RichResult
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -21,7 +22,7 @@ interface BillingConnection {
* Perform a network query to get SKU details and * Perform a network query to get SKU details and
* return the result asynchronously. * 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. * 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/products/get
* https://developers.google.com/android-publisher/api-ref/purchases/subscriptions/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) 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.BillingClient
import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase 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.SkuDetails
import com.android.billingclient.api.SkuDetailsParams import com.android.billingclient.api.SkuDetailsParams
import com.artemchep.keyguard.common.io.IO import com.artemchep.keyguard.common.io.IO
@ -59,14 +62,14 @@ class BillingConnectionImpl(
private const val TIMEOUT_LAUNCH_BILLING_FLOW = 1000L 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 -> 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) { ioEffect(Dispatchers.IO) {
querySkuDetailsSuspending(skuDetailsParams) querySkuDetailsSuspending(params)
} }
.flattenMap() .flattenMap()
.flatMap { skuDetailsList -> .flatMap { skuDetailsList ->
@ -76,16 +79,16 @@ class BillingConnectionImpl(
?: kotlin.run { ?: kotlin.run {
val exception = val exception =
IllegalArgumentException("Sku details list must not be null!") IllegalArgumentException("Sku details list must not be null!")
ioRaise<List<SkuDetails>>(exception) ioRaise<List<ProductDetails>>(exception)
} }
} }
.retryIfNetworkIssue() .retryIfNetworkIssue()
.attempt().bind().let { RichResult.invoke(it) } .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 -> mapClient { client ->
ioEffect(Dispatchers.IO) { ioEffect(Dispatchers.IO) {
client.queryPurchasesSuspending(skuType) client.queryPurchasesSuspending(params)
} }
.flattenMap() .flattenMap()
.flatMap { purchasesList -> .flatMap { purchasesList ->
@ -169,9 +172,9 @@ class BillingConnectionImpl(
} }
} }
private suspend fun BillingClient.querySkuDetailsSuspending(skuDetailsParams: SkuDetailsParams) = private suspend fun BillingClient.querySkuDetailsSuspending(params: QueryProductDetailsParams) =
suspendCancellableCoroutine<Either<BillingResponseException, List<SkuDetails>?>> { continuation -> suspendCancellableCoroutine<Either<BillingResponseException, List<ProductDetails>?>> { continuation ->
querySkuDetailsAsync(skuDetailsParams) { billingResult, skuDetailsList -> queryProductDetailsAsync(params) { billingResult, skuDetailsList ->
kotlin.runCatching { kotlin.runCatching {
val r = getBillingResultOrException(billingResult, skuDetailsList) val r = getBillingResultOrException(billingResult, skuDetailsList)
continuation.resume(r) 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 -> suspendCancellableCoroutine<Either<BillingResponseException, List<Purchase>?>> { continuation ->
queryPurchasesAsync(skuType) { billingResult, purchases -> queryPurchasesAsync(params) { billingResult, purchases ->
kotlin.runCatching { kotlin.runCatching {
val r = getBillingResultOrException(billingResult, purchases) val r = getBillingResultOrException(billingResult, purchases)
continuation.resume(r) continuation.resume(r)

View File

@ -2,10 +2,11 @@ package com.artemchep.keyguard.copy
import android.app.Activity import android.app.Activity
import com.android.billingclient.api.AcknowledgePurchaseParams 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.BillingFlowParams
import com.android.billingclient.api.Purchase 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.android.closestActivityOrNull
import com.artemchep.keyguard.billing.BillingConnection import com.artemchep.keyguard.billing.BillingConnection
import com.artemchep.keyguard.billing.BillingManager import com.artemchep.keyguard.billing.BillingManager
@ -59,8 +60,8 @@ class SubscriptionServiceAndroid(
.map { receiptsResult -> .map { receiptsResult ->
receiptsResult.map { receipts -> receiptsResult.map { receipts ->
receipts.any { receipts.any {
val isSubscription = it.skus.intersect(SkuListSubscription).isNotEmpty() val isSubscription = it.products.intersect(SkuListSubscription).isNotEmpty()
val isProduct = it.skus.intersect(SkuListProduct).isNotEmpty() val isProduct = it.products.intersect(SkuListProduct).isNotEmpty()
isSubscription || isProduct isSubscription || isProduct
} }
} }
@ -69,47 +70,80 @@ class SubscriptionServiceAndroid(
override fun subscriptions(): Flow<List<Subscription>?> = combine( override fun subscriptions(): Flow<List<Subscription>?> = combine(
getReceiptFlow() getReceiptFlow()
.filter { it !is RichResult.Loading }, .filter { it !is RichResult.Loading },
getSkuDetailsFlow(SkuType.SUBS) getProductDetailsFlow(ProductType.SUBS)
.filter { it !is RichResult.Loading }, .filter { it !is RichResult.Loading },
) { receiptsResult, skuDetailsResult -> ) { receiptsResult, skuDetailsResult ->
val receipts = receiptsResult.orNull() val receipts = receiptsResult.orNull()
val skuDetails = skuDetailsResult.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 status = kotlin.run {
val skuReceipt = receipts?.firstOrNull { purchase -> it.sku in purchase.skus } val skuReceipt =
?: return@run Subscription.Status.Inactive 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( Subscription.Status.Active(
willRenew = skuReceipt.isAutoRenewing, willRenew = skuReceipt.isAutoRenewing,
) )
} }
Subscription( Subscription(
id = it.sku, id = it.productId,
title = it.title, title = it.name,
description = it.description, description = it.description,
price = it.price, price = finalPrice.formattedPrice,
status = status, status = status,
purchase = { context -> purchase = { context ->
val activity = context.context.closestActivityOrNull val activity = context.context.closestActivityOrNull
?: return@Subscription ?: return@Subscription
val offerToken = bestOffer?.offerToken
?: baseOffer.offerToken
val list = listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(it)
.setOfferToken(offerToken)
.build()
)
val params = BillingFlowParams.newBuilder() val params = BillingFlowParams.newBuilder()
.run { .run {
val existingPurchase = receipts val existingPurchase = receipts
?.firstOrNull { ?.firstOrNull {
SkuListSubscription.intersect(it.skus) SkuListSubscription.intersect(it.products)
.isNotEmpty() .isNotEmpty()
} }
if (existingPurchase != null && it.sku !in existingPurchase.skus) { if (existingPurchase != null && it.productId !in existingPurchase.products) {
val params = BillingFlowParams.SubscriptionUpdateParams val params = BillingFlowParams.SubscriptionUpdateParams
.newBuilder() .newBuilder()
.setOldSkuPurchaseToken(existingPurchase.purchaseToken) .setOldPurchaseToken(existingPurchase.purchaseToken)
.build() .build()
setSubscriptionUpdateParams(params) setSubscriptionUpdateParams(params)
} else { } else {
this this
} }
} }
.setSkuDetails(it) .setProductDetailsParamsList(list)
.build() .build()
purchase(activity, params) purchase(activity, params)
.crashlyticsTap() .crashlyticsTap()
@ -123,29 +157,37 @@ class SubscriptionServiceAndroid(
override fun products(): Flow<List<Product>?> = combine( override fun products(): Flow<List<Product>?> = combine(
getReceiptFlow() getReceiptFlow()
.filter { it !is RichResult.Loading }, .filter { it !is RichResult.Loading },
getSkuDetailsFlow(SkuType.INAPP) getProductDetailsFlow(ProductType.INAPP)
.filter { it !is RichResult.Loading }, .filter { it !is RichResult.Loading },
) { receiptsResult, skuDetailsResult -> ) { receiptsResult, skuDetailsResult ->
val receipts = receiptsResult.orNull() val receipts = receiptsResult.orNull()
val skuDetails = skuDetailsResult.orNull() val skuDetails = skuDetailsResult.orNull()
skuDetails?.map { skuDetails?.mapNotNull {
val status = kotlin.run { 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 ?: return@run Product.Status.Inactive
Product.Status.Active Product.Status.Active
} }
val finalPrice = it.oneTimePurchaseOfferDetails
?: return@mapNotNull null
Product( Product(
id = it.sku, id = it.productId,
title = it.title, title = it.name,
description = it.description, description = it.description,
price = it.price, price = finalPrice.formattedPrice,
status = status, status = status,
purchase = { context -> purchase = { context ->
val activity = context.context.closestActivityOrNull val activity = context.context.closestActivityOrNull
?: return@Product ?: return@Product
val list = listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(it)
.build()
)
val params = BillingFlowParams.newBuilder() val params = BillingFlowParams.newBuilder()
.setSkuDetails(it) .setProductDetailsParamsList(list)
.build() .build()
purchase(activity, params) purchase(activity, params)
.crashlyticsTap() .crashlyticsTap()
@ -180,10 +222,18 @@ class SubscriptionServiceAndroid(
.billingConnectionFlow .billingConnectionFlow
.flatMapLatest { connection -> .flatMapLatest { connection ->
val subscriptionsFlow = connection val subscriptionsFlow = connection
.purchasesFlow(SkuType.SUBS) .purchasesFlow(
QueryPurchasesParams.newBuilder()
.setProductType(ProductType.SUBS)
.build(),
)
.onEachAcknowledge(connection) .onEachAcknowledge(connection)
val productsFlow = connection val productsFlow = connection
.purchasesFlow(SkuType.INAPP) .purchasesFlow(
QueryPurchasesParams.newBuilder()
.setProductType(ProductType.INAPP)
.build(),
)
.onEachAcknowledge(connection) .onEachAcknowledge(connection)
combine( combine(
subscriptionsFlow, subscriptionsFlow,
@ -213,19 +263,24 @@ class SubscriptionServiceAndroid(
connection.acknowledgePurchase(acknowledgePurchaseParams) connection.acknowledgePurchase(acknowledgePurchaseParams)
} }
private fun getSkuDetailsFlow(skuType: String) = billingManager private fun getProductDetailsFlow(@ProductType productType: String) = billingManager
.billingConnectionFlow .billingConnectionFlow
.flatMapLatest { connection -> .flatMapLatest { connection ->
val skusList = when (skuType) { val productIdList = when (productType) {
SkuType.SUBS -> SkuListSubscription ProductType.SUBS -> SkuListSubscription
SkuType.INAPP -> SkuListProduct ProductType.INAPP -> SkuListProduct
else -> error("Unknown SKU type!") else -> error("Unknown SKU type!")
} }
val productList = productIdList
val skuDetailsParams = SkuDetailsParams.newBuilder() .map {
.setType(skuType) QueryProductDetailsParams.Product.newBuilder()
.setSkusList(skusList) .setProductId(it)
.setProductType(productType)
.build()
}
val skuDetailsParams = QueryProductDetailsParams.newBuilder()
.setProductList(productList)
.build() .build()
connection.skuDetailsFlow(skuDetailsParams) 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_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_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_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_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_premium_manage_subscription_on_play_store_title">Manage on Play Store</string>
<string name="pref_item_permission_post_notifications_title">Post notifications</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, val purchase: (LeContext) -> Unit,
) { ) {
sealed interface Status { sealed interface Status {
data object Inactive : Status data class Inactive(
val hasTrialAvailable: Boolean,
) : Status
data class Active( data class Active(
val willRenew: Boolean, 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape 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.model.fold
import com.artemchep.keyguard.common.usecase.GetProducts import com.artemchep.keyguard.common.usecase.GetProducts
import com.artemchep.keyguard.common.usecase.GetSubscriptions 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.home.vault.component.Section
import com.artemchep.keyguard.feature.onboarding.OnboardingCard import com.artemchep.keyguard.feature.onboarding.OnboardingCard
import com.artemchep.keyguard.feature.onboarding.onboardingItemsPremium 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.FlatItemLayout
import com.artemchep.keyguard.ui.FlatItemTextContent import com.artemchep.keyguard.ui.FlatItemTextContent
import com.artemchep.keyguard.ui.FlatSimpleNote import com.artemchep.keyguard.ui.FlatSimpleNote
import com.artemchep.keyguard.ui.FlatTextFieldBadge
import com.artemchep.keyguard.ui.MediumEmphasisAlpha import com.artemchep.keyguard.ui.MediumEmphasisAlpha
import com.artemchep.keyguard.ui.SimpleNote import com.artemchep.keyguard.ui.SimpleNote
import com.artemchep.keyguard.ui.icons.ChevronIcon import com.artemchep.keyguard.ui.icons.ChevronIcon
@ -268,6 +271,18 @@ private fun SettingSubscriptionItem(
}, },
elevation = 2.dp, elevation = 2.dp,
trailing = { 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() ChevronIcon()
}, },
content = { content = {

View File

@ -13,7 +13,7 @@ appVersionName = "1.3.1"
appVersionCode = "6" appVersionCode = "6"
# https://github.com/google/accompanist # https://github.com/google/accompanist
accompanist = "0.34.0" accompanist = "0.34.0"
androidBillingClient = "6.2.1" androidBillingClient = "7.0.0"
# https://mvnrepository.com/artifact/com.android.tools/desugar_jdk_libs # https://mvnrepository.com/artifact/com.android.tools/desugar_jdk_libs
androidDesugar = "2.0.4" androidDesugar = "2.0.4"
# https://mvnrepository.com/artifact/com.android.tools.build/gradle?repo=google # https://mvnrepository.com/artifact/com.android.tools.build/gradle?repo=google