Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit b180cc72 authored by Wolf-Martell Montwé's avatar Wolf-Martell Montwé
Browse files

Add BillingClient.startConnection() extension to use coroutines to await...

Add BillingClient.startConnection() extension to use coroutines to await connection and change to Outcome
parent b8750839
Loading
Loading
Loading
Loading
+28 −8
Original line number Diff line number Diff line
@@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.StateFlow
import com.android.billingclient.api.BillingClient as GoogleBillingClient
import com.android.billingclient.api.BillingResult as GoogleBillingResult

interface DataContract {
internal interface DataContract {

    interface Mapper {
        interface Product {
@@ -24,9 +24,9 @@ interface DataContract {
        }

        interface BillingResult {
            fun <T> mapToOutcome(
            suspend fun <T> mapToOutcome(
                billingResult: GoogleBillingResult,
                transformSuccess: () -> T,
                transformSuccess: suspend () -> T,
            ): Outcome<T, BillingError>
        }
    }
@@ -51,6 +51,16 @@ interface DataContract {
                clientProvider: GoogleBillingClientProvider,
                purchases: List<Purchase>,
            ): List<Contribution>

            suspend fun handleOneTimePurchases(
                clientProvider: GoogleBillingClientProvider,
                purchases: List<Purchase>,
            ): List<OneTimeContribution>

            suspend fun handleRecurringPurchases(
                clientProvider: GoogleBillingClientProvider,
                purchases: List<Purchase>,
            ): List<RecurringContribution>
        }
    }

@@ -66,7 +76,7 @@ interface DataContract {
         *
         * @param onConnected Callback to be invoked when the billing service is connected.
         */
        suspend fun <T> connect(onConnected: suspend () -> T): T
        suspend fun <T> connect(onConnected: suspend () -> Outcome<T, BillingError>): Outcome<T, BillingError>

        /**
         * Disconnect from the billing service.
@@ -78,19 +88,29 @@ interface DataContract {
         */
        suspend fun loadOneTimeContributions(
            productIds: List<String>,
        ): List<OneTimeContribution>
        ): Outcome<List<OneTimeContribution>, BillingError>

        /**
         * Load recurring contributions.
         */
        suspend fun loadRecurringContributions(
            productIds: List<String>,
        ): List<RecurringContribution>
        ): Outcome<List<RecurringContribution>, BillingError>

        /**
         * Load purchased one-time contributions.
         */
        suspend fun loadPurchasedOneTimeContributions(): Outcome<List<OneTimeContribution>, BillingError>

        /**
         *  Load purchased recurring contributions.
         */
        suspend fun loadPurchasedRecurringContributions(): Outcome<List<RecurringContribution>, BillingError>

        /**
         * Load purchased contributions.
         * Load the most recent one-time contribution.
         */
        suspend fun loadPurchasedContributions(): List<Contribution>
        suspend fun loadPurchasedOneTimeContributionHistory(): Outcome<OneTimeContribution?, BillingError>

