From 2f1cbe34e584592cfa6e8bfc42b8c79a6716adf7 Mon Sep 17 00:00:00 2001 From: Matthias Urhahn Date: Sat, 11 Jul 2026 11:51:54 +0200 Subject: [PATCH] fix(upgrade): Surface Google Play errors when a purchase can't start --- .../common/upgrade/core/UpgradeRepoGplay.kt | 29 ++++++++++- .../core/client/BillingClientConnection.kt | 17 +++++- .../ItemAlreadyOwnedBillingException.kt | 18 +++++++ .../client/UserCanceledBillingException.kt | 5 ++ .../upgrade/core/data/BillingDataRepo.kt | 24 ++++++++- .../capod/upgrade/ui/UpgradeViewModel.kt | 52 +++++++------------ app/src/gplay/res/values/strings.xml | 2 + .../upgrade/core/UpgradeRepoGplayTest.kt | 49 +++++++++++++++++ .../upgrade/core/data/BillingDataRepoTest.kt | 18 ++++++- .../capod/upgrade/ui/UpgradeViewModelTest.kt | 35 +++++++++++++ 10 files changed, 211 insertions(+), 38 deletions(-) create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/ItemAlreadyOwnedBillingException.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/UserCanceledBillingException.kt diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt index fc1ed47c..db570913 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt @@ -2,12 +2,14 @@ package eu.darken.capod.common.upgrade.core import android.app.Activity import eu.darken.capod.common.coroutine.AppScope +import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.common.upgrade.core.client.ItemAlreadyOwnedBillingException import eu.darken.capod.common.upgrade.core.data.BillingData import eu.darken.capod.common.upgrade.core.data.BillingDataRepo import eu.darken.capod.common.upgrade.core.data.PurchasedSku @@ -21,6 +23,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.withTimeoutOrNull import java.time.Duration import java.time.Instant import javax.inject.Inject @@ -128,7 +131,29 @@ class UpgradeRepoGplay @Inject constructor( activity: Activity, sku: Sku, offer: Sku.Subscription.Offer? = null, - ) = billingDataRepo.startBillingFlow(activity, sku, offer) + ) { + try { + billingDataRepo.startBillingFlow(activity, sku, offer) + } catch (e: ItemAlreadyOwnedBillingException) { + // Stale local state: Play says they already own it, so tapping "buy" really means + // "unlock what I own" — restore instead of showing an error. Success is silent, the + // reactive upgradeInfo emission closes the upgrade screen. + log(TAG, INFO) { "Launch says already owned -> restoring purchase" } + val restored = try { + withTimeoutOrNull(RESTORE_ON_OWNED_TIMEOUT_MS) { restorePurchaseNow() } + } catch (re: CancellationException) { + throw re + } catch (re: Exception) { + log(TAG, WARN) { "Restore after already-owned failed: ${re.asLog()}" } + null + } + if (restored?.isPro != true) { + // Couldn't reconcile the entitlement (pending purchase, account mismatch, Play + // quirk) — fall back to the already-owned dialog with restore tips. + throw e + } + } + } companion object { private fun BillingData.getProSku(): PurchasedSku? = purchasedSkus @@ -140,6 +165,8 @@ class UpgradeRepoGplay @Inject constructor( // Keep paying users Pro through transient empty/failed Play Billing responses. val GRACE_PERIOD_MS = Duration.ofDays(7).toMillis() + private const val RESTORE_ON_OWNED_TIMEOUT_MS = 15_000L + val TAG: String = logTag("Upgrade", "Gplay", "Control") } } diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt index 2961c3c3..f8d1bf8a 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt @@ -21,8 +21,10 @@ import eu.darken.capod.common.upgrade.core.CapodSku import eu.darken.capod.common.upgrade.core.data.Sku import eu.darken.capod.common.upgrade.core.data.SkuDetails import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -194,7 +196,20 @@ data class BillingClientConnection( setProductDetailsParamsList(listOf(productParams)) }.build() - return client.launchBillingFlow(activity, billingFlowParams) + // launchBillingFlow must run on the main thread (documented BillingClient contract), and + // its RETURNED result reports whether the flow could be launched at all (ITEM_ALREADY_OWNED, + // BILLING_UNAVAILABLE, DEVELOPER_ERROR, ...) — launch failures arrive here, not as + // exceptions. Throw like the sibling methods do, so callers can surface them instead of + // failing silently. + val result = withContext(Dispatchers.Main) { + client.launchBillingFlow(activity, billingFlowParams) + } + + log(TAG) { "launchBillingFlow(sku=${sku.id}): code=${result.responseCode}, message=${result.debugMessage}" } + + if (!result.isSuccess) throw BillingResultException(result) + + return result } companion object { diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/ItemAlreadyOwnedBillingException.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/ItemAlreadyOwnedBillingException.kt new file mode 100644 index 00000000..e2cfd9f8 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/ItemAlreadyOwnedBillingException.kt @@ -0,0 +1,18 @@ +package eu.darken.capod.common.upgrade.core.client + +import android.content.Context +import eu.darken.capod.R +import eu.darken.capod.common.error.HasLocalizedError +import eu.darken.capod.common.error.LocalizedError + +// Google Play reports the product as already owned when trying to launch the purchase flow. +// UpgradeRepoGplay auto-handles this by restoring; this error only surfaces if that fails. +class ItemAlreadyOwnedBillingException(cause: Throwable) : + Exception("Already owned according to Google Play.", cause), HasLocalizedError { + + override fun getLocalizedError(context: Context): LocalizedError = LocalizedError( + throwable = this, + label = context.getString(R.string.upgrades_gplay_already_owned_label), + description = context.getString(R.string.upgrades_gplay_already_owned_description) + ) +} diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/UserCanceledBillingException.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/UserCanceledBillingException.kt new file mode 100644 index 00000000..d5f3a03b --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/UserCanceledBillingException.kt @@ -0,0 +1,5 @@ +package eu.darken.capod.common.upgrade.core.client + +// The user backed out of the Google Play payment sheet — expected control-flow outcome, +// handled silently by the UI layer, never shown as an error. +class UserCanceledBillingException(cause: Throwable) : Exception("User canceled the billing flow.", cause) diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt index 091ec30e..b9cdb72d 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt @@ -1,6 +1,7 @@ package eu.darken.capod.common.upgrade.core.data import android.app.Activity +import com.android.billingclient.api.BillingClient import com.android.billingclient.api.Purchase import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.debug.Bugs @@ -116,10 +117,11 @@ class BillingDataRepo @Inject constructor( try { val clientConnection = connectionProvider.first() clientConnection.launchBillingFlow(activity, sku, offer) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log(TAG, WARN) { "Failed to start billing flow:\n${e.asLog()}" } - val ignoredCodes = listOf(3, 6) - if (e !is BillingResultException || !e.result.responseCode.let { ignoredCodes.contains(it) }) { + if (e !is BillingResultException || e.result.responseCode !in IGNORED_LAUNCH_CODES) { Bugs.report(TAG, "Billing flow failed for $sku", e) } @@ -130,6 +132,16 @@ class BillingDataRepo @Inject constructor( companion object { val TAG: String = logTag("Upgrade", "Gplay", "Billing", "DataRepo") + // Expected environmental/user situations — user-facing handling only, no bug report. + // USER_CANCELED stays silent in the UI, ITEM_ALREADY_OWNED is auto-handled by + // UpgradeRepoGplay (restore instead of error). + private val IGNORED_LAUNCH_CODES = setOf( + BillingClient.BillingResponseCode.USER_CANCELED, + BillingClient.BillingResponseCode.BILLING_UNAVAILABLE, + BillingClient.BillingResponseCode.ERROR, + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED, + ) + internal fun Throwable.tryMapUserFriendly(): Throwable = when { this is BillingResultException && this.result.isGplayUnavailableTemporary -> { GplayServiceUnavailableException(this) @@ -137,6 +149,14 @@ class BillingDataRepo @Inject constructor( this is BillingResultException && this.result.isGplayUnavailablePermanent -> { GplayServiceUnavailableException(this) } + this is BillingResultException && + this.result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> { + UserCanceledBillingException(this) + } + this is BillingResultException && + this.result.responseCode == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> { + ItemAlreadyOwnedBillingException(this) + } else -> this } } diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt index 8a356d71..05b8a90e 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt @@ -12,6 +12,8 @@ import eu.darken.capod.common.flow.SingleEventFlow import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.common.upgrade.core.CapodSku import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay +import eu.darken.capod.common.upgrade.core.client.UserCanceledBillingException +import eu.darken.capod.common.upgrade.core.data.Sku import eu.darken.capod.common.upgrade.core.data.SkuDetails import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.SharingStarted @@ -21,13 +23,12 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject @HiltViewModel class UpgradeViewModel @Inject constructor( - private val dispatcherProvider: DispatcherProvider, + dispatcherProvider: DispatcherProvider, private val upgradeRepo: UpgradeRepoGplay, ) : ViewModel4(dispatcherProvider) { @@ -132,46 +133,31 @@ class UpgradeViewModel @Inject constructor( billingEvents.tryEmit(BillingEvent.LaunchSubscriptionTrial) } - fun launchBillingIap(activity: Activity) = launch { + fun launchBillingIap(activity: Activity) { log(TAG, INFO) { "launchBillingIap()" } - try { - withContext(dispatcherProvider.Main) { - upgradeRepo.launchBillingFlow(activity, CapodSku.Iap.PRO_UPGRADE) - } - } catch (e: Exception) { - log(TAG) { "launchBillingIap failed: $e" } - errorEvents.emitBlocking(e) - } + launchBillingFlow(activity, CapodSku.Iap.PRO_UPGRADE, null) } - fun launchBillingSubscription(activity: Activity) = launch { + fun launchBillingSubscription(activity: Activity) { log(TAG, INFO) { "launchBillingSubscription()" } - try { - withContext(dispatcherProvider.Main) { - upgradeRepo.launchBillingFlow( - activity, - CapodSku.Sub.PRO_UPGRADE, - CapodSku.Sub.PRO_UPGRADE.BASE_OFFER, - ) - } - } catch (e: Exception) { - log(TAG) { "launchBillingSubscription failed: $e" } - errorEvents.emitBlocking(e) - } + launchBillingFlow(activity, CapodSku.Sub.PRO_UPGRADE, CapodSku.Sub.PRO_UPGRADE.BASE_OFFER) } - fun launchBillingSubscriptionTrial(activity: Activity) = launch { + fun launchBillingSubscriptionTrial(activity: Activity) { log(TAG, INFO) { "launchBillingSubscriptionTrial()" } + launchBillingFlow(activity, CapodSku.Sub.PRO_UPGRADE, CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER) + } + + private fun launchBillingFlow(activity: Activity, sku: Sku, offer: Sku.Subscription.Offer?) = launch { try { - withContext(dispatcherProvider.Main) { - upgradeRepo.launchBillingFlow( - activity, - CapodSku.Sub.PRO_UPGRADE, - CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER, - ) - } + upgradeRepo.launchBillingFlow(activity, sku, offer) + } catch (e: CancellationException) { + throw e + } catch (e: UserCanceledBillingException) { + // Backing out of the payment sheet is a normal user action, not an error. + log(TAG) { "User canceled the billing flow" } } catch (e: Exception) { - log(TAG) { "launchBillingSubscriptionTrial failed: $e" } + log(TAG, WARN) { "launchBillingFlow(${sku.id}) failed: ${e.asLog()}" } errorEvents.emitBlocking(e) } } diff --git a/app/src/gplay/res/values/strings.xml b/app/src/gplay/res/values/strings.xml index 789335ab..7a7a3ffa 100644 --- a/app/src/gplay/res/values/strings.xml +++ b/app/src/gplay/res/values/strings.xml @@ -6,6 +6,8 @@ There was an error in Google Play. Please try again later or reboot your phone.\n\nError: %s Google Play Billing Error There was an error when asking Google Play for your purchase details. Clear Google Play cache and reboot your phone.\n\nError %s + Already purchased? + Google Play reports that you already own this upgrade, but it couldn\'t be restored. Make sure you are using the Google account you purchased with. Play Store synchronization may take time — try rebooting, clearing the Google Play cache or simply waiting. Restore purchase Pro \ No newline at end of file diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt index 3cd98d95..2261f714 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt @@ -1,9 +1,11 @@ package eu.darken.capod.common.upgrade.core +import android.app.Activity import androidx.datastore.preferences.core.PreferenceDataStoreFactory import com.android.billingclient.api.Purchase import eu.darken.capod.common.datastore.createValue import eu.darken.capod.common.datastore.valueBlocking +import eu.darken.capod.common.upgrade.core.client.ItemAlreadyOwnedBillingException import eu.darken.capod.common.upgrade.core.data.BillingData import eu.darken.capod.common.upgrade.core.data.BillingDataRepo import io.kotest.assertions.throwables.shouldThrow @@ -275,4 +277,51 @@ class UpgradeRepoGplayTest : BaseTest() { testScope.cancel() } + + @Test + fun `already-owned buy attempt silently restores the purchase instead of erroring`() = runTest2 { + val testScope = TestScope(UnconfinedTestDispatcher(testScheduler)) + coEvery { billingDataRepo.startBillingFlow(any(), any(), any()) } throws + ItemAlreadyOwnedBillingException(RuntimeException("launch result")) + coEvery { billingDataRepo.refresh() } returns BillingData( + purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)) + ) + val repo = createRepo(testScope) + + // Restore succeeds -> no exception surfaces. + repo.launchBillingFlow(mockk(), CapodSku.Iap.PRO_UPGRADE) + + testScope.cancel() + } + + @Test + fun `already-owned buy attempt falls back to the error when restore finds nothing`() = runTest2 { + val testScope = TestScope(UnconfinedTestDispatcher(testScheduler)) + coEvery { billingDataRepo.startBillingFlow(any(), any(), any()) } throws + ItemAlreadyOwnedBillingException(RuntimeException("launch result")) + coEvery { billingDataRepo.refresh() } returns BillingData(purchases = emptyList()) + val repo = createRepo(testScope) + + // Grace expired -> the restore can't rescue the entitlement either. + shouldThrow { + repo.launchBillingFlow(mockk(), CapodSku.Iap.PRO_UPGRADE) + } + + testScope.cancel() + } + + @Test + fun `already-owned buy attempt falls back to the error when restore itself errors`() = runTest2 { + val testScope = TestScope(UnconfinedTestDispatcher(testScheduler)) + coEvery { billingDataRepo.startBillingFlow(any(), any(), any()) } throws + ItemAlreadyOwnedBillingException(RuntimeException("launch result")) + coEvery { billingDataRepo.refresh() } throws RuntimeException("Play unavailable") + val repo = createRepo(testScope) + + shouldThrow { + repo.launchBillingFlow(mockk(), CapodSku.Iap.PRO_UPGRADE) + } + + testScope.cancel() + } } diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt index af9b18b1..fc3c5b55 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt @@ -5,6 +5,8 @@ import com.android.billingclient.api.BillingResult import eu.darken.capod.common.upgrade.core.client.BillingException import eu.darken.capod.common.upgrade.core.client.BillingResultException import eu.darken.capod.common.upgrade.core.client.GplayServiceUnavailableException +import eu.darken.capod.common.upgrade.core.client.ItemAlreadyOwnedBillingException +import eu.darken.capod.common.upgrade.core.client.UserCanceledBillingException import eu.darken.capod.common.upgrade.core.data.BillingDataRepo.Companion.tryMapUserFriendly import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf @@ -49,8 +51,22 @@ class BillingDataRepoTest : BaseTest() { } @Test - fun `other billing result passes through unchanged`() { + fun `user canceled maps to UserCanceledBillingException`() { + val result = mockBillingResult(BillingClient.BillingResponseCode.USER_CANCELED) + val mapped = BillingResultException(result).tryMapUserFriendly() + mapped.shouldBeInstanceOf() + } + + @Test + fun `item already owned maps to ItemAlreadyOwnedBillingException`() { val result = mockBillingResult(BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) + val mapped = BillingResultException(result).tryMapUserFriendly() + mapped.shouldBeInstanceOf() + } + + @Test + fun `other billing result passes through unchanged`() { + val result = mockBillingResult(BillingClient.BillingResponseCode.DEVELOPER_ERROR) val original = BillingResultException(result) val mapped = original.tryMapUserFriendly() mapped shouldBe original diff --git a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt index c9558792..7d07d838 100644 --- a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt @@ -1,6 +1,8 @@ package eu.darken.capod.upgrade.ui +import android.app.Activity import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay +import eu.darken.capod.common.upgrade.core.client.UserCanceledBillingException import io.kotest.matchers.shouldBe import io.mockk.coEvery import io.mockk.coVerify @@ -97,4 +99,37 @@ class UpgradeViewModelTest : BaseTest() { forwardedError.await() shouldBe boom } + + @Test + fun `user canceling the billing flow stays silent`() = runTest2 { + val repo = mockRepo() + coEvery { repo.launchBillingFlow(any(), any(), any()) } throws + UserCanceledBillingException(RuntimeException("launch result")) + val vm = createVm(repo) + + val errors = mutableListOf() + val errorJob = launch(UnconfinedTestDispatcher(testScheduler)) { vm.errorEvents.collect { errors.add(it) } } + + vm.launchBillingIap(mockk()) + advanceUntilIdle() + + coVerify(exactly = 1) { repo.launchBillingFlow(any(), any(), any()) } + errors shouldBe emptyList() + + errorJob.cancel() + } + + @Test + fun `billing flow launch errors are forwarded to the error dialog`() = runTest2 { + val repo = mockRepo() + val boom = IllegalStateException("launch failed") + coEvery { repo.launchBillingFlow(any(), any(), any()) } throws boom + val vm = createVm(repo) + + val forwardedError = async { vm.errorEvents.first() } + vm.launchBillingIap(mockk()) + advanceUntilIdle() + + forwardedError.await() shouldBe boom + } }