mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(upgrade): Surface Google Play errors when a purchase can't start
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -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 {
|
||||
|
||||
+18
@@ -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)
|
||||
)
|
||||
}
|
||||
+5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user