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
+ }
}