        /**
         * Purchase a contribution.
+68 −92
Original line number Diff line number Diff line
@@ -3,16 +3,15 @@ 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
import app.k9mail.feature.funding.googleplay.data.remote.startConnection
import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError
import app.k9mail.feature.funding.googleplay.domain.Outcome
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 app.k9mail.feature.funding.googleplay.domain.handle
import app.k9mail.feature.funding.googleplay.domain.handleAsync
import app.k9mail.feature.funding.googleplay.domain.mapFailure
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
@@ -34,8 +33,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber

@Suppress("TooManyFunctions")
@@ -60,37 +57,16 @@ internal class GoogleBillingClient(
    override val purchasedContribution: StateFlow<Outcome<Contribution?, BillingError>> =
        _purchasedContribution.asStateFlow()

    override suspend fun <T> connect(onConnected: suspend () -> T): T {
        return suspendCancellableCoroutine { continuation ->
            clientProvider.current.startConnection(
                object : BillingClientStateListener {
                    override fun onBillingSetupFinished(billingResult: BillingResult) {
                        if (billingResult.responseCode == BillingResponseCode.OK) {
                            continuation.resumeWith(
                                Result.runCatching {
                                    runBlocking { onConnected() }
                                },
                            )
                        } else {
                            continuation.resumeWith(
                                Result.failure(
                                    IllegalStateException(
                                        "Error connecting to billing service: ${billingResult.responseCode}",
                                    ),
                                ),
                            )
                        }
                    }
    override suspend fun <T> connect(onConnected: suspend () -> Outcome<T, BillingError>): Outcome<T, BillingError> {
        val connectionResult = clientProvider.current.startConnection()
        val result = resultMapper.mapToOutcome(connectionResult) {}

                    override fun onBillingServiceDisconnected() {
                        continuation.resumeWith(
                            Result.failure(
                                IllegalStateException("Billing service disconnected"),
                            ),
                        )
        return when (result) {
            is Outcome.Success -> {
                onConnected()
            }
                },
            )

            is Outcome.Failure -> result
        }
    }

@@ -100,79 +76,80 @@ internal class GoogleBillingClient(
        clientProvider.clear()
    }

    override suspend fun loadOneTimeContributions(productIds: List<String>): List<OneTimeContribution> {
    override suspend fun loadOneTimeContributions(
        productIds: List<String>,
    ): Outcome<List<OneTimeContribution>, BillingError> {
        val oneTimeProductsResult = queryProducts(ProductType.INAPP, productIds)
        return if (oneTimeProductsResult.billingResult.responseCode == BillingResponseCode.OK) {
        return resultMapper.mapToOutcome(oneTimeProductsResult.billingResult) {
            oneTimeProductsResult.productDetailsList.orEmpty().map {
                val contribution = productMapper.mapToOneTimeContribution(it)
                productCache[it.productId] = it
                contribution
            }
        } else {
            Timber.e(
                "Error loading one-time products: ${oneTimeProductsResult.billingResult.responseCode}",
            )
            emptyList()
        }.mapFailure { billingError, _ ->
            Timber.e("Error loading one-time products: ${oneTimeProductsResult.billingResult.debugMessage}")
            billingError
        }
    }

    override suspend fun loadRecurringContributions(productIds: List<String>): List<RecurringContribution> {
    override suspend fun loadRecurringContributions(
        productIds: List<String>,
    ): Outcome<List<RecurringContribution>, BillingError> {
        val recurringProductsResult = queryProducts(ProductType.SUBS, productIds)
        return if (recurringProductsResult.billingResult.responseCode == BillingResponseCode.OK) {
        return resultMapper.mapToOutcome(recurringProductsResult.billingResult) {
            recurringProductsResult.productDetailsList.orEmpty().map {
                val contribution = productMapper.mapToRecurringContribution(it)
                productCache[it.productId] = it
                contribution
            }
        } else {
            Timber.e(
                "Error querying recurring products: ${recurringProductsResult.billingResult.debugMessage}",
            )
            emptyList()
        }.mapFailure { billingError, _ ->
            Timber.e("Error loading recurring products: ${recurringProductsResult.billingResult.debugMessage}")
            billingError
        }
    }

    override suspend fun loadPurchasedContributions(): List<Contribution> {
        val inAppPurchases = queryPurchase(ProductType.INAPP)
        val subscriptionPurchases = queryPurchase(ProductType.SUBS)
        val contributions = purchaseHandler.handlePurchases(
            clientProvider = clientProvider,
            purchases = inAppPurchases.purchasesList + subscriptionPurchases.purchasesList,
        )
        val recentContribution = if (inAppPurchases.purchasesList.isEmpty()) {
            loadInAppPurchaseHistory()
        } else {
            null
    override suspend fun loadPurchasedOneTimeContributions(): Outcome<List<OneTimeContribution>, BillingError> {
        val purchasesResult = queryPurchase(ProductType.INAPP)
        return resultMapper.mapToOutcome(purchasesResult.billingResult) {
            purchaseHandler.handleOneTimePurchases(clientProvider, purchasesResult.purchasesList)
        }.mapFailure { billingError, _ ->
            Timber.e("Error loading one-time purchases: ${purchasesResult.billingResult.debugMessage}")
            billingError
        }
    }

        return if (recentContribution != null) {
            contributions + recentContribution
        } else {
            contributions
    override suspend fun loadPurchasedRecurringContributions(): Outcome<List<RecurringContribution>, BillingError> {
        val purchasesResult = queryPurchase(ProductType.INAPP)
        return resultMapper.mapToOutcome(purchasesResult.billingResult) {
            purchaseHandler.handleRecurringPurchases(clientProvider, purchasesResult.purchasesList)
        }.mapFailure { billingError, _ ->
            Timber.e("Error loading recurring purchases: ${purchasesResult.billingResult.debugMessage}")
            billingError
        }
    }

    private suspend fun loadInAppPurchaseHistory(): Contribution? {
    override suspend fun loadPurchasedOneTimeContributionHistory(): Outcome<OneTimeContribution?, BillingError> {
        val queryPurchaseHistoryParams = QueryPurchaseHistoryParams.newBuilder()
            .setProductType(ProductType.INAPP)
            .build()

        val result = clientProvider.current.queryPurchaseHistory(queryPurchaseHistoryParams)
        return if (result.billingResult.responseCode == BillingResponseCode.OK) {
            val recentPurchaseId = result.purchaseHistoryRecordList.orEmpty().firstOrNull()?.products?.filter {
        val purchasesResult = clientProvider.current.queryPurchaseHistory(queryPurchaseHistoryParams)
        return resultMapper.mapToOutcome(purchasesResult.billingResult) {
            val recentPurchaseId =
                purchasesResult.purchaseHistoryRecordList.orEmpty().firstOrNull()?.products?.firstOrNull {
                    productCache.hasKey(it)
            }?.firstOrNull()
                }

            if (recentPurchaseId != null) {
                val recentPurchase = productCache[recentPurchaseId]
                productMapper.mapToContribution(recentPurchase!!)
                productMapper.mapToOneTimeContribution(recentPurchase!!)
            } else {
                Timber.e("No recent purchase found: ${result.billingResult.debugMessage}")
                Timber.e("No recent purchase found: ${purchasesResult.billingResult.debugMessage}")
                null
            }
        } else {
            Timber.e("Error querying purchase history: ${result.billingResult.debugMessage}")
            null
        }.mapFailure { billingError, _ ->
            Timber.e("Error loading one-time purchase history: ${purchasesResult.billingResult.debugMessage}")
            billingError
        }
    }

@@ -211,9 +188,8 @@ internal class GoogleBillingClient(
        activity: Activity,
        contribution: Contribution,
    ): Outcome<Unit, BillingError> {
        val productDetails = productCache[contribution.id] ?: return Outcome.failure(
            BillingError.PurchaseFailed("Product details not found for ${contribution.id}"),
        )
        val productDetails = productCache[contribution.id]
            ?: return Outcome.failure(BillingError.PurchaseFailed("ProductDetails not found: ${contribution.id}"))
        val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken

        val productDetailsParamsList = listOf(
@@ -241,10 +217,10 @@ internal class GoogleBillingClient(
    }

    override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) {
        resultMapper.mapToOutcome(billingResult) { }.handle(
        coroutineScope.launch {
            resultMapper.mapToOutcome(billingResult) { }.handleAsync(
                onSuccess = {
                    if (purchases != null) {
                    coroutineScope.launch {
                        val contributions = purchaseHandler.handlePurchases(clientProvider, purchases)
                        if (contributions.isNotEmpty()) {
                            _purchasedContribution.emit(
@@ -254,7 +230,6 @@ internal class GoogleBillingClient(
                            )
                        }
                    }
                }
                },
                onFailure = { error ->
                    Timber.e(
@@ -266,3 +241,4 @@ internal class GoogleBillingClient(
            )
        }
    }
}
+2 −2
Original line number Diff line number Diff line
@@ -8,9 +8,9 @@ import com.android.billingclient.api.BillingResult

class BillingResultMapper : Mapper.BillingResult {

    override fun <T> mapToOutcome(
    override suspend fun <T> mapToOutcome(
        billingResult: BillingResult,
        transformSuccess: () -> T,
        transformSuccess: suspend () -> T,
    ): Outcome<T, BillingError> {
        return when (billingResult.responseCode) {
            BillingResponseCode.OK -> {
+32 −0
Original line number Diff line number Diff line
package app.k9mail.feature.funding.googleplay.data.remote

import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingResult
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine

/**
 * Starts the billing client connection.
 *
 * Kotlin coroutines are used to suspend the coroutine until the connection is established.
 */
internal suspend fun BillingClient.startConnection(): BillingResult = suspendCancellableCoroutine { continuation ->
    startConnection(
        object : BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                continuation.resume(billingResult)
            }

            override fun onBillingServiceDisconnected() {
                continuation.resume(
                    BillingResult.newBuilder()
                        .setResponseCode(BillingResponseCode.SERVICE_DISCONNECTED)
                        .setDebugMessage("Service disconnected: onBillingServiceDisconnected")
                        .build(),
                )
            }
        },
    )
}
+65 −2
Original line number Diff line number Diff line
@@ -4,6 +4,8 @@ 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 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
import com.android.billingclient.api.BillingClient.BillingResponseCode
@@ -16,7 +18,10 @@ import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.consumePurchase
import timber.log.Timber

