diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt index 4997d63b..fc5f1602 100644 --- a/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt @@ -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), + ) } } diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeViewModel.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeViewModel.kt index 93a12a14..47882106 100644 --- a/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeViewModel.kt +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeViewModel.kt @@ -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 = combine( + internal val state: StateFlow = 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(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() diff --git a/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt b/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt index fd85e995..1acfa609 100644 --- a/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt +++ b/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt @@ -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)) } } } diff --git a/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenHostTest.kt b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenHostTest.kt new file mode 100644 index 00000000..e1cb5abe --- /dev/null +++ b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenHostTest.kt @@ -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() + + private var persisted = 0 + + private fun mockRepo(): UpgradeRepoFoss = mockk(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 + } +} diff --git a/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt index fbad9904..a5bc947d 100644 --- a/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt +++ b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt @@ -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 diff --git a/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeViewModelTest.kt b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeViewModelTest.kt index 19952092..e7bf6ca2 100644 --- a/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeViewModelTest.kt +++ b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeViewModelTest.kt @@ -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 + } } diff --git a/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/SponsorReturnTrackerTest.kt b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/SponsorReturnTrackerTest.kt index 8aeb4c31..6e7ee371 100644 --- a/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/SponsorReturnTrackerTest.kt +++ b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/SponsorReturnTrackerTest.kt @@ -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 + } }