mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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).
|
||||
|
||||
+76
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user