fix(upgrade): Surface Google Play errors when a purchase can't start

This commit is contained in:
Matthias Urhahn
2026-07-11 11:51:54 +02:00
committed by GitHub
parent 8dcc4f8489
commit 2f1cbe34e5
10 changed files with 211 additions and 38 deletions
@@ -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")
}
}
@@ -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 {
@@ -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)
)
}
@@ -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)
@@ -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
}
}
@@ -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)
}
}
+2
View File
@@ -6,6 +6,8 @@
<string name="upgrades_gplay_billing_error_description">There was an error in Google Play. Please try again later or reboot your phone.\n\nError: %s</string>
<string name="upgrades_gplay_billing_result_error_label">Google Play Billing Error</string>
<string name="upgrades_gplay_billing_result_error_description">There was an error when asking Google Play for your purchase details. Clear Google Play cache and reboot your phone.\n\nError %s</string>
<string name="upgrades_gplay_already_owned_label">Already purchased?</string>
<string name="upgrades_gplay_already_owned_description">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.</string>
<string name="upgrade_restore_action">Restore purchase</string>
<string name="upgrade_badge_label">Pro</string>
</resources>
@@ -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<Activity>(), 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<ItemAlreadyOwnedBillingException> {
repo.launchBillingFlow(mockk<Activity>(), 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<ItemAlreadyOwnedBillingException> {
repo.launchBillingFlow(mockk<Activity>(), CapodSku.Iap.PRO_UPGRADE)
}
testScope.cancel()
}
}
@@ -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<UserCanceledBillingException>()
}
@Test
fun `item already owned maps to ItemAlreadyOwnedBillingException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<ItemAlreadyOwnedBillingException>()
}
@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
@@ -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<Throwable>()
val errorJob = launch(UnconfinedTestDispatcher(testScheduler)) { vm.errorEvents.collect { errors.add(it) } }
vm.launchBillingIap(mockk<Activity>())
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<Activity>())
advanceUntilIdle()
forwardedError.await() shouldBe boom
}
}