mirror of
https://github.com/bitwarden/android.git
synced 2026-10-22 02:53:19 -05:00
[PM-41836] fix: Promote Plan screen off Free view without requiring an in-flight checkout (#7337)
This commit is contained in:
1 parent
9b9f41ffe4
commit
7e14fcf2f7
2 files changed
+127
-84
No files matched your search
+26
-26
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+101
-58
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user