Skip to content

Commit

Permalink
Add GoogleBillingPurchaseHandler
Browse files Browse the repository at this point in the history
  • Loading branch information
wmontwe committed Oct 21, 2024
1 parent 036aa47 commit 159ff1f
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 57 deletions.
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,6 +44,13 @@ interface DataContract {
*/
fun clear()
}

interface GoogleBillingPurchaseHandler {
suspend fun handlePurchases(
clientProvider: GoogleBillingClientProvider,
purchases: List<Purchase>,
): List<Contribution>
}
}

interface BillingClient {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 -> {
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<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) }
}
}

0 comments on commit 159ff1f

Please sign in to comment.