Loading feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/FeatureFundingModule.kt +16 −1 Original line number Diff line number Diff line 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 Loading @@ -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 Loading @@ -35,12 +38,24 @@ val featureFundingModule = module { ) } single<Cache<String, ProductDetails>> { InMemoryCache() } single<DataContract.Remote.GoogleBillingPurchaseHandler> { GoogleBillingPurchaseHandler( productCache = get(), productMapper = get(), ) } single<DataContract.BillingClient> { GoogleBillingClient( clientProvider = get(), productMapper = get(), resultMapper = get(), productCache = InMemoryCache(), productCache = get(), purchaseHandler = get(), ) } Loading feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt +8 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -43,6 +44,13 @@ interface DataContract { */ fun clear() } interface GoogleBillingPurchaseHandler { suspend fun handlePurchases( clientProvider: GoogleBillingClientProvider, purchases: List<Purchase>, ): List<Contribution> } } interface BillingClient { Loading feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt +10 −56 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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<String, ProductDetails>, private val purchaseHandler: Remote.GoogleBillingPurchaseHandler, backgroundDispatcher: CoroutineContext = Dispatchers.IO, ) : DataContract.BillingClient, PurchasesUpdatedListener { Loading Loading @@ -124,7 +121,10 @@ internal class GoogleBillingClient( override suspend fun loadPurchasedContributions(): List<Contribution> { 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 { Loading Loading @@ -223,7 +223,9 @@ internal class GoogleBillingClient( override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) { when (billingResult.responseCode) { BillingResponseCode.OK -> coroutineScope.launch { handlePurchases(purchases) if (purchases != null) { purchaseHandler.handlePurchases(clientProvider, purchases) } } BillingResponseCode.USER_CANCELED -> { Loading @@ -248,52 +250,4 @@ internal class GoogleBillingClient( } } } private suspend fun handlePurchases(purchases: List<Purchase>?): List<Contribution> { 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) } } feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingPurchaseHandler.kt 0 → 100644 +93 −0 Original line number Diff line number Diff line 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<String, ProductDetails>, private val productMapper: DataContract.Mapper.Product, ) : Remote.GoogleBillingPurchaseHandler { override suspend fun handlePurchases( clientProvider: Remote.GoogleBillingClientProvider, purchases: List<Purchase>, ): List<Contribution> { return purchases.flatMap { purchase -> handlePurchase(clientProvider.current, purchase) } } private suspend fun handlePurchase( billingClient: BillingClient, purchase: Purchase, ): List<Contribution> { // 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<Contribution> { if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) { return emptyList() } return purchase.products.mapNotNull { product -> productCache[product] }.filter { it.productType == ProductType.SUBS } .map { productMapper.mapToContribution(it) } } } Loading
feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/FeatureFundingModule.kt +16 −1 Original line number Diff line number Diff line 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 Loading @@ -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 Loading @@ -35,12 +38,24 @@ val featureFundingModule = module { ) } single<Cache<String, ProductDetails>> { InMemoryCache() } single<DataContract.Remote.GoogleBillingPurchaseHandler> { GoogleBillingPurchaseHandler( productCache = get(), productMapper = get(), ) } single<DataContract.BillingClient> { GoogleBillingClient( clientProvider = get(), productMapper = get(), resultMapper = get(), productCache = InMemoryCache(), productCache = get(), purchaseHandler = get(), ) } Loading
feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt +8 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -43,6 +44,13 @@ interface DataContract { */ fun clear() } interface GoogleBillingPurchaseHandler { suspend fun handlePurchases( clientProvider: GoogleBillingClientProvider, purchases: List<Purchase>, ): List<Contribution> } } interface BillingClient { Loading
feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt +10 −56 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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<String, ProductDetails>, private val purchaseHandler: Remote.GoogleBillingPurchaseHandler, backgroundDispatcher: CoroutineContext = Dispatchers.IO, ) : DataContract.BillingClient, PurchasesUpdatedListener { Loading Loading @@ -124,7 +121,10 @@ internal class GoogleBillingClient( override suspend fun loadPurchasedContributions(): List<Contribution> { 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 { Loading Loading @@ -223,7 +223,9 @@ internal class GoogleBillingClient( override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) { when (billingResult.responseCode) { BillingResponseCode.OK -> coroutineScope.launch { handlePurchases(purchases) if (purchases != null) { purchaseHandler.handlePurchases(clientProvider, purchases) } } BillingResponseCode.USER_CANCELED -> { Loading @@ -248,52 +250,4 @@ internal class GoogleBillingClient( } } } private suspend fun handlePurchases(purchases: List<Purchase>?): List<Contribution> { 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) } }
feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingPurchaseHandler.kt 0 → 100644 +93 −0 Original line number Diff line number Diff line 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<String, ProductDetails>, private val productMapper: DataContract.Mapper.Product, ) : Remote.GoogleBillingPurchaseHandler { override suspend fun handlePurchases( clientProvider: Remote.GoogleBillingClientProvider, purchases: List<Purchase>, ): List<Contribution> { return purchases.flatMap { purchase -> handlePurchase(clientProvider.current, purchase) } } private suspend fun handlePurchase( billingClient: BillingClient, purchase: Purchase, ): List<Contribution> { // 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<Contribution> { if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) { return emptyList() } return purchase.products.mapNotNull { product -> productCache[product] }.filter { it.productType == ProductType.SUBS } .map { productMapper.mapToContribution(it) } } }