fix(upgrade): Report empty Play product results as a merchandising error

When both the IAP and SUB queries came back with nothing, the screen always
reported a connectivity failure, telling users to clear Play's cache and reboot.
Play can answer OK and simply have no sellable offer (region, account
eligibility, pulled product), where that advice is futile.

Both causes are now inspected: only when BOTH are OfferUnavailableBillingException
does the merchandising copy surface. A single non-merchandising failure can't
rule out a real Play problem, so the conservative copy stays.
This commit is contained in:
darken
2026-08-07 09:49:57 +02:00
committed by Matthias Urhahn
parent 4dba593c13
commit 2f715ccac9
2 changed files with 81 additions and 5 deletions
@@ -18,6 +18,7 @@ import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.OurSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.billing.OfferUnavailableBillingException
import eu.darken.capod.common.upgrade.core.billing.Sku
import eu.darken.capod.common.upgrade.core.billing.SkuDetails
import kotlinx.coroutines.CancellationException
@@ -186,9 +187,22 @@ class UpgradeViewModel @Inject constructor(
if (done != null) {
if (iap == null && sub == null) {
val serviceUnavailableError = GplayServiceUnavailableException(
done.iap.exceptionOrNull() ?: RuntimeException("IAP and SUB data request failed.")
)
val iapCause = done.iap.exceptionOrNull()
val subCause = done.sub.exceptionOrNull()
// Play answered fine and simply has nothing to sell here (region, account
// eligibility, pulled product): reporting that as a connectivity failure sends the
// user chasing futile advice (clear Play's cache, reboot). Only when BOTH causes
// are merchandising — a single connectivity failure can't rule out a real Play
// problem, so the conservative "can't reach Play" copy stays correct.
val queryError = if (
iapCause is OfferUnavailableBillingException && subCause is OfferUnavailableBillingException
) {
iapCause
} else {
GplayServiceUnavailableException(
iapCause ?: RuntimeException("IAP and SUB data request failed.")
)
}
// Grace users and owners are excluded: during an outage (exactly when grace
// matters) they must keep the Loaded presentation with their status/grace card,
// not an acquisition-style error state or dialog.
@@ -197,9 +211,9 @@ class UpgradeViewModel @Inject constructor(
// toggling) — emit once per failure episode, not once per recombination.
if (!hasShownServiceUnavailableError) {
hasShownServiceUnavailableError = true
errorEvents.tryEmit(serviceUnavailableError)
errorEvents.tryEmit(queryError)
}
return@combine GplayUpgradeUiState.Unavailable(serviceUnavailableError)
return@combine GplayUpgradeUiState.Unavailable(queryError)
}
} else {
hasShownServiceUnavailableError = false
@@ -10,6 +10,7 @@ import eu.darken.capod.common.upgrade.core.OurSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.billing.BillingData
import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.billing.OfferUnavailableBillingException
import eu.darken.capod.common.upgrade.core.billing.Sku
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.collections.shouldBeEmpty
@@ -92,6 +93,67 @@ class GplayUpgradeViewModelTest : BaseTest() {
coVerify(exactly = 1) { repo.querySkus(OurSku.Sub.PRO_UPGRADE) }
}
@Test
fun `both product types unavailable surfaces the merchandising error, not a connectivity one`() = runTest2(
context = testDispatcher,
) {
// Play answered OK and simply has no sellable offer here (region, account eligibility,
// pulled product). Reporting that as "can't connect to Google Play" tells the user to
// clear Play's cache and reboot, which cannot help.
val repo = mockRepo()
coEvery { repo.querySkus(OurSku.Iap.PRO_UPGRADE) } throws
OfferUnavailableBillingException(OurSku.Iap.PRO_UPGRADE, null)
coEvery { repo.querySkus(OurSku.Sub.PRO_UPGRADE) } throws
OfferUnavailableBillingException(OurSku.Sub.PRO_UPGRADE, null)
val vm = buildVm(repo)
val unavailableState = async { vm.state.first { it is GplayUpgradeUiState.Unavailable } }
val forwardedError = async { vm.errorEvents.first() }
advanceUntilIdle()
forwardedError.await().shouldBeInstanceOf<OfferUnavailableBillingException>()
val state = unavailableState.await().shouldBeInstanceOf<GplayUpgradeUiState.Unavailable>()
state.error.shouldBeInstanceOf<OfferUnavailableBillingException>()
}
@Test
fun `a mixed failure keeps the conservative connectivity error`() = runTest2(
context = testDispatcher,
) {
// One sku failed for a non-merchandising reason: a real Play problem can't be ruled out,
// so the conservative "can't reach Play" copy stays.
val repo = mockRepo()
coEvery { repo.querySkus(OurSku.Iap.PRO_UPGRADE) } throws
OfferUnavailableBillingException(OurSku.Iap.PRO_UPGRADE, null)
coEvery { repo.querySkus(OurSku.Sub.PRO_UPGRADE) } throws IllegalStateException("Play unavailable")
val vm = buildVm(repo)
val unavailableState = async { vm.state.first { it is GplayUpgradeUiState.Unavailable } }
val forwardedError = async { vm.errorEvents.first() }
advanceUntilIdle()
forwardedError.await().shouldBeInstanceOf<GplayServiceUnavailableException>()
val state = unavailableState.await().shouldBeInstanceOf<GplayUpgradeUiState.Unavailable>()
state.error.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
fun `a connectivity failure on both product types stays a connectivity error`() = runTest2(
context = testDispatcher,
) {
val repo = mockRepo()
coEvery { repo.querySkus(any()) } throws IllegalStateException("Play unavailable")
val vm = buildVm(repo)
val unavailableState = async { vm.state.first { it is GplayUpgradeUiState.Unavailable } }
val forwardedError = async { vm.errorEvents.first() }
advanceUntilIdle()
forwardedError.await().shouldBeInstanceOf<GplayServiceUnavailableException>()
val state = unavailableState.await().shouldBeInstanceOf<GplayUpgradeUiState.Unavailable>()
state.error.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
fun `a slow but healthy Play store loads instead of tripping the timeout`() = runTest2(
context = testDispatcher,