From cc6fcecc5b22006d6d033a895aa33b359edfff83 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Wed, 27 May 2026 16:53:06 -0400 Subject: [PATCH] [PM-37232] fix: Hide upgrade CTAs while a Premium upgrade is pending (#6978) --- .../billing/manager/PremiumStateManager.kt | 12 ++ .../manager/PremiumStateManagerImpl.kt | 80 +++++++++++- .../repository/model/UpgradeLifecycleState.kt | 33 +++++ .../datasource/disk/SettingsDiskSource.kt | 19 +++ .../datasource/disk/SettingsDiskSourceImpl.kt | 35 ++++- .../feature/premium/plan/PlanScreen.kt | 68 ++++++---- .../feature/premium/plan/PlanViewModel.kt | 50 +++++++- .../manager/PremiumStateManagerTest.kt | 120 ++++++++++++++++++ .../datasource/disk/SettingsDiskSourceTest.kt | 71 +++++++++++ .../disk/util/FakeSettingsDiskSource.kt | 30 +++++ .../feature/premium/plan/PlanScreenTest.kt | 1 + .../feature/premium/plan/PlanViewModelTest.kt | 72 ++++++++++- 12 files changed, 554 insertions(+), 37 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/UpgradeLifecycleState.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManager.kt index c652d43c37..11b96a23d5 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManager.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.billing.manager import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState +import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState import kotlinx.coroutines.flow.StateFlow /** @@ -38,6 +39,11 @@ interface PremiumStateManager { */ val subscriptionStatusStateFlow: StateFlow + /** + * Emits the active user's current [UpgradeLifecycleState]. + */ + val upgradeLifecycleStateFlow: StateFlow + /** * Emits whether the current state should be treated as self-hosted for premium upgrade * gating. Reactive equivalent of [isSelfHosted]. @@ -66,4 +72,10 @@ interface PremiumStateManager { * never re-appears for that user. */ fun dismissUpgradedToPremiumCard() + + /** + * Marks the active user as having a Premium upgrade in flight (Stripe checkout completed + * but the server has not yet flipped `isPremium`). + */ + fun markPremiumUpgradePending(userId: String) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt index d82ade54b2..9dee16494a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt @@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.billing.repository.BillingRepository import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState +import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PushManager @@ -85,6 +86,42 @@ class PremiumStateManagerImpl( initialValue = SubscriptionStatusState.Loading, ) + @OptIn(ExperimentalCoroutinesApi::class) + override val upgradeLifecycleStateFlow: StateFlow = + combine( + authDiskSource.userStateFlow, + subscriptionStatusStateFlow, + authDiskSource.activeUserIdChangesFlow + .flatMapLatest { userId -> + userId + ?.let { id -> + settingsDiskSource + .getPremiumUpgradePendingFlow(id) + .map { it ?: false } + } + ?: flowOf(false) + }, + ) { userState, subscriptionStatus, isPending -> + deriveLifecycleState( + userState = userState, + subscriptionStatus = subscriptionStatus, + isPending = isPending, + ) + } + .distinctUntilChanged() + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = deriveLifecycleState( + userState = authDiskSource.userState, + subscriptionStatus = subscriptionStatusStateFlow.value, + isPending = authDiskSource.userState + ?.activeUserId + ?.let { settingsDiskSource.getPremiumUpgradePending(userId = it) } + ?: false, + ), + ) + @OptIn(ExperimentalCoroutinesApi::class) override val isPremiumUpgradeBannerEligibleFlow: StateFlow = combine( @@ -117,7 +154,7 @@ class PremiumStateManagerImpl( vaultDataState = vaultDataState, ) } - .combine(subscriptionStatusStateFlow) { inputs, subscriptionStatus -> + .combine(upgradeLifecycleStateFlow) { inputs, lifecycle -> val profile = inputs.userState?.activeAccount?.profile ?: return@combine false val isAccountOldEnough = profile.creationDate.isOlderThanDays( @@ -125,12 +162,13 @@ class PremiumStateManagerImpl( clock = clock, ) val itemCount = inputs.vaultDataState.activeVaultItemCount() - val hasPremium = profile.hasPremiumPersonally == true || - profile.hasPremiumFromOrganization == true - val isEffectivelyPremium = hasPremium && - !subscriptionStatus.isInTroubleState() + val lifecycleAllowsBanner = lifecycle is UpgradeLifecycleState.Free || + ( + lifecycle is UpgradeLifecycleState.Premium && + lifecycle.subscriptionStatus.isInTroubleState() + ) - !isEffectivelyPremium && + lifecycleAllowsBanner && inputs.isInAppBillingSupported && inputs.featureFlagEnabled && !inputs.isDismissed && @@ -254,6 +292,7 @@ class PremiumStateManagerImpl( // an upgrade. if (previous?.first == currentUserId && !previous.second) { markUpgradedToPremiumCardPending(userId = currentUserId) + clearPremiumUpgradePending(userId = currentUserId) } } .launchIn(unconfinedScope) @@ -285,6 +324,35 @@ class PremiumStateManagerImpl( ) } + override fun markPremiumUpgradePending(userId: String) { + settingsDiskSource.storePremiumUpgradePending( + userId = userId, + isPending = true, + ) + } + + private fun clearPremiumUpgradePending(userId: String) { + settingsDiskSource.storePremiumUpgradePending( + userId = userId, + isPending = null, + ) + } + + private fun deriveLifecycleState( + userState: UserStateJson?, + subscriptionStatus: SubscriptionStatusState, + isPending: Boolean, + ): UpgradeLifecycleState { + val profile = userState?.activeAccount?.profile ?: return UpgradeLifecycleState.Free + val hasPremium = profile.hasPremiumPersonally == true || + profile.hasPremiumFromOrganization == true + return when { + hasPremium -> UpgradeLifecycleState.Premium(subscriptionStatus = subscriptionStatus) + isPending -> UpgradeLifecycleState.UpgradePending + else -> UpgradeLifecycleState.Free + } + } + private fun markUpgradedToPremiumCardPending(userId: String) { // Don't re-arm the card if the user has already consumed it for this account. if (settingsDiskSource.getUpgradedToPremiumCardConsumed(userId = userId) == true) { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/UpgradeLifecycleState.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/UpgradeLifecycleState.kt new file mode 100644 index 0000000000..42a3de1718 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/UpgradeLifecycleState.kt @@ -0,0 +1,33 @@ +package com.x8bit.bitwarden.data.billing.repository.model + +/** + * Represents the active user's position in the Premium upgrade lifecycle. + * + * Transitions: + * - [Free] → [UpgradePending] when the user completes Stripe checkout and the post-checkout + * sync still reports the user as non-premium — checkout is done, backend reconciliation + * is in flight. + * - [UpgradePending] → [Premium] when the server flips `isPremium` to `true`. + * + * Cancellation, expiration, and other terminal substates are surfaced via + * [Premium.subscriptionStatus] rather than as separate leaves. + */ +sealed class UpgradeLifecycleState { + + /** + * The user has no Premium subscription and no upgrade is in flight. + */ + data object Free : UpgradeLifecycleState() + + /** + * Stripe checkout completed but the server has not yet flipped `isPremium`. + */ + data object UpgradePending : UpgradeLifecycleState() + + /** + * The user holds Premium; [subscriptionStatus] carries the substate (active, canceled, etc). + */ + data class Premium( + val subscriptionStatus: SubscriptionStatusState, + ) : UpgradeLifecycleState() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt index c034515917..e1231c09a8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -182,6 +182,25 @@ interface SettingsDiskSource : FlightRecorderDiskSource { */ fun getUpgradedToPremiumCardPendingFlow(userId: String): Flow + /** + * Retrieves the stored value of whether a Premium upgrade is awaiting server confirmation + * for the given [userId]. + */ + fun getPremiumUpgradePending(userId: String): Boolean? + + /** + * Stores whether a Premium upgrade is awaiting server confirmation for the given [userId]. + */ + fun storePremiumUpgradePending( + userId: String, + isPending: Boolean?, + ) + + /** + * Emits updates that track [getPremiumUpgradePending] for the given [userId]. + */ + fun getPremiumUpgradePendingFlow(userId: String): Flow + /** * Retrieves the biometric integrity validity for the given [userId] and * [systemBioIntegrityState]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 3a0e539e9b..da19488a72 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -57,11 +57,13 @@ private const val UPGRADED_TO_PREMIUM_CARD_CONSUMED = "upgradedToPremiumCardConsumed" private const val UPGRADED_TO_PREMIUM_CARD_PENDING = "upgradedToPremiumCardPending" +private const val PREMIUM_UPGRADE_PENDING = + "premiumUpgradePending" /** * Primary implementation of [SettingsDiskSource]. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") class SettingsDiskSourceImpl( private val sharedPreferences: SharedPreferences, private val json: Json, @@ -107,6 +109,9 @@ class SettingsDiskSourceImpl( private val mutableUpgradedToPremiumCardPendingFlowMap = mutableMapOf>() + private val mutablePremiumUpgradePendingFlowMap = + mutableMapOf>() + private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow() private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow() @@ -264,6 +269,7 @@ class SettingsDiskSourceImpl( // - Premium upgrade banner dismissed // - Upgraded to Premium action card consumed // - Upgraded to Premium action card pending + // - Premium upgrade pending } override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? = @@ -346,6 +352,26 @@ class SettingsDiskSourceImpl( getMutableUpgradedToPremiumCardPendingFlow(userId = userId) .onSubscription { emit(getUpgradedToPremiumCardPending(userId = userId)) } + override fun getPremiumUpgradePending(userId: String): Boolean? = + getBoolean( + key = PREMIUM_UPGRADE_PENDING.appendIdentifier(identifier = userId), + ) + + override fun storePremiumUpgradePending( + userId: String, + isPending: Boolean?, + ) { + putBoolean( + key = PREMIUM_UPGRADE_PENDING.appendIdentifier(identifier = userId), + value = isPending, + ) + getMutablePremiumUpgradePendingFlow(userId = userId).tryEmit(isPending) + } + + override fun getPremiumUpgradePendingFlow(userId: String): Flow = + getMutablePremiumUpgradePendingFlow(userId = userId) + .onSubscription { emit(getPremiumUpgradePending(userId = userId)) } + override fun getAccountBiometricIntegrityValidity( userId: String, systemBioIntegrityState: String, @@ -711,6 +737,13 @@ class SettingsDiskSourceImpl( bufferedMutableSharedFlow(replay = 1) } + private fun getMutablePremiumUpgradePendingFlow( + userId: String, + ): MutableSharedFlow = + mutablePremiumUpgradePendingFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + private fun getMutableLastSyncFlow( userId: String, ): MutableSharedFlow = diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt index 00f65239b9..82d261e9ed 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt @@ -295,33 +295,14 @@ private fun FreeCloudContent( Spacer(modifier = Modifier.height(24.dp)) - BitwardenFilledButton( - label = stringResource(id = BitwardenString.upgrade_now), - onClick = { shouldShowUpgradeDialog = true }, - icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link), - modifier = Modifier - .standardHorizontalMargin() - .fillMaxWidth() - .testTag("UpgradeNowButton"), - ) - - Spacer(modifier = Modifier.height(12.dp)) - - Text( - text = stringResource( - id = BitwardenString - .youll_go_to_stripes_secure_checkout_to_complete_your_purchase, - ), - style = BitwardenTheme.typography.bodyMedium, - color = BitwardenTheme.colorScheme.text.secondary, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .standardHorizontalMargin() - .testTag("StripeFooterText"), - ) - - Spacer(modifier = Modifier.height(16.dp)) + // Hide the Upgrade Now CTA (and its Stripe footer copy) while a Stripe upgrade is + // already in flight for the active user. CTAs reappear once the server flips the + // user to Premium. + if (!viewState.isPremiumUpgradePending) { + UpgradeNowCallToAction( + onUpgradeNowClick = { shouldShowUpgradeDialog = true }, + ) + } Spacer(modifier = Modifier.navigationBarsPadding()) } @@ -344,6 +325,38 @@ private fun FreeCloudContent( } } +@Composable +private fun UpgradeNowCallToAction( + onUpgradeNowClick: () -> Unit, +) { + BitwardenFilledButton( + label = stringResource(id = BitwardenString.upgrade_now), + onClick = onUpgradeNowClick, + icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link), + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth() + .testTag("UpgradeNowButton"), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource( + id = BitwardenString.youll_go_to_stripes_secure_checkout_to_complete_your_purchase, + ), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .testTag("StripeFooterText"), + ) + + Spacer(modifier = Modifier.height(16.dp)) +} + @Suppress("MaxLineLength") @Composable private fun FreeSelfHostedContent( @@ -811,6 +824,7 @@ private fun PlanScreenFreeCloudAccount_preview() { rate = "$1.67", checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), handlers = PlanHandlers( onBackClick = {}, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt index 74b63da686..7c505027ce 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt @@ -25,6 +25,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStat import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState +import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance @@ -91,6 +92,9 @@ class PlanViewModel @Inject constructor( rate = PLACEHOLDER_TEXT, checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = premiumStateManager + .upgradeLifecycleStateFlow + .value is UpgradeLifecycleState.UpgradePending, ) }, dialogState = null, @@ -124,6 +128,12 @@ class PlanViewModel @Inject constructor( .onEach(::sendAction) .launchIn(viewModelScope) + premiumStateManager + .upgradeLifecycleStateFlow + .map { PlanAction.Internal.UpgradeLifecycleStateReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + onFreeCloudContent { viewModelScope.launch { sendAction( @@ -187,6 +197,10 @@ class PlanViewModel @Inject constructor( is PlanAction.Internal.SubscriptionStatusUpdateReceive -> { handleSubscriptionStatusUpdateReceive(action) } + + is PlanAction.Internal.UpgradeLifecycleStateReceive -> { + handleUpgradeLifecycleStateReceive(action) + } } } @@ -403,6 +417,9 @@ class PlanViewModel @Inject constructor( rate = PLACEHOLDER_TEXT, checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = premiumStateManager + .upgradeLifecycleStateFlow + .value is UpgradeLifecycleState.UpgradePending, ), dialogState = PlanState.DialogState.Loading( message = BitwardenString.loading.asText(), @@ -433,6 +450,22 @@ class PlanViewModel @Inject constructor( } } + private fun handleUpgradeLifecycleStateReceive( + action: PlanAction.Internal.UpgradeLifecycleStateReceive, + ) { + val isPending = action.state is UpgradeLifecycleState.UpgradePending + onFreeCloudContent { freeState -> + if (freeState.isPremiumUpgradePending == isPending) return@onFreeCloudContent + mutableStateFlow.update { + it.copy( + viewState = freeState.copy( + isPremiumUpgradePending = isPending, + ), + ) + } + } + } + private fun handleSubscriptionStatusUpdateReceive( action: PlanAction.Internal.SubscriptionStatusUpdateReceive, ) { @@ -561,14 +594,19 @@ class PlanViewModel @Inject constructor( onFreeCloudContent { freeState -> if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent - val isPremium = authRepository + val activeAccount = authRepository .userStateFlow .value ?.activeAccount - ?.isPremium == true + val isPremium = activeAccount?.isPremium == true if (isPremium) { onPremiumUpgradeSuccess() } else { + // Persist the pending-upgrade signal so the Vault banner and the Plan-screen + // Upgrade Now CTA can suppress themselves while the server catches up. + activeAccount?.userId?.let { userId -> + premiumStateManager.markPremiumUpgradePending(userId = userId) + } mutableStateFlow.update { it.copy( dialogState = PlanState.DialogState.PendingUpgrade, @@ -802,6 +840,7 @@ data class PlanState( val rate: String, val checkoutUrl: String?, val isAwaitingPremiumStatus: Boolean, + val isPremiumUpgradePending: Boolean, ) : Free() /** @@ -1119,6 +1158,13 @@ sealed class PlanAction { data class SubscriptionStatusUpdateReceive( val state: SubscriptionStatusState, ) : Internal() + + /** + * The shared [UpgradeLifecycleState] for the active user has updated. + */ + data class UpgradeLifecycleStateReceive( + val state: UpgradeLifecycleState, + ) : Internal() } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt index aa8ddf85d6..271b88163e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt @@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStat import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState +import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PushManager @@ -724,6 +725,125 @@ class PremiumStateManagerTest { ) } + @Test + fun `upgradeLifecycleStateFlow emits Free when nothing has been observed`() = runTest { + val manager = createManager() + manager.upgradeLifecycleStateFlow.test { + assertEquals(UpgradeLifecycleState.Free, awaitItem()) + } + } + + @Test + fun `upgradeLifecycleStateFlow emits Free when there is no active user`() = runTest { + fakeAuthDiskSource.userState = null + val manager = createManager() + manager.upgradeLifecycleStateFlow.test { + assertEquals(UpgradeLifecycleState.Free, awaitItem()) + } + } + + @Test + fun `markPremiumUpgradePending transitions upgradeLifecycleStateFlow to UpgradePending`() = + runTest { + val manager = createManager() + manager.upgradeLifecycleStateFlow.test { + assertEquals(UpgradeLifecycleState.Free, awaitItem()) + manager.markPremiumUpgradePending(userId = ACTIVE_USER_ID) + assertEquals(UpgradeLifecycleState.UpgradePending, awaitItem()) + fakeSettingsDiskSource.assertPremiumUpgradePending( + userId = ACTIVE_USER_ID, + expected = true, + ) + } + } + + @Test + fun `upgradeLifecycleStateFlow emits Premium when the active user holds personal Premium`() = + runTest { + fakeAuthDiskSource.userState = userStateJsonWith( + account = createAccountJson(hasPremiumPersonally = true), + ) + val manager = createManager() + manager.upgradeLifecycleStateFlow.test { + assertEquals( + UpgradeLifecycleState.Premium( + subscriptionStatus = SubscriptionStatusState.NoSubscription, + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `upgradeLifecycleStateFlow re-keys on active user change and only reflects the active user's flag`() = + runTest { + val otherUserId = "otherUserId" + fakeSettingsDiskSource.storePremiumUpgradePending( + userId = ACTIVE_USER_ID, + isPending = true, + ) + val manager = createManager() + manager.upgradeLifecycleStateFlow.test { + assertEquals(UpgradeLifecycleState.UpgradePending, awaitItem()) + // Switching active user — the other user has no pending flag set. + fakeAuthDiskSource.userState = userStateJsonWith( + account = createAccountJson(userId = otherUserId), + ) + assertEquals(UpgradeLifecycleState.Free, awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `userState transition from non-Premium to personal Premium transitions upgradeLifecycleStateFlow to Premium and clears the pending flag`() = + runTest { + // Free user with a pending upgrade in flight. + fakeAuthDiskSource.userState = userStateJsonWith(account = createAccountJson()) + fakeSettingsDiskSource.storePremiumUpgradePending( + userId = ACTIVE_USER_ID, + isPending = true, + ) + val manager = createManager() + manager.upgradeLifecycleStateFlow.test { + assertEquals(UpgradeLifecycleState.UpgradePending, awaitItem()) + // Server flips personal Premium on — lifecycle transitions to Premium, the + // disk-backed pending flag auto-clears via the manager's init block. + fakeAuthDiskSource.userState = userStateJsonWith( + account = createAccountJson(hasPremiumPersonally = true), + ) + assertEquals( + UpgradeLifecycleState.Premium( + subscriptionStatus = SubscriptionStatusState.NoSubscription, + ), + awaitItem(), + ) + fakeSettingsDiskSource.assertPremiumUpgradePending( + userId = ACTIVE_USER_ID, + expected = null, + ) + } + } + + @Test + fun `banner is ineligible while the active user has a pending Premium upgrade`() = runTest { + // Baseline: banner is eligible (the default DEFAULT_USER_STATE_JSON satisfies all gates). + fakeSettingsDiskSource.storePremiumUpgradePending( + userId = ACTIVE_USER_ID, + isPending = true, + ) + val manager = createManager() + manager.isPremiumUpgradeBannerEligibleFlow.test { + assertFalse(awaitItem()) + // Clearing pending re-enables the banner. + fakeSettingsDiskSource.storePremiumUpgradePending( + userId = ACTIVE_USER_ID, + isPending = false, + ) + assertTrue(awaitItem()) + } + } + @Test fun `subscriptionStatusStateFlow emits NoSubscription when there is no active user`() = runTest { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt index 2473a10832..b07b119cc5 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -154,6 +154,10 @@ class SettingsDiskSourceTest { userId = userId, isPending = true, ) + settingsDiskSource.storePremiumUpgradePending( + userId = userId, + isPending = true, + ) settingsDiskSource.storeInlineAutofillEnabled( userId = userId, isInlineAutofillEnabled = true, @@ -196,6 +200,9 @@ class SettingsDiskSourceTest { assertTrue( settingsDiskSource.getUpgradedToPremiumCardPending(userId = userId) ?: false, ) + assertTrue( + settingsDiskSource.getPremiumUpgradePending(userId = userId) ?: false, + ) // These should be cleared assertNull(settingsDiskSource.getVaultTimeoutInMinutes(userId = userId)) @@ -955,6 +962,70 @@ class SettingsDiskSourceTest { } } + @Test + fun `getPremiumUpgradePending when values are present should pull from SharedPreferences`() { + val baseKey = "bwPreferencesStorage:premiumUpgradePending" + val mockUserId = "mockUserId" + val key = "${baseKey}_$mockUserId" + assertNull(settingsDiskSource.getPremiumUpgradePending(userId = mockUserId)) + fakeSharedPreferences.edit { putBoolean(key, true) } + assertEquals( + true, + settingsDiskSource.getPremiumUpgradePending(userId = mockUserId), + ) + } + + @Test + fun `storePremiumUpgradePending for non-null values should update SharedPreferences`() { + val baseKey = "bwPreferencesStorage:premiumUpgradePending" + val mockUserId = "mockUserId" + val key = "${baseKey}_$mockUserId" + settingsDiskSource.storePremiumUpgradePending( + userId = mockUserId, + isPending = true, + ) + assertTrue(fakeSharedPreferences.getBoolean(key, false)) + } + + @Test + fun `storePremiumUpgradePending for null values should clear SharedPreferences`() { + val baseKey = "bwPreferencesStorage:premiumUpgradePending" + val mockUserId = "mockUserId" + val key = "${baseKey}_$mockUserId" + fakeSharedPreferences.edit { putBoolean(key, true) } + settingsDiskSource.storePremiumUpgradePending( + userId = mockUserId, + isPending = null, + ) + assertFalse(fakeSharedPreferences.contains(key)) + } + + @Test + fun `getPremiumUpgradePendingFlow should react to changes in storePremiumUpgradePending`() = + runTest { + val mockUserId = "mockUserId" + settingsDiskSource + .getPremiumUpgradePendingFlow(userId = mockUserId) + .test { + assertNull( + settingsDiskSource.getPremiumUpgradePending(userId = mockUserId), + ) + assertNull(awaitItem()) + + settingsDiskSource.storePremiumUpgradePending( + userId = mockUserId, + isPending = true, + ) + assertEquals(true, awaitItem()) + + settingsDiskSource.storePremiumUpgradePending( + userId = mockUserId, + isPending = false, + ) + assertEquals(false, awaitItem()) + } + } + @Test fun `storePullToRefreshEnabled for non-null values should update SharedPreferences`() { val pullToRefreshBaseKey = "bwPreferencesStorage:syncOnRefresh" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt index 9caafd2139..241a0590c0 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt @@ -70,6 +70,7 @@ class FakeSettingsDiskSource( private var storedPremiumUpgradeBannerDismissed = mutableMapOf() private val storedUpgradedToPremiumCardConsumed = mutableMapOf() private val storedUpgradedToPremiumCardPending = mutableMapOf() + private val storedPremiumUpgradePending = mutableMapOf() private val storedInlineAutofillEnabled = mutableMapOf() private val storedBlockedAutofillUris = mutableMapOf?>() private var storedIsIconLoadingDisabled: Boolean? = null @@ -124,6 +125,9 @@ class FakeSettingsDiskSource( private val mutableUpgradedToPremiumCardPendingFlow = mutableMapOf>() + private val mutablePremiumUpgradePendingFlow = + mutableMapOf>() + override var appLanguage: AppLanguage? get() = storedAppLanguage set(value) { @@ -389,6 +393,18 @@ class FakeSettingsDiskSource( getMutableUpgradedToPremiumCardPendingFlow(userId = userId) .onSubscription { emit(getUpgradedToPremiumCardPending(userId = userId)) } + override fun getPremiumUpgradePending(userId: String): Boolean? = + storedPremiumUpgradePending[userId] + + override fun storePremiumUpgradePending(userId: String, isPending: Boolean?) { + storedPremiumUpgradePending[userId] = isPending + getMutablePremiumUpgradePendingFlow(userId = userId).tryEmit(isPending) + } + + override fun getPremiumUpgradePendingFlow(userId: String): Flow = + getMutablePremiumUpgradePendingFlow(userId = userId) + .onSubscription { emit(getPremiumUpgradePending(userId = userId)) } + override fun getInlineAutofillEnabled(userId: String): Boolean? = storedInlineAutofillEnabled[userId] @@ -567,6 +583,13 @@ class FakeSettingsDiskSource( assertEquals(expected, storedUpgradedToPremiumCardPending[userId]) } + /** + * Asserts that the stored "Premium upgrade pending" value matches the [expected] one. + */ + fun assertPremiumUpgradePending(userId: String, expected: Boolean?) { + assertEquals(expected, storedPremiumUpgradePending[userId]) + } + /** * Asserts that the stored last sync time matches the [expected] one. */ @@ -652,6 +675,13 @@ class FakeSettingsDiskSource( bufferedMutableSharedFlow(replay = 1) } + private fun getMutablePremiumUpgradePendingFlow( + userId: String, + ): MutableSharedFlow = + mutablePremiumUpgradePendingFlow.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + private fun getMutableShowAutoFillSettingBadgeFlow( userId: String, ): MutableSharedFlow = mutableShowAutoFillSettingBadgeFlowMap.getOrPut(userId) { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt index 62587bef1c..5bdc95082c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt @@ -1334,6 +1334,7 @@ private val DEFAULT_FREE_STATE = PlanState( rate = "$1.65", checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = null, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt index 6aed400d83..3fbc1ca54f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt @@ -20,6 +20,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStat import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState +import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance @@ -67,9 +68,12 @@ class PlanViewModelTest : BaseViewModelTest() { } private val mutableSubscriptionStatusStateFlow = MutableStateFlow(SubscriptionStatusState.NoSubscription) + private val mutableLifecycleStateFlow = + MutableStateFlow(UpgradeLifecycleState.Free) private var mockIsSelfHosted = false - private val mockPremiumStateManager: PremiumStateManager = mockk { + private val mockPremiumStateManager: PremiumStateManager = mockk(relaxed = true) { every { subscriptionStatusStateFlow } returns mutableSubscriptionStatusStateFlow + every { upgradeLifecycleStateFlow } returns mutableLifecycleStateFlow every { isSelfHosted } answers { mockIsSelfHosted } } private val mutableEnvironmentFlow = MutableStateFlow(Environment.Us) @@ -144,6 +148,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "$1.67", checkoutUrl = null, isAwaitingPremiumStatus = true, + isPremiumUpgradePending = false, ), dialogState = PlanState.DialogState.WaitingForPayment, ), @@ -224,6 +229,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "$1.67", checkoutUrl = checkoutUrl, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = null, ), @@ -314,6 +320,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "$1.67", checkoutUrl = checkoutUrl, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = null, ), @@ -386,6 +393,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "$1.67", checkoutUrl = checkoutUrl, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ) val viewModel = createViewModel( initialState = DEFAULT_FREE_STATE.copy( @@ -434,6 +442,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "$1.67", checkoutUrl = null, isAwaitingPremiumStatus = true, + isPremiumUpgradePending = false, ), dialogState = PlanState.DialogState.WaitingForPayment, ), @@ -476,6 +485,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "$1.67", checkoutUrl = null, isAwaitingPremiumStatus = true, + isPremiumUpgradePending = false, ), dialogState = PlanState.DialogState.WaitingForPayment, ), @@ -531,6 +541,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "$1.67", checkoutUrl = null, isAwaitingPremiumStatus = true, + isPremiumUpgradePending = false, ), dialogState = PlanState.DialogState.PendingUpgrade, ), @@ -540,12 +551,61 @@ class PlanViewModelTest : BaseViewModelTest() { verify { mockSpecialCircumstanceManager.specialCircumstance = null + mockPremiumStateManager.markPremiumUpgradePending(userId = DEFAULT_ACCOUNT.userId) } coVerify { mockVaultRepository.syncForResult(forced = true) } } + @Test + fun `ContinueClick dismisses the PendingUpgrade dialog and navigates back`() = runTest { + val viewModel = createViewModel( + initialState = DEFAULT_FREE_STATE.copy( + viewState = PlanState.ViewState.Free.Cloud( + rate = "$1.67", + checkoutUrl = null, + isAwaitingPremiumStatus = true, + isPremiumUpgradePending = true, + ), + dialogState = PlanState.DialogState.PendingUpgrade, + ), + pricingResult = null, + ) + + viewModel.eventFlow.test { + viewModel.trySendAction(PlanAction.ContinueClick) + assertEquals(PlanEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `upgradeLifecycleStateFlow updates propagate onto Free Cloud view state`() = runTest { + val viewModel = createViewModel() + + viewModel.stateFlow.test { + assertEquals(DEFAULT_FREE_STATE, awaitItem()) + + mutableLifecycleStateFlow.value = UpgradeLifecycleState.UpgradePending + + assertEquals( + DEFAULT_FREE_STATE.copy( + viewState = PlanState.ViewState.Free.Cloud( + rate = "$1.67", + checkoutUrl = null, + isAwaitingPremiumStatus = false, + isPremiumUpgradePending = true, + ), + ), + awaitItem(), + ) + + mutableLifecycleStateFlow.value = UpgradeLifecycleState.Free + + assertEquals(DEFAULT_FREE_STATE, awaitItem()) + } + } + @Test fun `UserStateUpdateReceive premium during Loading should navigate to UpgradedToPremium`() = runTest { @@ -555,6 +615,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "$1.67", checkoutUrl = null, isAwaitingPremiumStatus = true, + isPremiumUpgradePending = false, ), dialogState = PlanState.DialogState.Loading( message = BitwardenString.confirming_your_upgrade.asText(), @@ -666,6 +727,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "$1.67", checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = null, ), @@ -694,6 +756,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "--", checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = null, ), @@ -719,6 +782,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "--", checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = PlanState.DialogState.GetPricingError( title = BitwardenString.pricing_unavailable.asText(), @@ -747,6 +811,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "--", checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = PlanState.DialogState.GetPricingError( title = BitwardenString.pricing_unavailable.asText(), @@ -770,6 +835,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "--", checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = PlanState.DialogState.Loading( message = BitwardenString.loading.asText(), @@ -801,6 +867,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "--", checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = PlanState.DialogState.GetPricingError( title = BitwardenString.pricing_unavailable.asText(), @@ -819,6 +886,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "--", checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = null, ), @@ -990,6 +1058,7 @@ class PlanViewModelTest : BaseViewModelTest() { rate = "--", checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = PlanState.DialogState.Loading( message = BitwardenString.loading.asText(), @@ -1770,6 +1839,7 @@ private val DEFAULT_FREE_STATE = PlanState( rate = "$1.67", checkoutUrl = null, isAwaitingPremiumStatus = false, + isPremiumUpgradePending = false, ), dialogState = null, )