mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(upgrade): Show the supporter-since date on the FOSS status screen
The upgraded status now renders the date the supporter unlocked, derived in the same emission as the view so the screen never shows the status without the date it is supposed to carry. Guards that date: a return from the recurring-donation button no longer runs persistUpgrade() when the install is already Pro, which would have rewritten upgradedAt and visibly reset the displayed date. The sponsor-return tracker is seeded from the handle-backed pending launch so a process death while the sponsor page is in front does not swallow the first return. The status views are titled "CAPod FOSS" instead of "CAPod Pro" -- on FOSS the flavor name is the brand.
This commit is contained in:
@@ -34,6 +34,10 @@ import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
// Which presentation the FOSS upgrade screen shows: the classic support pitch, or one of the
|
||||
// status views behind the settings "upgrade status" entry.
|
||||
@@ -54,7 +58,12 @@ fun UpgradeScreenHost(
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val sponsorReturnTracker = remember { SponsorReturnTracker() }
|
||||
// Seeded from the ViewModel's handle-backed pending launch: after a process death while the
|
||||
// sponsor page was open, a blank tracker would swallow the very first return. The handle is the
|
||||
// authority on whether a return is still expected, so it reconstructs the tracker's state.
|
||||
val sponsorReturnTracker = remember(vm) {
|
||||
SponsorReturnTracker(wentToBackground = vm.hasPendingSponsorLaunch())
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
vm.snackbarEvents.collect { stringRes ->
|
||||
@@ -77,12 +86,13 @@ fun UpgradeScreenHost(
|
||||
}
|
||||
}
|
||||
|
||||
val view by vm.state.collectAsStateWithLifecycle()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
UpgradeScreen(
|
||||
// Until the route binding lands (one frame): the default route keeps rendering the pitch
|
||||
// exactly as before, only the manage route waits for the status decision.
|
||||
view = view ?: FossUpgradeView.PITCH.takeIf { !route.manage },
|
||||
view = state?.view ?: FossUpgradeView.PITCH.takeIf { !route.manage },
|
||||
supporterSince = state?.supporterSince,
|
||||
snackbarHostState = snackbarHostState,
|
||||
onGithubSponsors = vm::goGithubSponsors,
|
||||
onShowUpgradeOptions = vm::onShowUpgradeOptions,
|
||||
@@ -93,6 +103,7 @@ fun UpgradeScreenHost(
|
||||
@Composable
|
||||
internal fun UpgradeScreen(
|
||||
view: FossUpgradeView? = FossUpgradeView.PITCH,
|
||||
supporterSince: Instant? = null,
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
onGithubSponsors: () -> Unit = {},
|
||||
onShowUpgradeOptions: () -> Unit = {},
|
||||
@@ -104,7 +115,12 @@ internal fun UpgradeScreen(
|
||||
title = if (view == FossUpgradeView.PITCH) {
|
||||
AnnotatedString(stringResource(R.string.settings_upgrade_status_label))
|
||||
} else {
|
||||
upgradeScreenTitle(upgraded = view == FossUpgradeView.STATUS_UPGRADED)
|
||||
// "CAPod FOSS", not "CAPod Pro": on FOSS the flavor name IS the brand. The upgraded
|
||||
// gate keeps the highlight for supporters only.
|
||||
upgradeScreenTitle(
|
||||
upgraded = view == FossUpgradeView.STATUS_UPGRADED,
|
||||
nameRes = R.string.app_name_foss,
|
||||
)
|
||||
},
|
||||
onNavigateUp = onNavigateUp,
|
||||
snackbarHostState = snackbarHostState,
|
||||
@@ -123,6 +139,7 @@ internal fun UpgradeScreen(
|
||||
|
||||
FossUpgradeView.STATUS_UPGRADED -> UpgradeStatusUpgradedContent(
|
||||
paddingValues = paddingValues,
|
||||
supporterSince = supporterSince,
|
||||
onGithubSponsors = onGithubSponsors,
|
||||
)
|
||||
}
|
||||
@@ -216,6 +233,7 @@ private fun UpgradeStatusFreeContent(
|
||||
@Composable
|
||||
private fun UpgradeStatusUpgradedContent(
|
||||
paddingValues: PaddingValues,
|
||||
supporterSince: Instant? = null,
|
||||
onGithubSponsors: () -> Unit,
|
||||
) {
|
||||
UpgradeScreenContent(
|
||||
@@ -238,6 +256,15 @@ private fun UpgradeStatusUpgradedContent(
|
||||
text = stringResource(R.string.upgrade_foss_supporter_thanks),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
supporterSince?.let { since ->
|
||||
val formatter = remember {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault())
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_foss_supporter_since, formatter.format(since)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
UpgradeSectionCard(
|
||||
@@ -257,8 +284,9 @@ private fun UpgradeStatusUpgradedContent(
|
||||
}
|
||||
}
|
||||
|
||||
internal class SponsorReturnTracker {
|
||||
private var wentToBackground = false
|
||||
internal class SponsorReturnTracker(
|
||||
private var wentToBackground: Boolean = false,
|
||||
) {
|
||||
|
||||
fun onStop() {
|
||||
wentToBackground = true
|
||||
@@ -294,6 +322,9 @@ private fun UpgradeScreenStatusFreePreview() {
|
||||
@Composable
|
||||
private fun UpgradeScreenStatusUpgradedPreview() {
|
||||
PreviewWrapper {
|
||||
UpgradeScreen(view = FossUpgradeView.STATUS_UPGRADED)
|
||||
UpgradeScreen(
|
||||
view = FossUpgradeView.STATUS_UPGRADED,
|
||||
supporterSince = Instant.ofEpochMilli(1_700_000_000_000L),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.take
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -43,20 +44,34 @@ class UpgradeViewModel @Inject constructor(
|
||||
// gets a status view first; the pitch only appears once a free user asks for the upgrade
|
||||
// options. Upgrading wins over that choice — completing the sponsor flow from the pitch must
|
||||
// land on the upgraded status, not back on the ask. null until the route is bound.
|
||||
internal val state: StateFlow<FossUpgradeView?> = combine(
|
||||
internal val state: StateFlow<State?> = combine(
|
||||
routeFlow,
|
||||
upgradeRepo.upgradeInfo,
|
||||
handle.getStateFlow(KEY_SHOW_UPGRADE_OPTIONS, false),
|
||||
) { route, info, showOptions ->
|
||||
when {
|
||||
val view = when {
|
||||
route == null -> null
|
||||
route.manage && info.isPro -> FossUpgradeView.STATUS_UPGRADED
|
||||
route.manage && !showOptions -> FossUpgradeView.STATUS_FREE
|
||||
else -> FossUpgradeView.PITCH
|
||||
}
|
||||
// Derived in the same emission as the view on purpose: a sibling flow would let the
|
||||
// upgraded status render for a frame without the date it is supposed to carry.
|
||||
view?.let {
|
||||
State(
|
||||
view = it,
|
||||
supporterSince = info.upgradedAt.takeIf { _ -> it == FossUpgradeView.STATUS_UPGRADED },
|
||||
)
|
||||
}
|
||||
}.safeStateIn(
|
||||
initialValue = null,
|
||||
onError = { FossUpgradeView.PITCH },
|
||||
onError = { State(view = FossUpgradeView.PITCH) },
|
||||
)
|
||||
|
||||
// internal like FossUpgradeView: the view enum is a screen-local presentation detail.
|
||||
internal data class State(
|
||||
val view: FossUpgradeView,
|
||||
val supporterSince: Instant? = null,
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -97,20 +112,32 @@ class UpgradeViewModel @Inject constructor(
|
||||
upgradeRepo.openGithubSponsorsPage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a sponsor-page launch is still awaiting its return.
|
||||
*
|
||||
* Handle-backed, so it survives process recreation while the browser is in front — the screen's
|
||||
* in-memory return tracker does not, and gating on that alone drops the first return after a
|
||||
* recreation.
|
||||
*/
|
||||
fun hasPendingSponsorLaunch(): Boolean = handle.contains(KEY_SPONSOR_PRESSED_AT)
|
||||
|
||||
fun checkSponsorReturn() = launch {
|
||||
val pressedAt = handle.remove<Long>(KEY_SPONSOR_PRESSED_AT) ?: return@launch
|
||||
|
||||
// Evaluated before the duration: an already upgraded supporter (recurring donation button)
|
||||
// has nothing left to unlock, and persisting again would rewrite their upgradedAt — visibly
|
||||
// resetting the "supporter since" date the status screen shows them.
|
||||
if (upgradeRepo.upgradeInfo.first().isPro) {
|
||||
log(TAG) { "checkSponsorReturn(): Already upgraded, staying quiet" }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val elapsed = SystemClock.elapsedRealtime() - pressedAt
|
||||
log(TAG) { "checkSponsorReturn(): elapsed=${elapsed}ms" }
|
||||
|
||||
if (elapsed < SPONSOR_DELAY_MS) {
|
||||
// The nudge belongs to the unlock heuristic. An already upgraded user (recurring
|
||||
// donation button) has nothing to unlock — peeking at the page needs no feedback.
|
||||
if (upgradeRepo.upgradeInfo.first().isPro) {
|
||||
log(TAG) { "checkSponsorReturn(): Too quick, but already upgraded, staying quiet" }
|
||||
} else {
|
||||
log(TAG) { "checkSponsorReturn(): Too quick, showing snackbar" }
|
||||
snackbarEvents.tryEmit(R.string.upgrade_foss_sponsor_returned_early)
|
||||
}
|
||||
log(TAG) { "checkSponsorReturn(): Too quick, showing snackbar" }
|
||||
snackbarEvents.tryEmit(R.string.upgrade_foss_sponsor_returned_early)
|
||||
} else {
|
||||
log(TAG) { "checkSponsorReturn(): Delay passed, persisting upgrade" }
|
||||
upgradeRepo.persistUpgrade()
|
||||
|
||||
@@ -85,10 +85,14 @@ internal object UpgradeScreenTags {
|
||||
// Composed app title with the flavor postfix highlighted in the upgraded color while Pro is
|
||||
// active — the same treatment the dashboard title card uses.
|
||||
@Composable
|
||||
internal fun upgradeScreenTitle(upgraded: Boolean): AnnotatedString {
|
||||
internal fun upgradeScreenTitle(
|
||||
upgraded: Boolean,
|
||||
@StringRes nameRes: Int = R.string.app_name_pro,
|
||||
): AnnotatedString {
|
||||
// capod ships the composed "CAPod Pro" as one translatable string so translations can reorder
|
||||
// the words; the postfix is the trailing part and gets the upgraded highlight.
|
||||
val parts = stringResource(R.string.app_name_pro).split(" ").filter { it.isNotEmpty() }
|
||||
// the words; the postfix is the trailing part and gets the upgraded highlight. FOSS passes its
|
||||
// own "CAPod FOSS" instead — the flavor name is the brand there, Pro is not a thing.
|
||||
val parts = stringResource(nameRes).split(" ").filter { it.isNotEmpty() }
|
||||
val highlight = colorResource(R.color.brand_tertiary)
|
||||
return buildAnnotatedString {
|
||||
if (parts.size == 2) {
|
||||
@@ -97,7 +101,7 @@ internal fun upgradeScreenTitle(upgraded: Boolean): AnnotatedString {
|
||||
append(parts[1])
|
||||
if (upgraded) pop()
|
||||
} else {
|
||||
append(stringResource(R.string.app_name_pro))
|
||||
append(stringResource(nameRes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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.UpgradeRepoFoss
|
||||
import io.kotest.matchers.shouldBe
|
||||
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 org.robolectric.shadows.ShadowSystemClock
|
||||
import testhelpers.BaseTest
|
||||
import testhelpers.TestApplication
|
||||
import testhelpers.coroutine.TestDispatcherProvider
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* Host-level counterpart to the ViewModel's sponsor-return tests: those prove the ViewModel reacts,
|
||||
* they cannot prove the screen actually bridges the lifecycle to it. Only a real STOP/RESUME
|
||||
* round-trip catches a missing or mis-scoped lifecycle effect, or a tracker that isn't seeded from
|
||||
* the handle after a recreation.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33], application = TestApplication::class)
|
||||
class FossUpgradeScreenHostTest : BaseTest() {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private var persisted = 0
|
||||
|
||||
private fun mockRepo(): UpgradeRepoFoss = mockk<UpgradeRepoFoss>(relaxed = true).apply {
|
||||
every { upgradeInfo } returns MutableStateFlow(UpgradeRepoFoss.Info())
|
||||
coEvery { persistUpgrade() } answers { persisted++ }
|
||||
}
|
||||
|
||||
private fun buildVm(
|
||||
repo: UpgradeRepoFoss,
|
||||
handle: SavedStateHandle = SavedStateHandle(),
|
||||
) = UpgradeViewModel(
|
||||
handle = handle,
|
||||
dispatcherProvider = TestDispatcherProvider(),
|
||||
upgradeRepo = repo,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a stop-resume round-trip after a sponsor launch runs the return check`() {
|
||||
// The real ViewModel instance, passed explicitly: hiltViewModel() has nothing to resolve
|
||||
// here. The default route binds via the host's own LaunchedEffect.
|
||||
val vm = buildVm(mockRepo())
|
||||
|
||||
composeRule.setContent {
|
||||
PreviewWrapper {
|
||||
UpgradeScreenHost(vm = vm)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
vm.goGithubSponsors()
|
||||
ShadowSystemClock.advanceBy(Duration.ofSeconds(6))
|
||||
|
||||
// CREATED, not STARTED: only that far down does the activity emit ON_STOP, which is what
|
||||
// "the browser took the foreground" looks like to the return tracker.
|
||||
composeRule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
|
||||
composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.waitUntil { persisted == 1 }
|
||||
vm.hasPendingSponsorLaunch() shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recreated screen consumes the pending sponsor launch on the first resume`() {
|
||||
// Process death while the browser was in front: the fresh screen's tracker never saw the
|
||||
// ON_STOP, so it has to be seeded from the handle or the first return is swallowed.
|
||||
val handle = SavedStateHandle()
|
||||
buildVm(mockRepo(), handle).goGithubSponsors()
|
||||
|
||||
val recreatedVm = buildVm(mockRepo(), handle)
|
||||
recreatedVm.hasPendingSponsorLaunch() shouldBe true
|
||||
|
||||
ShadowSystemClock.advanceBy(Duration.ofSeconds(6))
|
||||
|
||||
composeRule.setContent {
|
||||
PreviewWrapper {
|
||||
UpgradeScreenHost(vm = recreatedVm)
|
||||
}
|
||||
}
|
||||
composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.waitUntil { persisted == 1 }
|
||||
recreatedVm.hasPendingSponsorLaunch() shouldBe false
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import testhelpers.compose.BaseComposeRobolectricTest
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
class FossUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
|
||||
@@ -60,7 +64,9 @@ class FossUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
UpgradeScreen(view = FossUpgradeView.STATUS_FREE)
|
||||
}
|
||||
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.app_name_pro)).assertCountEquals(1)
|
||||
// "CAPod FOSS", not "CAPod Pro": the status views describe a FOSS install.
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.app_name_foss)).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.app_name_pro)).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_STATUS_FREE).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SHOW_OPTIONS).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SPONSOR).assertCountEquals(0)
|
||||
@@ -85,19 +91,39 @@ class FossUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
|
||||
@Test
|
||||
fun `upgraded status view thanks the supporter and offers a recurring donation`() {
|
||||
val since = Instant.ofEpochMilli(1_700_000_000_000L)
|
||||
composeRule.setUpgradeContent {
|
||||
UpgradeScreen(view = FossUpgradeView.STATUS_UPGRADED)
|
||||
UpgradeScreen(view = FossUpgradeView.STATUS_UPGRADED, supporterSince = since)
|
||||
}
|
||||
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.app_name_pro)).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.app_name_foss)).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_STATUS_UPGRADED).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_foss_supporter_thanks))
|
||||
.assertCountEquals(1)
|
||||
val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault())
|
||||
composeRule.onAllNodesWithText(
|
||||
context.getString(R.string.upgrade_foss_supporter_since, formatter.format(since))
|
||||
).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_DONATE).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SHOW_OPTIONS).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SPONSOR).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the supporter-since line stays away without a date`() {
|
||||
// UpgradeRepoFoss can report an upgrade whose record predates the timestamp: no date line
|
||||
// instead of a bogus one.
|
||||
composeRule.setUpgradeContent {
|
||||
UpgradeScreen(view = FossUpgradeView.STATUS_UPGRADED, supporterSince = null)
|
||||
}
|
||||
|
||||
val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault())
|
||||
composeRule.onAllNodesWithText(
|
||||
context.getString(R.string.upgrade_foss_supporter_since, formatter.format(Instant.EPOCH))
|
||||
).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_STATUS_UPGRADED).assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recurring donation button invokes the sponsors callback`() {
|
||||
var clicked = false
|
||||
|
||||
+90
-18
@@ -76,37 +76,55 @@ class FossUpgradeViewModelTest : BaseTest() {
|
||||
fun `manage route shows the free status to non-upgraded users`() = runTest2(context = testDispatcher) {
|
||||
val vm = buildVm()
|
||||
|
||||
val view = async { vm.state.first { it != null } }
|
||||
val view = async { vm.state.first { it != null }!! }
|
||||
vm.bindRoute(Nav.Main.Upgrade(manage = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
view.await() shouldBe FossUpgradeView.STATUS_FREE
|
||||
view.await().view shouldBe FossUpgradeView.STATUS_FREE
|
||||
// Only supporters have a supporter-since date.
|
||||
view.await().supporterSince shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manage route shows the upgraded status to supporters`() = runTest2(context = testDispatcher) {
|
||||
val vm = buildVm(repo = mockRepo(MutableStateFlow(upgradedInfo())))
|
||||
|
||||
val view = async { vm.state.first { it != null } }
|
||||
val view = async { vm.state.first { it != null }!! }
|
||||
vm.bindRoute(Nav.Main.Upgrade(manage = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
view.await() shouldBe FossUpgradeView.STATUS_UPGRADED
|
||||
view.await().view shouldBe FossUpgradeView.STATUS_UPGRADED
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `supporterSince reflects the repo's upgradedAt`() = runTest2(context = testDispatcher) {
|
||||
// Derived in the same emission as the view: the upgraded status must never render a frame
|
||||
// without the date it is supposed to carry.
|
||||
val vm = buildVm(repo = mockRepo(MutableStateFlow(upgradedInfo())))
|
||||
|
||||
val state = async { vm.state.first { it != null }!! }
|
||||
vm.bindRoute(Nav.Main.Upgrade(manage = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
state.await() shouldBe UpgradeViewModel.State(
|
||||
view = FossUpgradeView.STATUS_UPGRADED,
|
||||
supporterSince = Instant.EPOCH,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default and forced routes show the pitch`() = runTest2(context = testDispatcher) {
|
||||
val defaultVm = buildVm()
|
||||
val defaultView = async { defaultVm.state.first { it != null } }
|
||||
val defaultView = async { defaultVm.state.first { it != null }!! }
|
||||
defaultVm.bindRoute(Nav.Main.Upgrade())
|
||||
|
||||
val forcedVm = buildVm()
|
||||
val forcedView = async { forcedVm.state.first { it != null } }
|
||||
val forcedView = async { forcedVm.state.first { it != null }!! }
|
||||
forcedVm.bindRoute(Nav.Main.Upgrade(forced = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
defaultView.await() shouldBe FossUpgradeView.PITCH
|
||||
forcedView.await() shouldBe FossUpgradeView.PITCH
|
||||
defaultView.await().view shouldBe FossUpgradeView.PITCH
|
||||
forcedView.await().view shouldBe FossUpgradeView.PITCH
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -114,15 +132,15 @@ class FossUpgradeViewModelTest : BaseTest() {
|
||||
val vm = buildVm()
|
||||
vm.bindRoute(Nav.Main.Upgrade(manage = true))
|
||||
|
||||
val freeView = async { vm.state.first { it != null } }
|
||||
val freeView = async { vm.state.first { it != null }!! }
|
||||
advanceUntilIdle()
|
||||
freeView.await() shouldBe FossUpgradeView.STATUS_FREE
|
||||
freeView.await().view shouldBe FossUpgradeView.STATUS_FREE
|
||||
|
||||
val pitchView = async { vm.state.first { it == FossUpgradeView.PITCH } }
|
||||
val pitchView = async { vm.state.first { it?.view == FossUpgradeView.PITCH }!! }
|
||||
vm.onShowUpgradeOptions()
|
||||
advanceUntilIdle()
|
||||
|
||||
pitchView.await() shouldBe FossUpgradeView.PITCH
|
||||
pitchView.await().view shouldBe FossUpgradeView.PITCH
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -135,11 +153,11 @@ class FossUpgradeViewModelTest : BaseTest() {
|
||||
|
||||
// Same handle, fresh ViewModel — as after the process was killed on the pitch.
|
||||
val recreatedVm = buildVm(handle = handle)
|
||||
val view = async { recreatedVm.state.first { it != null } }
|
||||
val view = async { recreatedVm.state.first { it != null }!! }
|
||||
recreatedVm.bindRoute(Nav.Main.Upgrade(manage = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
view.await() shouldBe FossUpgradeView.PITCH
|
||||
view.await().view shouldBe FossUpgradeView.PITCH
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -151,15 +169,15 @@ class FossUpgradeViewModelTest : BaseTest() {
|
||||
vm.bindRoute(Nav.Main.Upgrade(manage = true))
|
||||
vm.onShowUpgradeOptions()
|
||||
|
||||
val pitchView = async { vm.state.first { it != null } }
|
||||
val pitchView = async { vm.state.first { it != null }!! }
|
||||
advanceUntilIdle()
|
||||
pitchView.await() shouldBe FossUpgradeView.PITCH
|
||||
pitchView.await().view shouldBe FossUpgradeView.PITCH
|
||||
|
||||
val upgradedView = async { vm.state.first { it == FossUpgradeView.STATUS_UPGRADED } }
|
||||
val upgradedView = async { vm.state.first { it?.view == FossUpgradeView.STATUS_UPGRADED }!! }
|
||||
info.value = upgradedInfo()
|
||||
advanceUntilIdle()
|
||||
|
||||
upgradedView.await() shouldBe FossUpgradeView.STATUS_UPGRADED
|
||||
upgradedView.await().view shouldBe FossUpgradeView.STATUS_UPGRADED
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -237,4 +255,58 @@ class FossUpgradeViewModelTest : BaseTest() {
|
||||
thanks.await() shouldBe R.string.upgrade_foss_supporter_thanks
|
||||
coVerify(exactly = 1) { repo.persistUpgrade() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recurring donation from the upgraded status keeps the supporter date`() = runTest2(
|
||||
context = testDispatcher,
|
||||
) {
|
||||
// The upgraded status screen's donate-again button runs the very same sponsor flow. A
|
||||
// return past the delay must NOT persist again -- that would rewrite upgradedAt and
|
||||
// visibly reset the "supporter since" date the screen shows.
|
||||
val repo = mockRepo(MutableStateFlow(upgradedInfo()))
|
||||
val vm = buildVm(repo = repo)
|
||||
|
||||
val before = async { vm.state.first { it != null }!! }
|
||||
vm.bindRoute(Nav.Main.Upgrade(manage = true))
|
||||
advanceUntilIdle()
|
||||
before.await().supporterSince shouldBe Instant.EPOCH
|
||||
|
||||
vm.goGithubSponsors()
|
||||
ShadowSystemClock.advanceBy(Duration.ofSeconds(6))
|
||||
vm.checkSponsorReturn()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { repo.persistUpgrade() }
|
||||
vm.state.value!!.supporterSince shouldBe Instant.EPOCH
|
||||
}
|
||||
|
||||
/**
|
||||
* Process death between the sponsor launch and the return: the screen's in-memory return
|
||||
* tracker is gone, so the handle-backed pending launch has to carry the state across. Without
|
||||
* it the very first return after a recreation is dropped and the supporter never gets unlocked.
|
||||
*/
|
||||
@Test
|
||||
fun `a sponsor return after process recreation still persists the upgrade`() = runTest2(
|
||||
context = testDispatcher,
|
||||
) {
|
||||
val handle = SavedStateHandle()
|
||||
val firstVm = buildVm(handle = handle)
|
||||
firstVm.goGithubSponsors()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Same handle, fresh ViewModel — as after the process was killed while the browser was up.
|
||||
val repo = mockRepo()
|
||||
val recreatedVm = buildVm(repo = repo, handle = handle)
|
||||
recreatedVm.hasPendingSponsorLaunch() shouldBe true
|
||||
|
||||
val thanks = async { recreatedVm.toastEvents.first() }
|
||||
ShadowSystemClock.advanceBy(Duration.ofSeconds(6))
|
||||
recreatedVm.checkSponsorReturn()
|
||||
advanceUntilIdle()
|
||||
|
||||
thanks.await() shouldBe R.string.upgrade_foss_supporter_thanks
|
||||
coVerify(exactly = 1) { repo.persistUpgrade() }
|
||||
// Consumed: a later resume must not re-run the unlock.
|
||||
recreatedVm.hasPendingSponsorLaunch() shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -2,8 +2,9 @@ package eu.darken.capod.common.upgrade.ui
|
||||
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class SponsorReturnTrackerTest {
|
||||
class SponsorReturnTrackerTest : BaseTest() {
|
||||
|
||||
@Test
|
||||
fun `resume only counts after background transition`() {
|
||||
@@ -16,4 +17,18 @@ class SponsorReturnTrackerTest {
|
||||
tracker.consumeResumeReturn() shouldBe true
|
||||
tracker.consumeResumeReturn() shouldBe false
|
||||
}
|
||||
|
||||
/**
|
||||
* The process can be killed while the sponsor page is in front, i.e. between the launch and the
|
||||
* return. The recomposed screen gets a brand-new tracker that never saw the ON_STOP, so gating
|
||||
* on in-memory state alone would swallow that first return for good. The handle-backed pending
|
||||
* launch is the authority and seeds the tracker instead.
|
||||
*/
|
||||
@Test
|
||||
fun `a tracker seeded from a pending launch counts the first resume`() {
|
||||
val tracker = SponsorReturnTracker(wentToBackground = true)
|
||||
|
||||
tracker.consumeResumeReturn() shouldBe true
|
||||
tracker.consumeResumeReturn() shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user