diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/FeatureFundingModule.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/FeatureFundingModule.kt index b033810b336..c4c2050b15f 100644 --- a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/FeatureFundingModule.kt +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/FeatureFundingModule.kt @@ -1,5 +1,6 @@ package app.k9mail.feature.funding +import app.k9mail.core.common.cache.Cache import app.k9mail.core.common.cache.InMemoryCache import app.k9mail.feature.funding.api.FundingManager import app.k9mail.feature.funding.api.FundingNavigation @@ -10,10 +11,12 @@ import app.k9mail.feature.funding.googleplay.data.GoogleBillingClient import app.k9mail.feature.funding.googleplay.data.mapper.BillingResultMapper import app.k9mail.feature.funding.googleplay.data.mapper.ProductDetailsMapper import app.k9mail.feature.funding.googleplay.data.remote.GoogleBillingClientProvider +import app.k9mail.feature.funding.googleplay.data.remote.GoogleBillingPurchaseHandler import app.k9mail.feature.funding.googleplay.domain.BillingManager import app.k9mail.feature.funding.googleplay.domain.ContributionIdProvider import app.k9mail.feature.funding.googleplay.domain.DomainContract import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionViewModel +import com.android.billingclient.api.ProductDetails import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module @@ -35,12 +38,24 @@ val featureFundingModule = module { ) } + single> { + InMemoryCache() + } + + single { + GoogleBillingPurchaseHandler( + productCache = get(), + productMapper = get(), + ) + } + single { GoogleBillingClient( clientProvider = get(), productMapper = get(), resultMapper = get(), - productCache = InMemoryCache(), + productCache = get(), + purchaseHandler = get(), ) } diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt index 1526108a89c..50d01b8dae8 100644 --- a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt @@ -7,6 +7,7 @@ import app.k9mail.feature.funding.googleplay.domain.entity.Contribution import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.BillingClient as GoogleBillingClient import com.android.billingclient.api.BillingResult as GoogleBillingResult @@ -43,6 +44,13 @@ interface DataContract { */ fun clear() } + + interface GoogleBillingPurchaseHandler { + suspend fun handlePurchases( + clientProvider: GoogleBillingClientProvider, + purchases: List, + ): List + } } interface BillingClient { diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt index 1ca2fe53d4e..40a42fef5a3 100644 --- a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt @@ -2,18 +2,16 @@ package app.k9mail.feature.funding.googleplay.data import android.app.Activity import app.k9mail.core.common.cache.Cache -import app.k9mail.feature.funding.googleplay.data.DataContract.Remote.GoogleBillingClientProvider +import app.k9mail.feature.funding.googleplay.data.DataContract.Remote import app.k9mail.feature.funding.googleplay.domain.entity.Contribution import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution -import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.BillingClient.ProductType import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams import com.android.billingclient.api.BillingResult -import com.android.billingclient.api.ConsumeParams import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetailsResult import com.android.billingclient.api.Purchase @@ -22,8 +20,6 @@ import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryPurchaseHistoryParams import com.android.billingclient.api.QueryPurchasesParams -import com.android.billingclient.api.acknowledgePurchase -import com.android.billingclient.api.consumePurchase import com.android.billingclient.api.queryProductDetails import com.android.billingclient.api.queryPurchaseHistory import com.android.billingclient.api.queryPurchasesAsync @@ -37,10 +33,11 @@ import timber.log.Timber @Suppress("TooManyFunctions") internal class GoogleBillingClient( - private val clientProvider: GoogleBillingClientProvider, + private val clientProvider: Remote.GoogleBillingClientProvider, private val productMapper: DataContract.Mapper.Product, private val resultMapper: DataContract.Mapper.BillingResult, private val productCache: Cache, + private val purchaseHandler: Remote.GoogleBillingPurchaseHandler, backgroundDispatcher: CoroutineContext = Dispatchers.IO, ) : DataContract.BillingClient, PurchasesUpdatedListener { @@ -124,7 +121,10 @@ internal class GoogleBillingClient( override suspend fun loadPurchasedContributions(): List { val inAppPurchases = queryPurchase(ProductType.INAPP) val subscriptionPurchases = queryPurchase(ProductType.SUBS) - val contributions = handlePurchases(inAppPurchases.purchasesList + subscriptionPurchases.purchasesList) + val contributions = purchaseHandler.handlePurchases( + clientProvider = clientProvider, + purchases = inAppPurchases.purchasesList + subscriptionPurchases.purchasesList, + ) val recentContribution = if (inAppPurchases.purchasesList.isEmpty()) { loadInAppPurchaseHistory() } else { @@ -223,7 +223,9 @@ internal class GoogleBillingClient( override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList?) { when (billingResult.responseCode) { BillingResponseCode.OK -> coroutineScope.launch { - handlePurchases(purchases) + if (purchases != null) { + purchaseHandler.handlePurchases(clientProvider, purchases) + } } BillingResponseCode.USER_CANCELED -> { @@ -248,52 +250,4 @@ internal class GoogleBillingClient( } } } - - private suspend fun handlePurchases(purchases: List?): List { - return purchases?.mapNotNull { purchase -> - handlePurchase(purchase) - } ?: emptyList() - } - - private suspend fun handlePurchase(purchase: Purchase): Contribution? { - consumePurchase(purchase) - - return if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - val product = purchase.products.firstOrNull()?.let { productCache[it] } ?: return null - val contribution = productMapper.mapToContribution(product) - - if (!purchase.isAcknowledged) { - val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - - val acknowledgeResult: BillingResult = - clientProvider.current.acknowledgePurchase(acknowledgePurchaseParams) - - if (acknowledgeResult.responseCode != BillingResponseCode.OK) { - contribution - } else { - // handle acknowledge error - Timber.e("acknowledgePurchase failed") - null - } - } else { - Timber.e("purchase already acknowledged") - null - } - } else { - Timber.e("purchase not purchased") - null - } - } - - private suspend fun consumePurchase(purchase: Purchase) { - val consumeParams = ConsumeParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - - // This could fail but we can ignore the error as we handle purchases - // the next time the purchases are requested - clientProvider.current.consumePurchase(consumeParams) - } } diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingPurchaseHandler.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingPurchaseHandler.kt new file mode 100644 index 00000000000..c2deb10aae6 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingPurchaseHandler.kt @@ -0,0 +1,93 @@ +package app.k9mail.feature.funding.googleplay.data.remote + +import app.k9mail.core.common.cache.Cache +import app.k9mail.feature.funding.googleplay.data.DataContract +import app.k9mail.feature.funding.googleplay.data.DataContract.Remote +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ConsumeParams +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.acknowledgePurchase +import com.android.billingclient.api.consumePurchase +import timber.log.Timber + +class GoogleBillingPurchaseHandler( + private val productCache: Cache, + private val productMapper: DataContract.Mapper.Product, +) : Remote.GoogleBillingPurchaseHandler { + + override suspend fun handlePurchases( + clientProvider: Remote.GoogleBillingClientProvider, + purchases: List, + ): List { + return purchases.flatMap { purchase -> + handlePurchase(clientProvider.current, purchase) + } + } + + private suspend fun handlePurchase( + billingClient: BillingClient, + purchase: Purchase, + ): List { + // TODO verify purchase with public key + consumePurchase(billingClient, purchase) + acknowledgePurchase(billingClient, purchase) + + return extractContributions(purchase) + } + + private suspend fun acknowledgePurchase( + billingClient: BillingClient, + purchase: Purchase, + ) { + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + if (!purchase.isAcknowledged) { + val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + + val acknowledgeResult: BillingResult = billingClient.acknowledgePurchase(acknowledgePurchaseParams) + + if (acknowledgeResult.responseCode != BillingResponseCode.OK) { + // TODO success + } else { + // handle acknowledge error + Timber.e("acknowledgePurchase failed") + } + } else { + Timber.e("purchase already acknowledged") + } + } else { + Timber.e("purchase not purchased") + } + } + + private suspend fun consumePurchase( + billingClient: BillingClient, + purchase: Purchase, + ) { + val consumeParams = ConsumeParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + + // This could fail but we can ignore the error as we handle purchases + // the next time the purchases are requested + billingClient.consumePurchase(consumeParams) + } + + private fun extractContributions(purchase: Purchase): List { + if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) { + return emptyList() + } + + return purchase.products.mapNotNull { product -> + productCache[product] + }.filter { it.productType == ProductType.SUBS } + .map { productMapper.mapToContribution(it) } + } +}