class GoogleBillingPurchaseHandler(
// TODO propagate errors via Outcome
// TODO optimize purchase handling and reduce duplicate code
@Suppress("TooManyFunctions")
internal class GoogleBillingPurchaseHandler(
    private val productCache: Cache<String, ProductDetails>,
    private val productMapper: DataContract.Mapper.Product,
) : Remote.GoogleBillingPurchaseHandler {
@@ -30,6 +35,24 @@ class GoogleBillingPurchaseHandler(
        }
    }

    override suspend fun handleOneTimePurchases(
        clientProvider: Remote.GoogleBillingClientProvider,
        purchases: List<Purchase>,
    ): List<OneTimeContribution> {
        return purchases.flatMap { purchase ->
            handleOneTimePurchase(clientProvider.current, purchase)
        }
    }

    override suspend fun handleRecurringPurchases(
        clientProvider: Remote.GoogleBillingClientProvider,
        purchases: List<Purchase>
    ): List<RecurringContribution> {
        return purchases.flatMap { purchase ->
            handleRecurringPurchase(clientProvider.current, purchase)
        }
    }

    private suspend fun handlePurchase(
        billingClient: BillingClient,
        purchase: Purchase,
@@ -41,6 +64,26 @@ class GoogleBillingPurchaseHandler(
        return extractContributions(purchase)
    }

    private suspend fun handleOneTimePurchase(
        billingClient: BillingClient,
        purchase: Purchase,
    ): List<OneTimeContribution> {
        // TODO verify purchase with public key
        consumePurchase(billingClient, purchase)

        return extractOneTimeContributions(purchase)
    }

    private suspend fun handleRecurringPurchase(
        billingClient: BillingClient,
        purchase: Purchase,
    ): List<RecurringContribution> {
        // TODO verify purchase with public key
        acknowledgePurchase(billingClient, purchase)

        return extractRecurringContributions(purchase)
    }

    private suspend fun acknowledgePurchase(
        billingClient: BillingClient,
        purchase: Purchase,
@@ -85,9 +128,29 @@ class GoogleBillingPurchaseHandler(
            return emptyList()
        }

        return extractOneTimeContributions(purchase) + extractRecurringContributions(purchase)
    }


    private fun extractOneTimeContributions(purchase: Purchase): List<OneTimeContribution> {
        if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) {
            return emptyList()
        }

        return purchase.products.mapNotNull { product ->
            productCache[product]
        }.filter { it.productType == ProductType.INAPP }
            .map { productMapper.mapToOneTimeContribution(it) }
    }

    private fun extractRecurringContributions(purchase: Purchase): List<RecurringContribution> {
        if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) {
            return emptyList()
        }

        return purchase.products.mapNotNull { product ->
            productCache[product]
        }.filter { it.productType == ProductType.SUBS }
            .map { productMapper.mapToContribution(it) }
            .map { productMapper.mapToRecurringContribution(it) }
    }
}
Loading