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,