diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeViewModel.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeViewModel.kt index d8d6cfde..43538e75 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeViewModel.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeViewModel.kt @@ -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 diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeViewModelTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeViewModelTest.kt index 4154ec1a..fdba817f 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeViewModelTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeViewModelTest.kt @@ -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() + val state = unavailableState.await().shouldBeInstanceOf() + state.error.shouldBeInstanceOf() + } + + @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() + val state = unavailableState.await().shouldBeInstanceOf() + state.error.shouldBeInstanceOf() + } + + @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() + val state = unavailableState.await().shouldBeInstanceOf() + state.error.shouldBeInstanceOf() + } + @Test fun `a slow but healthy Play store loads instead of tripping the timeout`() = runTest2( context = testDispatcher,