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

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

Add flow to emit async purchases and errors

parent 159ff1f9
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -13,6 +13,7 @@ import androidx.compose.material.icons.outlined.Archive
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.ChevronLeft
import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.ErrorOutline
@@ -70,6 +71,9 @@ object Icons {
        val ChevronRight: ImageVector
            get() = MaterialIcons.Outlined.ChevronRight

        val Close: ImageVector
            get() = MaterialIcons.Outlined.Close

        val Delete: ImageVector
            get() = MaterialIcons.Outlined.Delete

+16 −2
Original line number Diff line number Diff line
@@ -3,12 +3,12 @@ package app.k9mail.feature.funding.googleplay.ui.contribution
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.common.annotation.PreviewDevicesWithBackground
import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme
import app.k9mail.feature.funding.googleplay.domain.DomainContract
import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.State

@Composable
@PreviewDevicesWithBackground
@Preview(showBackground = true)
fun ContributionContentPreview() {
    PreviewWithTheme {
        ContributionContent(
@@ -34,3 +34,17 @@ fun ContributionContentEmptyPreview() {
        )
    }
}

@Composable
@Preview(showBackground = true)
fun ContributionContentPurchaseErrorPreview() {
    PreviewWithTheme {
        ContributionContent(
            state = State(
                purchaseError = DomainContract.BillingError.DeveloperError("Developer error"),
            ),
            onEvent = {},
            contentPadding = PaddingValues(),
        )
    }
}
+50 −0
Original line number Diff line number Diff line
package app.k9mail.feature.funding.googleplay.ui.contribution

import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme
import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError

@Composable
@Preview(showBackground = true)
fun ContributionErrorPurchaseFailedPreview() {
    PreviewWithTheme {
        ContributionError(
            error = BillingError.PurchaseFailed("Purchase failed"),
            onDismissClick = {},
        )
    }
}

@Composable
@Preview(showBackground = true)
fun ContributionErrorServiceDisconnectedPreview() {
    PreviewWithTheme {
        ContributionError(
            error = BillingError.ServiceDisconnected("Service disconnected"),
            onDismissClick = {},
        )
    }
}

@Composable
@Preview(showBackground = true)
fun ContributionErrorUnknownErrorPreview() {
    PreviewWithTheme {
        ContributionError(
            error = BillingError.DeveloperError("Unknown error"),
            onDismissClick = {},
        )
    }
}

@Composable
@Preview(showBackground = true)
fun ContributionErrorDeveloperErrorPreview() {
    PreviewWithTheme {
        ContributionError(
            error = BillingError.UserCancelled("User cancelled"),
            onDismissClick = {},
        )
    }
}
+7 −1
Original line number Diff line number Diff line
@@ -9,6 +9,7 @@ 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 kotlinx.coroutines.flow.StateFlow
import com.android.billingclient.api.BillingClient as GoogleBillingClient
import com.android.billingclient.api.BillingResult as GoogleBillingResult

@@ -55,6 +56,11 @@ interface DataContract {

    interface BillingClient {

        /**
         * Flow that emits the last purchased contribution.
         */
        val purchasedContribution: StateFlow<Outcome<Contribution?, BillingError>>

        /**
         * Connect to the billing service.
         *
@@ -92,6 +98,6 @@ interface DataContract {
        suspend fun purchaseContribution(
            activity: Activity,
            contribution: Contribution,
        ): Contribution?
        ): Outcome<Unit, BillingError>
    }
}
+47 −32
Original line number Diff line number Diff line
@@ -3,9 +3,13 @@ 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.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.mapFailure
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClient.ProductType
import com.android.billingclient.api.BillingClientStateListener
@@ -26,6 +30,9 @@ import com.android.billingclient.api.queryPurchasesAsync
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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
@@ -47,6 +54,12 @@ internal class GoogleBillingClient(

    private val coroutineScope = CoroutineScope(backgroundDispatcher)

    private val _purchasedContribution = MutableStateFlow<Outcome<Contribution?, BillingError>>(
        value = Outcome.success(null),
    )
    override val purchasedContribution: StateFlow<Outcome<Contribution?, BillingError>> =
        _purchasedContribution.asStateFlow()

    override suspend fun <T> connect(onConnected: suspend () -> T): T {
        return suspendCancellableCoroutine { continuation ->
            clientProvider.current.startConnection(
@@ -83,6 +96,7 @@ internal class GoogleBillingClient(

    override fun disconnect() {
        productCache.clear()
        _purchasedContribution.value = Outcome.success(null)
        clientProvider.clear()
    }

@@ -193,8 +207,13 @@ internal class GoogleBillingClient(
        return clientProvider.current.queryPurchasesAsync(queryPurchaseParams)
    }

    override suspend fun purchaseContribution(activity: Activity, contribution: Contribution): Contribution? {
        val productDetails = productCache[contribution.id] ?: return null
    override suspend fun purchaseContribution(
        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 offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken

        val productDetailsParamsList = listOf(
@@ -212,42 +231,38 @@ internal class GoogleBillingClient(
            .setProductDetailsParamsList(productDetailsParamsList)
            .build()

        val result = clientProvider.current.launchBillingFlow(activity, billingFlowParams)
        return if (result.responseCode == BillingResponseCode.OK) {
            contribution
        } else {
            null
        }
        val billingResult = clientProvider.current.launchBillingFlow(activity, billingFlowParams)
        return resultMapper.mapToOutcome(billingResult) { }.mapFailure(
            transformFailure = { error, _ ->
                Timber.e("Error launching billing flow: ${error.message}")
                error
            },
        )
    }

    override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) {
        when (billingResult.responseCode) {
            BillingResponseCode.OK -> coroutineScope.launch {
        resultMapper.mapToOutcome(billingResult) { }.handle(
            onSuccess = {
                if (purchases != null) {
                    purchaseHandler.handlePurchases(clientProvider, purchases)
                }
            }

            BillingResponseCode.USER_CANCELED -> {
                Timber.d("User canceled the purchase")
                    coroutineScope.launch {
                        val contributions = purchaseHandler.handlePurchases(clientProvider, purchases)
                        if (contributions.isNotEmpty()) {
                            _purchasedContribution.emit(
                                Outcome.success(
                                    contributions.firstOrNull(),
                                ),
                            )
                        }

            BillingResponseCode.ITEM_ALREADY_OWNED -> coroutineScope.launch {
                Timber.d("Item already owned by the user")
                // TODO: Update purchases in this case
                    }

            BillingResponseCode.DEVELOPER_ERROR -> {
                // Make sure the SKU product id is correct and the test apk is signed with a release key.
                Timber.e("Developer error: ${billingResult.debugMessage}")
                }

            else -> {
            },
            onFailure = { error ->
                Timber.e(
                    "Response Code: ${billingResult.responseCode} " +
                        "Billing error: ${billingResult.debugMessage}",
                    "Error onPurchasesUpdated: " +
                        "${billingResult.responseCode}: ${billingResult.debugMessage}",
                )
                _purchasedContribution.value = Outcome.failure(error)
            },
        )
            }
        }
    }
}
Loading