Loading feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt +28 −8 Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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> } } Loading @@ -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> } } Loading @@ -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. Loading @@ -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. Loading feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt +68 −92 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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") Loading @@ -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 } } Loading @@ -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 } } Loading Loading @@ -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( Loading Loading @@ -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( Loading @@ -254,7 +230,6 @@ internal class GoogleBillingClient( ) } } } }, onFailure = { error -> Timber.e( Loading @@ -266,3 +241,4 @@ internal class GoogleBillingClient( ) } } } feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/BillingResultMapper.kt +2 −2 Original line number Diff line number Diff line Loading @@ -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 -> { Loading feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingClientExtensions.kt 0 → 100644 +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(), ) } }, ) } feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingPurchaseHandler.kt +65 −2 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 { Loading @@ -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, Loading @@ -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, Loading Loading @@ -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
feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt +28 −8 Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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> } } Loading @@ -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> } } Loading @@ -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. Loading @@ -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. Loading
feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt +68 −92 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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") Loading @@ -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 } } Loading @@ -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 } } Loading Loading @@ -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( Loading Loading @@ -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( Loading @@ -254,7 +230,6 @@ internal class GoogleBillingClient( ) } } } }, onFailure = { error -> Timber.e( Loading @@ -266,3 +241,4 @@ internal class GoogleBillingClient( ) } } }
feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/BillingResultMapper.kt +2 −2 Original line number Diff line number Diff line Loading @@ -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 -> { Loading
feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingClientExtensions.kt 0 → 100644 +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(), ) } }, ) }
feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingPurchaseHandler.kt +65 −2 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 { Loading @@ -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, Loading @@ -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, Loading Loading @@ -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) } } }