From 10317b373f3414b2a3d57160b2a2d56926c743e1 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 30 Jul 2026 12:21:21 +0200 Subject: [PATCH] fix(upgrade): Polish the GPlay offers-unavailable card The card reports that PRICES could not be loaded, so it now says so instead of borrowing the generic "Google Play services are unavailable" title, which contradicted its own body. The retry latches after the first tap: the guard sits inside onClick because `enabled` only takes effect after recomposition, so two taps in the same frame would both fire. It resets naturally when the card leaves composition. Returning to the screen re-runs the SKU query when it is in the unavailable state. MainActivity's per-resume refresh only covers the entitlement, so a transient Play outage left the retry card up until it was tapped by hand. --- .../capod/common/upgrade/ui/UpgradeScreen.kt | 22 +++++- .../common/upgrade/ui/UpgradeViewModel.kt | 8 ++ .../upgrade/ui/GplayUpgradeScreenHostTest.kt | 76 +++++++++++++++++++ .../upgrade/ui/GplayUpgradeScreenTest.kt | 26 +++++++ .../upgrade/ui/GplayUpgradeViewModelTest.kt | 44 +++++++++++ 5 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenHostTest.kt diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt index b338d578..327551a3 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -29,6 +30,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import eu.darken.capod.R import eu.darken.capod.common.compose.Preview2 @@ -49,8 +52,9 @@ fun UpgradeScreenHost( val context = LocalContext.current val activity = context as? android.app.Activity - // No screen-level resume refresh: MainActivity already refreshes the upgrade repo on every - // activity resume, which covers returning from Play after cancelling the subscription there. + // MainActivity's per-resume refresh only covers the entitlement, never the screen-local SKU + // query — so a transient Play outage would leave the retry card up until it's tapped by hand. + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { vm.onResume() } // rememberSaveable, not remember: these are driven by one-shot events that are already consumed // from the flow, so a rotation while a dialog is up would drop it for good. @@ -368,14 +372,24 @@ private fun UpgradeOffersBox( when (state) { GplayUpgradeUiState.Loading -> UpgradeActionCard { UpgradeLoadingBlock() } is GplayUpgradeUiState.Unavailable -> UpgradeInlineStateCard( - title = stringResource(R.string.upgrades_gplay_unavailable_error), + title = stringResource(R.string.upgrade_screen_offers_unavailable_title), body = stringResource(R.string.upgrade_screen_offers_unavailable_message), icon = Icons.TwoTone.WarningAmber, ) { // Play can be slow rather than broken (cold store, first sign-in): let // the user re-run the offer queries instead of leaving a dead screen. + // No reset needed: this composable unmounts the moment the state leaves Unavailable. + var retryTapped by remember { mutableStateOf(false) } OutlinedButton( - onClick = onRetry, + // Guard inside the callback, not just via `enabled`: `enabled` only takes effect + // after recomposition, so two taps in the same frame would both fire. + onClick = { + if (!retryTapped) { + retryTapped = true + onRetry() + } + }, + enabled = !retryTapped, modifier = Modifier .fillMaxWidth() .testTag(UpgradeScreenTags.GPLAY_RETRY), 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 4aec46aa..d8d6cfde 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 @@ -266,6 +266,14 @@ class UpgradeViewModel @Inject constructor( retryTrigger.update { it + 1 } } + // Returning to the screen is the user's own "try again": a transient Play outage would + // otherwise leave the retry card up until it's tapped by hand. Only re-queries from the + // unavailable state — a loaded or still-loading screen has nothing to retry. + fun onResume() { + log(TAG) { "onResume()" } + if (state.value is GplayUpgradeUiState.Unavailable) retrySkuQuery() + } + // Acquires the single action slot. Rejects while ANY other entitlement action of this ViewModel // runs, and while the repo reports a Play launch in flight (which may belong to another VM // instance — the repo CAS remains the authoritative gate, this only avoids the pointless tap). diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenHostTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenHostTest.kt new file mode 100644 index 00000000..88e4d74e --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenHostTest.kt @@ -0,0 +1,76 @@ +package eu.darken.capod.common.upgrade.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.SavedStateHandle +import eu.darken.capod.common.compose.PreviewWrapper +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.Sku +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import testhelpers.BaseTest +import testhelpers.TestApplication +import testhelpers.coroutine.TestDispatcherProvider + +/** + * Host-level counterpart to the ViewModel's `onResume` tests: those prove the ViewModel re-queries, + * they cannot prove the screen actually asks it to. Only a real lifecycle round-trip catches a + * missing or mis-scoped ON_RESUME effect. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = TestApplication::class) +class GplayUpgradeScreenHostTest : BaseTest() { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun mockRepo(): UpgradeRepoGplay = mockk(relaxed = true).apply { + every { upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(false, null, null, isSettled = true)) + every { wasEverPro } returns MutableStateFlow(false) + every { proUnconfirmedSince } returns MutableStateFlow(0L) + every { autoRestoreBusy } returns MutableStateFlow(false) + every { purchaseLaunchSku } returns MutableStateFlow(null) + } + + @Test + fun `returning to the screen re-runs a failed offers query`() { + val repo = mockRepo() + var queries = 0 + coEvery { repo.querySkus(any()) } coAnswers { + queries++ + throw GplayServiceUnavailableException(RuntimeException("Play hiccup")) + } + val vm = UpgradeViewModel( + handle = SavedStateHandle(mapOf("forced" to false)), + dispatcherProvider = TestDispatcherProvider(), + upgradeRepo = repo, + webpageTool = mockk(relaxed = true), + ) + + // The real ViewModel instance, passed explicitly: hiltViewModel() has nothing to resolve + // here. The default route binds via the host's own LaunchedEffect. + composeRule.setContent { + PreviewWrapper { + UpgradeScreenHost(vm = vm) + } + } + + composeRule.waitUntil { vm.state.value is GplayUpgradeUiState.Unavailable } + val baseline = queries + + composeRule.activityRule.scenario.moveToState(Lifecycle.State.STARTED) + composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + composeRule.waitForIdle() + + composeRule.waitUntil { queries > baseline } + } +} diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt index f2681241..5220f1bc 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt @@ -152,6 +152,12 @@ class GplayUpgradeScreenTest : BaseComposeRobolectricTest() { composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_UNAVAILABLE).assertCountEquals(1) composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_offers_unavailable_message)).assertCountEquals(1) composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_benefits_title)).assertCountEquals(1) + // The card is about the prices, not about Play as a whole -- the generic service-unavailable + // title contradicted its own body. + composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_offers_unavailable_title)) + .assertCountEquals(1) + composeRule.onAllNodesWithText(context.getString(R.string.upgrades_gplay_unavailable_error)) + .assertCountEquals(0) } @Test @@ -303,6 +309,26 @@ class GplayUpgradeScreenTest : BaseComposeRobolectricTest() { composeRule.runOnIdle { check(retryClicks == 1) { "expected 1 retry click, got $retryClicks" } } } + @Test + fun `the retry latches after the first tap`() { + var retryClicks = 0 + composeRule.setUpgradeContent { + UpgradeScreen( + uiState = GplayUpgradeUiState.Unavailable( + error = RuntimeException("Google Play services unavailable"), + ), + onRetry = { retryClicks++ }, + ) + } + + val retry = composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_RETRY).performScrollTo() + retry.performClick() + retry.performClick() + + composeRule.runOnIdle { check(retryClicks == 1) { "expected 1 retry click, got $retryClicks" } } + composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_RETRY).assertIsNotEnabled() + } + @Test fun `offer copy promises the trial only when Play returned the trial offer`() { composeRule.setUpgradeContent { 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 656ad965..4154ec1a 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 @@ -138,6 +138,50 @@ class GplayUpgradeViewModelTest : BaseTest() { coVerify(exactly = 4) { repo.querySkus(any()) } } + @Test + fun `onResume retries the query after a failure`() = runTest2( + context = testDispatcher, + ) { + // MainActivity's per-resume refresh only covers the entitlement -- coming back to the screen + // after a Play outage has to re-run the screen-local SKU query too. + val repo = mockRepo() + coEvery { repo.querySkus(any()) } throws GplayServiceUnavailableException(RuntimeException("Play hiccup")) + val vm = buildVm(repo) + + val unavailable = async { vm.state.first { it is GplayUpgradeUiState.Unavailable } } + advanceUntilIdle() + + unavailable.await().shouldBeInstanceOf() + coVerify(exactly = 1) { repo.querySkus(OurSku.Iap.PRO_UPGRADE) } + coVerify(exactly = 1) { repo.querySkus(OurSku.Sub.PRO_UPGRADE) } + + vm.onResume() + advanceUntilIdle() + + coVerify(exactly = 2) { repo.querySkus(OurSku.Iap.PRO_UPGRADE) } + coVerify(exactly = 2) { repo.querySkus(OurSku.Sub.PRO_UPGRADE) } + } + + @Test + fun `onResume does not re-query when offers are already loaded`() = runTest2( + context = testDispatcher, + ) { + val repo = mockRepo() + coEvery { repo.querySkus(any()) } returns emptyList() + val vm = buildVm(repo) + + val loaded = async { vm.state.first { it is GplayUpgradeUiState.Loaded } } + advanceUntilIdle() + + loaded.await().shouldBeInstanceOf() + coVerify(exactly = 2) { repo.querySkus(any()) } + + vm.onResume() + advanceUntilIdle() + + coVerify(exactly = 2) { repo.querySkus(any()) } + } + @Test fun `a single failed product type keeps the screen loaded and surfaces the error once`() = runTest2( context = testDispatcher,