From 2f715ccac9372e164e18788dd914db71bf9e617f Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 6 Aug 2026 18:48:48 +0200 Subject: [PATCH] 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. --- .../common/upgrade/ui/UpgradeViewModel.kt | 24 +++++-- .../upgrade/ui/GplayUpgradeViewModelTest.kt | 62 +++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) 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,