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 409a0875f9..582461c67a 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 @@ -466,24 +466,13 @@ class PlanViewModel @Inject constructor( action: PlanAction.Internal.SubscriptionStatusUpdateReceive, ) { val status = (action.state as? SubscriptionStatusState.Available)?.status ?: return - if (!status.isPremiumViewEligible()) return - onFreeCloudContent { freeState -> - if (freeState.isAwaitingPremiumStatus) return@onFreeCloudContent - mutableStateFlow.update { - it.copy( - viewState = PlanState.ViewState.Loading( - message = BitwardenString.loading_subscription.asText(), - ), - ) - } - viewModelScope.launch { - sendAction( - PlanAction.Internal.SubscriptionResultReceive( - result = billingRepository.getSubscription(), - ), - ) - } + // ACTIVE status for a premium account must promote too, not just trouble statuses. + val isPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true + if (!isPremium && !status.isPremiumViewEligible()) { + return } + // Only a confirmed-premium signal celebrates; a trouble status promotes silently. + promoteFreeCloudToPremiumView(isConfirmedPremium = isPremium) } // endregion Premium user handlers @@ -494,17 +483,15 @@ class PlanViewModel @Inject constructor( action: PlanAction.Internal.UserStateUpdateReceive, ) { val isPremium = action.userState?.activeAccount?.isPremium == true + val wasShowingPremiumView = state.showsPremiumView mutableStateFlow.update { it.copy( showsPremiumView = isPremium || premiumStateManager.subscriptionStatusStateFlow.value.isPremiumViewEligible(), ) } - onFreeCloudContent { freeState -> - if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent - if (isPremium) { - onPremiumUpgradeSuccess() - } + if (isPremium && !wasShowingPremiumView) { + promoteFreeCloudToPremiumView(isConfirmedPremium = true) } } @@ -615,7 +602,19 @@ class PlanViewModel @Inject constructor( } } - private fun onPremiumUpgradeSuccess() { + // Fires the celebration event only when isConfirmedPremium and a checkout was actually + // in flight; otherwise this is a silent state recovery. + private fun promoteFreeCloudToPremiumView(isConfirmedPremium: Boolean) { + onFreeCloudContent { freeState -> + if (isConfirmedPremium && freeState.isAwaitingPremiumStatus) { + onPremiumUpgradeSuccess() + } else { + promoteToPremiumView() + } + } + } + + private fun promoteToPremiumView() { onFreeCloudContent { mutableStateFlow.update { it.copy( @@ -632,9 +631,10 @@ class PlanViewModel @Inject constructor( ) } } - // The Upgraded to Premium route uses `launchSingleTop = true` so a duplicate event is a - // no-op for the user. The event itself is harmless to re-emit; the state mutation above - // is what's guarded by `onFreeCloudContent`. + } + + private fun onPremiumUpgradeSuccess() { + promoteToPremiumView() sendEvent(PlanEvent.NavigateToUpgradedToPremium) } 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 66722382a9..c0ec5b14fd 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 @@ -435,37 +435,6 @@ class PlanViewModelTest : BaseViewModelTest() { } } - @Test - fun `premium status flip should show snackbar when in WaitingForPayment`() = - runTest { - val viewModel = createViewModel( - initialState = DEFAULT_FREE_STATE.copy( - viewState = PlanState.ViewState.Content.Free.Cloud( - rate = "$1.67", - checkoutUrl = null, - isAwaitingPremiumStatus = true, - isPremiumUpgradePending = false, - ), - dialogState = PlanState.DialogState.WaitingForPayment, - ), - pricingResult = null, - ) - - viewModel.eventFlow.test { - // Simulate premium status flip while WaitingForPayment. - mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( - accounts = listOf( - DEFAULT_ACCOUNT.copy(isPremium = true), - ), - ) - - assertEquals( - PlanEvent.NavigateToUpgradedToPremium, - awaitItem(), - ) - } - } - @Test fun `premium flip via canceled special circumstance should navigate to UpgradedToPremium`() = runTest { @@ -610,24 +579,13 @@ class PlanViewModelTest : BaseViewModelTest() { } @Test - fun `UserStateUpdateReceive premium during Loading should navigate to UpgradedToPremium`() = + fun `premium status flip should promote off Free view when not awaiting checkout`() = runTest { - val viewModel = createViewModel( - initialState = DEFAULT_FREE_STATE.copy( - viewState = PlanState.ViewState.Content.Free.Cloud( - rate = "$1.67", - checkoutUrl = null, - isAwaitingPremiumStatus = true, - isPremiumUpgradePending = false, - ), - dialogState = PlanState.DialogState.Loading( - message = BitwardenString.confirming_your_upgrade.asText(), - ), - ), - pricingResult = null, - ) + val viewModel = createViewModel(subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE) + + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + assertEquals(DEFAULT_FREE_STATE, stateFlow.awaitItem()) - viewModel.eventFlow.test { mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( accounts = listOf( DEFAULT_ACCOUNT.copy(isPremium = true), @@ -635,25 +593,73 @@ class PlanViewModelTest : BaseViewModelTest() { ) assertEquals( - PlanEvent.NavigateToUpgradedToPremium, - awaitItem(), + DEFAULT_FREE_STATE.copy( + viewState = PlanState.ViewState.Loading( + message = BitwardenString.loading_subscription.asText(), + ), + showsPremiumView = true, + ), + stateFlow.awaitItem(), ) + assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem()) + + eventFlow.expectNoEvents() } } + @Suppress("MaxLineLength") @Test - fun `premium status flip should not emit event when not in WaitingForPayment`() = + fun `SubscriptionStatusUpdateReceive with trouble status while awaiting checkout should promote without celebration event`() = runTest { - val viewModel = createViewModel() - - viewModel.eventFlow.test { - // Flip premium while in Content state. - mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( - accounts = listOf( - DEFAULT_ACCOUNT.copy(isPremium = true), + val initialState = DEFAULT_FREE_STATE.copy( + viewState = PlanState.ViewState.Content.Free.Cloud( + rate = "$1.67", + checkoutUrl = null, + isAwaitingPremiumStatus = true, + isPremiumUpgradePending = false, + ), + dialogState = PlanState.DialogState.WaitingForPayment, + ) + val viewModel = createViewModel( + initialState = initialState, + pricingResult = null, + subscriptionResult = SubscriptionResult.Success( + subscription = SUBSCRIPTION_INFO_ACTIVE.copy( + status = PremiumSubscriptionStatus.CANCELED, + canceledDate = Instant.parse("2026-04-21T00:00:00Z"), ), + ), + ) + + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + assertEquals(initialState, stateFlow.awaitItem()) + + mutableSubscriptionStatusStateFlow.value = SubscriptionStatusState.Available( + status = PremiumSubscriptionStatus.CANCELED, ) - expectNoEvents() + + assertEquals( + initialState.copy( + viewState = PlanState.ViewState.Loading( + message = BitwardenString.loading_subscription.asText(), + ), + ), + stateFlow.awaitItem(), + ) + + assertEquals( + initialState.copy( + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( + status = PremiumSubscriptionStatus.CANCELED, + canceledDateText = "April 21, 2026", + showCancelButton = false, + ), + dialogState = null, + ), + stateFlow.awaitItem(), + ) + + eventFlow.expectNoEvents() } } @@ -976,6 +982,43 @@ class PlanViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `SubscriptionStatusUpdateReceive with ACTIVE status promotes premium account off Free view`() = + runTest { + markUserPremium() + val viewModel = createViewModel(subscriptionResult = SubscriptionResult.NotFound) + + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + // The NotFound fallback leaves the account premium while the view is still + // showing Free.Cloud content. + assertEquals( + DEFAULT_FREE_STATE.copy(showsPremiumView = true), + stateFlow.awaitItem(), + ) + + coEvery { + mockBillingRepository.getSubscription() + } returns SUBSCRIPTION_SUCCESS_ACTIVE + mutableSubscriptionStatusStateFlow.value = SubscriptionStatusState.Available( + status = PremiumSubscriptionStatus.ACTIVE, + ) + + assertEquals( + DEFAULT_FREE_STATE.copy( + viewState = PlanState.ViewState.Loading( + message = BitwardenString.loading_subscription.asText(), + ), + showsPremiumView = true, + ), + stateFlow.awaitItem(), + ) + assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem()) + + eventFlow.expectNoEvents() + } + } + @Test fun `SubscriptionResultReceive NotFound falls back to Free view and fetches pricing`() = runTest {