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:
darken
2026-07-30 12:55:31 +02:00
committed by Matthias Urhahn
parent 10317b373f
commit 0c39ca89c8
7 changed files with 322 additions and 44 deletions
@@ -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
@@ -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
}
}
@@ -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
}
}