Loading core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icons.kt +4 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionContentPreview.kt +16 −2 Original line number Diff line number Diff line Loading @@ -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( Loading @@ -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(), ) } } feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionErrorPreview.kt 0 → 100644 +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 = {}, ) } } feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt +7 −1 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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. * Loading Loading @@ -92,6 +98,6 @@ interface DataContract { suspend fun purchaseContribution( activity: Activity, contribution: Contribution, ): Contribution? ): Outcome<Unit, BillingError> } } feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt +47 −32 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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( Loading Loading @@ -83,6 +96,7 @@ internal class GoogleBillingClient( override fun disconnect() { productCache.clear() _purchasedContribution.value = Outcome.success(null) clientProvider.clear() } Loading Loading @@ -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( Loading @@ -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
core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icons.kt +4 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading
feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionContentPreview.kt +16 −2 Original line number Diff line number Diff line Loading @@ -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( Loading @@ -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(), ) } }
feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionErrorPreview.kt 0 → 100644 +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 = {}, ) } }
feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt +7 −1 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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. * Loading Loading @@ -92,6 +98,6 @@ interface DataContract { suspend fun purchaseContribution( activity: Activity, contribution: Contribution, ): Contribution? ): Outcome<Unit, BillingError> } }
feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt +47 −32 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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( Loading Loading @@ -83,6 +96,7 @@ internal class GoogleBillingClient( override fun disconnect() { productCache.clear() _purchasedContribution.value = Outcome.success(null) clientProvider.clear() } Loading Loading @@ -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( Loading @@ -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) }, ) } } } }