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.
This commit is contained in:
darken
2026-07-30 12:55:31 +02:00
committed by Matthias Urhahn
parent 7fb1f7aabd
commit 10317b373f
5 changed files with 172 additions and 4 deletions
@@ -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),
@@ -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).
@@ -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<ComponentActivity>()
private fun mockRepo(): UpgradeRepoGplay = mockk<UpgradeRepoGplay>(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<Sku?>(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 }
}
}
@@ -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 {
@@ -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<GplayUpgradeUiState.Unavailable>()
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<GplayUpgradeUiState.Loaded>()
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,