[PM-33516] feat: Create PlanScreen, PlanViewModel, and modal navigation (#6715)

This commit is contained in:
Patrick Honkonen
2026-03-25 18:38:38 +00:00
committed by GitHub
parent d58c82ced4
commit 69a13c91b5
25 changed files with 1786 additions and 3 deletions
@@ -88,6 +88,10 @@ class MainActivity : AppCompatActivity() {
mainViewModel.trySendAction(MainAction.CookieAcquisitionResult(it))
}
private val premiumCheckoutLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
mainViewModel.trySendAction(MainAction.PremiumCheckoutResult(it))
}
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
var shouldShowSplashScreen = true
@@ -115,6 +119,7 @@ class MainActivity : AppCompatActivity() {
sso = ssoLauncher,
webAuthn = webAuthnLauncher,
cookie = cookieLauncher,
premiumCheckout = premiumCheckoutLauncher,
),
) {
ObserveScreenDataEffect(
@@ -198,6 +198,7 @@ class MainViewModel @Inject constructor(
is MainAction.SsoResult -> handleSsoResult(action)
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult()
is MainAction.Internal -> handleInternalAction(action)
}
}
@@ -247,6 +248,11 @@ class MainViewModel @Inject constructor(
)
}
private fun handlePremiumCheckoutResult() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PremiumCheckoutResult
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
when (val data = action.screenResumeData) {
null -> appResumeManager.clearResumeScreen()
@@ -555,6 +561,13 @@ sealed class MainAction {
val cookieCallbackResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive the result from the premium checkout flow.
*/
data class PremiumCheckoutResult(
val authResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive first Intent by the application.
*/
@@ -0,0 +1,90 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import com.bitwarden.ui.platform.util.ParcelableRouteSerializer
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
/**
* The type-safe route for the plan screen.
*/
@OmitFromCoverage
@Parcelize
@Serializable(with = PlanRoute.Serializer::class)
sealed class PlanRoute : Parcelable {
/**
* Custom serializer to support polymorphic routes.
*/
class Serializer : ParcelableRouteSerializer<PlanRoute>(PlanRoute::class)
/**
* Standard destination — inside settingsGraph, bottom nav visible, back arrow.
*/
@Parcelize
@Serializable(with = Standard.Serializer::class)
data object Standard : PlanRoute() {
/**
* Custom serializer to support polymorphic routes.
*/
class Serializer : ParcelableRouteSerializer<Standard>(Standard::class)
}
/**
* Modal destination — parent vaultUnlockedGraph level, bottom nav hidden, close icon.
*/
@Parcelize
@Serializable(with = Modal.Serializer::class)
data object Modal : PlanRoute() {
/**
* Custom serializer to support polymorphic routes.
*/
class Serializer : ParcelableRouteSerializer<Modal>(Modal::class)
}
}
/**
* Class to retrieve plan arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class PlanArgs(val planMode: PlanMode)
/**
* Constructs [PlanArgs] from the [SavedStateHandle] and internal route data.
*/
fun SavedStateHandle.toPlanArgs(): PlanArgs {
val route = this.toRoute<PlanRoute>()
return PlanArgs(
planMode = when (route) {
is PlanRoute.Standard -> PlanMode.Standard
is PlanRoute.Modal -> PlanMode.Modal
},
)
}
/**
* Register at parent vaultUnlockedGraph level — bottom nav hidden.
*/
fun NavGraphBuilder.planModalDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions<PlanRoute.Modal> {
PlanScreen(onNavigateBack = onNavigateBack)
}
}
/**
* Navigate to the plan screen (modal, at parent level).
*/
fun NavController.navigateToPlanModal(navOptions: NavOptions? = null) {
navigate(route = PlanRoute.Modal, navOptions = navOptions)
}
@@ -0,0 +1,304 @@
package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.cardStyle
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.content.BitwardenContentBlock
import com.bitwarden.ui.platform.components.content.model.ContentBlockData
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers.PlanHandlers
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
/**
* The screen for the plan -- shows upgrade flow for free users.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlanScreen(
onNavigateBack: () -> Unit,
viewModel: PlanViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
authTabLaunchers: AuthTabLaunchers = LocalAuthTabLaunchers.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handlers = remember(viewModel) { PlanHandlers.create(viewModel) }
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is PlanEvent.LaunchBrowser -> {
intentManager.startAuthTab(
uri = event.url.toUri(),
authTabData = event.authTabData,
launcher = authTabLaunchers.premiumCheckout,
)
}
PlanEvent.NavigateBack -> onNavigateBack()
}
}
FreeDialogs(
dialogState = state.dialogState,
handlers = handlers,
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = BitwardenString.upgrade_to_premium),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = state.navigationIcon),
navigationIconContentDescription = stringResource(
id = state.navigationIconContentDescription,
),
onNavigationIconClick = handlers.onBackClick,
)
},
) {
when (val viewState = state.viewState) {
is PlanState.ViewState.Free -> {
FreeContent(
viewState = viewState,
isDialogShowing = state.dialogState != null,
handlers = handlers,
)
}
}
}
}
@Composable
private fun FreeDialogs(
dialogState: PlanState.DialogState?,
handlers: PlanHandlers,
) {
when (dialogState) {
is PlanState.DialogState.CheckoutError -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.secure_checkout_didnt_load),
message = stringResource(id = BitwardenString.trouble_opening_payment_page),
confirmButtonText = stringResource(id = BitwardenString.try_again),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = handlers.onRetryClick,
onDismissClick = handlers.onDismissError,
onDismissRequest = handlers.onDismissError,
)
}
is PlanState.DialogState.WaitingForPayment -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.payment_not_received_yet),
message = stringResource(id = BitwardenString.return_to_stripe_to_finish),
confirmButtonText = stringResource(id = BitwardenString.go_back),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = handlers.onGoBackClick,
onDismissClick = handlers.onCancelWaiting,
onDismissRequest = handlers.onCancelWaiting,
)
}
is PlanState.DialogState.Loading -> {
BitwardenLoadingDialog(text = dialogState.message())
}
null -> Unit
}
}
@Composable
private fun FreeContent(
viewState: PlanState.ViewState.Free,
isDialogShowing: Boolean,
handlers: PlanHandlers,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(12.dp))
PremiumDetailsCard(
rate = viewState.rate,
frequency = stringResource(id = BitwardenString.per_month),
modifier = Modifier.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.upgrade_now),
onClick = handlers.onUpgradeNowClick,
isEnabled = !isDialogShowing,
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.testTag("UpgradeNowButton"),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(id = BitwardenString.stripe_checkout_footer),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.testTag("StripeFooterText"),
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun PremiumDetailsCard(
rate: String,
frequency: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.cardStyle(cardStyle = CardStyle.Full),
) {
Column(
modifier = Modifier
.padding(
top = 16.dp,
bottom = 12.dp,
)
.standardHorizontalMargin(),
) {
PriceRow(
rate = rate,
frequency = frequency,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(
id = BitwardenString.unlock_premium_features,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
)
}
BitwardenHorizontalDivider(
modifier = Modifier.padding(start = 16.dp),
)
val features = listOf(
BitwardenString.built_in_authenticator,
BitwardenString.emergency_access,
BitwardenString.secure_file_storage,
BitwardenString.breach_monitoring,
)
features.forEachIndexed { index, featureStringRes ->
BitwardenContentBlock(
data = ContentBlockData(
headerText = stringResource(id = featureStringRes),
iconVectorResource = BitwardenDrawable.ic_check_mark,
),
headerTextStyle = BitwardenTheme.typography.titleMedium,
showDivider = index != features.lastIndex,
modifier = Modifier.padding(vertical = 6.dp),
)
}
}
}
@Composable
private fun PriceRow(
rate: String,
frequency: String,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier) {
Text(
text = rate,
style = BitwardenTheme.typography.headlineMedium,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier.alignByBaseline(),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = frequency,
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.alignByBaseline(),
)
}
}
@Preview
@OmitFromCoverage
@Composable
private fun PlanScreenFreeAccount_preview() {
BitwardenTheme {
BitwardenScaffold {
FreeContent(
viewState = PlanState.ViewState.Free(rate = "$1.65"),
isDialogShowing = false,
handlers = PlanHandlers(
onBackClick = {},
onUpgradeNowClick = {},
onDismissError = {},
onRetryClick = {},
onCancelWaiting = {},
onGoBackClick = {},
),
)
}
}
}
@@ -0,0 +1,376 @@
package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* The callback URL for the premium checkout custom tab.
*/
const val PREMIUM_CHECKOUT_CALLBACK_URL = "bitwarden://premium-upgrade-callback"
/**
* Placeholder rate until dynamic pricing is available.
* TODO: [PM-33946] Replace with dynamic pricing from GET /api/plans/premium.
*/
private const val PLACEHOLDER_RATE = "$1.65"
/**
* View model for the plan screen, handling the free-user upgrade flow.
*/
@HiltViewModel
class PlanViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val billingRepository: BillingRepository,
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
authRepository: AuthRepository,
specialCircumstanceManager: SpecialCircumstanceManager,
) : BaseViewModel<PlanState, PlanEvent, PlanAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val planMode = savedStateHandle.toPlanArgs().planMode
val dialogState = when (specialCircumstanceManager.specialCircumstance) {
is SpecialCircumstance.PremiumCheckoutResult -> {
specialCircumstanceManager.specialCircumstance = null
PlanState.DialogState.WaitingForPayment
}
else -> null
}
PlanState(
planMode = planMode,
viewState = PlanState.ViewState.Free(rate = PLACEHOLDER_RATE),
dialogState = dialogState,
)
},
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
authRepository
.userStateFlow
.map { PlanAction.Internal.UserStateUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: PlanAction) {
when (action) {
is PlanAction.BackClick -> handleBackClick()
is PlanAction.UpgradeNowClick -> {
handleUpgradeNowClick()
}
is PlanAction.DismissError -> handleDismissError()
is PlanAction.RetryClick -> handleRetryClick()
is PlanAction.CancelWaiting -> handleCancelWaiting()
is PlanAction.GoBackClick -> handleGoBackClick()
is PlanAction.Internal.CheckoutUrlReceive -> {
handleCheckoutUrlReceive(action)
}
is PlanAction.Internal.UserStateUpdateReceive -> {
handleUserStateUpdateReceive(action)
}
}
}
private fun handleBackClick() {
sendEvent(PlanEvent.NavigateBack)
}
private fun handleUpgradeNowClick() {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.opening_checkout.asText(),
),
)
}
viewModelScope.launch {
val result = billingRepository.getCheckoutSessionUrl()
sendAction(
PlanAction.Internal.CheckoutUrlReceive(result),
)
}
}
private fun handleRetryClick() {
handleUpgradeNowClick()
}
private fun handleDismissError() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleCancelWaiting() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleGoBackClick() {
onFreeContent { freeState ->
freeState.checkoutUrl?.let { url ->
sendEvent(
PlanEvent.LaunchBrowser(
url = url,
authTabData = AuthTabData.CustomScheme(
callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL,
),
),
)
}
}
}
private fun handleCheckoutUrlReceive(
action: PlanAction.Internal.CheckoutUrlReceive,
) {
when (val result = action.result) {
is CheckoutSessionResult.Success -> {
sendEvent(
PlanEvent.LaunchBrowser(
url = result.url,
authTabData = AuthTabData.CustomScheme(
callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL,
),
),
)
onFreeContent { freeState ->
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
checkoutUrl = result.url,
),
dialogState = PlanState.DialogState.WaitingForPayment,
)
}
}
}
is CheckoutSessionResult.Error -> {
mutableStateFlow.update {
it.copy(dialogState = PlanState.DialogState.CheckoutError)
}
}
}
}
private fun handleUserStateUpdateReceive(
action: PlanAction.Internal.UserStateUpdateReceive,
) {
if (state.dialogState !is PlanState.DialogState.WaitingForPayment) return
val isPremium = action.userState?.activeAccount?.isPremium == true
if (isPremium) {
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(
message = BitwardenString.upgraded_to_premium.asText(),
),
relay = SnackbarRelay.PREMIUM_UPGRADED,
)
sendEvent(PlanEvent.NavigateBack)
}
}
private inline fun onFreeContent(
block: (PlanState.ViewState.Free) -> Unit,
) {
(state.viewState as? PlanState.ViewState.Free)
?.let(block)
}
}
/**
* Determines how the Plan screen was reached.
*/
enum class PlanMode {
/** Back arrow, bottom nav visible (push sub-screen from Settings). */
Standard,
/** Close icon, bottom nav hidden (modal overlay from Vault). */
Modal,
}
/**
* Models state for the plan screen.
*/
@Parcelize
data class PlanState(
val planMode: PlanMode,
val viewState: ViewState,
val dialogState: DialogState?,
) : Parcelable {
/**
* The navigation icon drawable resource for the top app bar.
*/
@get:DrawableRes
val navigationIcon: Int
get() = when (planMode) {
PlanMode.Standard -> BitwardenDrawable.ic_back
PlanMode.Modal -> BitwardenDrawable.ic_close
}
/**
* The navigation icon content description string resource.
*/
@get:StringRes
val navigationIconContentDescription: Int
get() = when (planMode) {
PlanMode.Standard -> BitwardenString.back
PlanMode.Modal -> BitwardenString.close
}
/**
* Models the content state of the plan screen.
*/
sealed class ViewState : Parcelable {
/**
* The monthly billing rate for the plan.
*/
abstract val rate: String
/**
* Free user view — shows upgrade pricing and feature list.
*/
@Parcelize
data class Free(
override val rate: String,
val checkoutUrl: String? = null,
) : ViewState()
}
/**
* Represents the dialog/overlay state for the plan screen.
*/
sealed class DialogState : Parcelable {
/**
* Loading overlay with a configurable message.
*/
@Parcelize
data class Loading(
val message: Text,
) : DialogState()
/**
* Error dialog shown when the checkout session could not
* be loaded.
*/
@Parcelize
data object CheckoutError : DialogState()
/**
* Waiting dialog shown after the browser has been launched
* for checkout.
*/
@Parcelize
data object WaitingForPayment : DialogState()
}
}
/**
* Models events for the plan screen.
*/
sealed class PlanEvent {
/**
* Launch the user's browser with the given checkout [url]
* via AuthTab.
*/
data class LaunchBrowser(
val url: String,
val authTabData: AuthTabData,
) : PlanEvent()
/**
* Navigate back to the previous screen.
*/
data object NavigateBack : PlanEvent()
}
/**
* Models actions for the plan screen.
*/
sealed class PlanAction {
/**
* The user clicked the back/close button.
*/
data object BackClick : PlanAction()
/**
* The user clicked the upgrade now button.
*/
data object UpgradeNowClick : PlanAction()
/**
* The user dismissed the checkout error dialog.
*/
data object DismissError : PlanAction()
/**
* The user clicked retry on the checkout error dialog.
*/
data object RetryClick : PlanAction()
/**
* The user dismissed the waiting for payment dialog.
*/
data object CancelWaiting : PlanAction()
/**
* The user clicked go back on the waiting for payment dialog.
*/
data object GoBackClick : PlanAction()
/**
* Models actions the view model sends itself.
*/
sealed class Internal : PlanAction() {
/**
* A checkout URL result has been received from the
* repository.
*/
data class CheckoutUrlReceive(
val result: CheckoutSessionResult,
) : Internal()
/**
* A user state update has been received.
*/
data class UserStateUpdateReceive(
val userState: UserState?,
) : Internal()
}
}
@@ -0,0 +1,33 @@
package com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.PlanAction
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.PlanViewModel
/**
* A collection of handler functions for managing actions within the context of
* the plan screen.
*/
data class PlanHandlers(
val onBackClick: () -> Unit,
val onUpgradeNowClick: () -> Unit,
val onDismissError: () -> Unit,
val onRetryClick: () -> Unit,
val onCancelWaiting: () -> Unit,
val onGoBackClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates the [PlanHandlers] using the [PlanViewModel] to send desired
* actions.
*/
fun create(viewModel: PlanViewModel): PlanHandlers = PlanHandlers(
onBackClick = { viewModel.trySendAction(PlanAction.BackClick) },
onUpgradeNowClick = { viewModel.trySendAction(PlanAction.UpgradeNowClick) },
onDismissError = { viewModel.trySendAction(PlanAction.DismissError) },
onRetryClick = { viewModel.trySendAction(PlanAction.RetryClick) },
onCancelWaiting = { viewModel.trySendAction(PlanAction.CancelWaiting) },
onGoBackClick = { viewModel.trySendAction(PlanAction.GoBackClick) },
)
}
}
@@ -13,6 +13,8 @@ import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupUnlockScr
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupAutoFillDestination
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupBrowserAutofillDestination
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupUnlockDestination
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.navigateToPlanModal
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.planModalDestination
import com.x8bit.bitwarden.ui.platform.feature.search.SearchRoute
import com.x8bit.bitwarden.ui.platform.feature.search.navigateToSearch
import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination
@@ -139,6 +141,7 @@ fun NavGraphBuilder.vaultUnlockedGraph(
onNavigateToAboutPrivilegedApps = {
navController.navigateToAboutPrivilegedAppsScreen()
},
onNavigateToPlan = { navController.navigateToPlanModal() },
)
flightRecorderDestination(
isPreAuth = false,
@@ -283,6 +286,9 @@ fun NavGraphBuilder.vaultUnlockedGraph(
previewAttachmentDestination(
onNavigateBack = { navController.popBackStack() },
)
planModalDestination(
onNavigateBack = { navController.popBackStack() },
)
}
}
@@ -52,6 +52,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToImportLogins: () -> Unit,
onNavigateToAddFolderScreen: (selectedFolderName: String?) -> Unit,
onNavigateToAboutPrivilegedApps: () -> Unit,
onNavigateToPlan: () -> Unit,
) {
composableWithStayTransitions<VaultUnlockedNavbarRoute> {
VaultUnlockedNavBarScreen(
@@ -75,6 +76,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps,
onNavigateToPlan = onNavigateToPlan,
)
}
}
@@ -69,6 +69,7 @@ fun VaultUnlockedNavBarScreen(
onNavigateToImportLogins: () -> Unit,
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
onNavigateToAboutPrivilegedApps: () -> Unit,
onNavigateToPlan: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@@ -109,6 +110,7 @@ fun VaultUnlockedNavBarScreen(
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps,
onNavigateToPlan = onNavigateToPlan,
)
}
@@ -144,6 +146,7 @@ private fun VaultUnlockedNavBarScaffold(
onNavigateToImportLogins: () -> Unit,
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
onNavigateToAboutPrivilegedApps: () -> Unit,
onNavigateToPlan: () -> Unit,
) {
var shouldDimNavBar by rememberSaveable { mutableStateOf(value = false) }
@@ -202,6 +205,7 @@ private fun VaultUnlockedNavBarScaffold(
navController.navigateToSettingsGraphRoot()
navController.navigateToAutoFill()
},
onNavigateToPlan = onNavigateToPlan,
)
sendGraph(
navController = navController,
@@ -3,14 +3,17 @@ package com.x8bit.bitwarden.ui.platform.model
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.runtime.Immutable
import com.bitwarden.annotation.OmitFromCoverage
/**
* Contains all the callbacks for the Auth Tabs.
*/
@OmitFromCoverage
@Immutable
class AuthTabLaunchers(
val duo: ActivityResultLauncher<Intent>,
val sso: ActivityResultLauncher<Intent>,
val webAuthn: ActivityResultLauncher<Intent>,
val cookie: ActivityResultLauncher<Intent>,
val premiumCheckout: ActivityResultLauncher<Intent>,
)
@@ -20,7 +20,7 @@ enum class SnackbarRelay {
/**
* A separate instance of the [CIPHER_UNARCHIVED] relay to avoid the View Cipher screen being
* both a producer and consumer of it's own event.
* both a producer and consumer of its own event.
*/
CIPHER_UNARCHIVED_VIEW,
CIPHER_CREATED,
@@ -36,6 +36,7 @@ enum class SnackbarRelay {
LOGIN_APPROVAL,
LOGIN_SUCCESS,
LOGINS_IMPORTED,
PREMIUM_UPGRADED,
SEND_DELETED,
SEND_UPDATED,
LEFT_ORGANIZATION,
@@ -34,6 +34,7 @@ fun NavGraphBuilder.vaultGraph(
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
onNavigateToAboutScreen: () -> Unit,
onNavigateToAutofillScreen: () -> Unit,
onNavigateToPlan: () -> Unit,
) {
navigation<VaultGraphRoute>(
startDestination = VaultRoute,
@@ -52,6 +53,7 @@ fun NavGraphBuilder.vaultGraph(
onNavigateToAddFolderScreen = onNavigateToAddFolderScreen,
onNavigateToAboutScreen = onNavigateToAboutScreen,
onNavigateToAutofillScreen = onNavigateToAutofillScreen,
onNavigateToPlan = onNavigateToPlan,
)
vaultItemListingDestination(
onNavigateBack = { navController.popBackStack() },
@@ -1,8 +1,11 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithRootPushTransitions
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs
@@ -13,6 +16,7 @@ import kotlinx.serialization.Serializable
/**
* The type-safe route for the vault screen.
*/
@OmitFromCoverage
@Serializable
data object VaultRoute
@@ -32,6 +36,7 @@ fun NavGraphBuilder.vaultDestination(
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
onNavigateToAboutScreen: () -> Unit,
onNavigateToAutofillScreen: () -> Unit,
onNavigateToPlan: () -> Unit,
) {
composableWithRootPushTransitions<VaultRoute> {
VaultScreen(
@@ -46,6 +51,7 @@ fun NavGraphBuilder.vaultDestination(
onNavigateToAddFolderScreen = onNavigateToAddFolderScreen,
onNavigateToAboutScreen = onNavigateToAboutScreen,
onNavigateToAutofillScreen = onNavigateToAutofillScreen,
onNavigateToPlan = onNavigateToPlan,
)
}
}
@@ -97,6 +97,7 @@ fun VaultScreen(
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
onNavigateToAboutScreen: () -> Unit,
onNavigateToAutofillScreen: () -> Unit,
onNavigateToPlan: () -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
appReviewManager: AppReviewManager = LocalAppReviewManager.current,
) {
@@ -177,6 +178,8 @@ fun VaultScreen(
}
VaultEvent.NavigateToAutofillSettings -> onNavigateToAutofillScreen()
VaultEvent.NavigateToUpgradePremium -> onNavigateToPlan()
}
}
val vaultHandlers = remember(viewModel) { VaultHandlers.create(viewModel) }
@@ -223,6 +223,7 @@ class VaultViewModel @Inject constructor(
SnackbarRelay.CIPHER_UPDATED,
SnackbarRelay.FOLDER_CREATED,
SnackbarRelay.LOGINS_IMPORTED,
SnackbarRelay.PREMIUM_UPGRADED,
SnackbarRelay.LEFT_ORGANIZATION,
SnackbarRelay.VAULT_MIGRATED_TO_MY_ITEMS,
),
@@ -410,7 +411,7 @@ class VaultViewModel @Inject constructor(
private fun handleActionCardClick(action: VaultAction.ActionCardClick) {
when (action.actionCard) {
VaultState.ActionCardState.UpgradePremium -> {
// Navigation to Plan screen wired in PM-33515/PM-33516.
sendEvent(VaultEvent.NavigateToUpgradePremium)
}
VaultState.ActionCardState.IntroducingArchive -> {
@@ -2037,6 +2038,11 @@ sealed class VaultEvent {
* Navigate to Autofill settings screen.
*/
data object NavigateToAutofillSettings : VaultEvent()
/**
* Navigate to the premium upgrade plan screen.
*/
data object NavigateToUpgradePremium : VaultEvent()
}
/**
@@ -1189,6 +1189,21 @@ class MainViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on PremiumCheckoutResult should set PremiumCheckoutResult special circumstance`() {
val authResult = mockk<AuthTabIntent.AuthResult>()
val viewModel = createViewModel()
viewModel.trySendAction(
MainAction.PremiumCheckoutResult(authResult = authResult),
)
assertEquals(
SpecialCircumstance.PremiumCheckoutResult,
specialCircumstanceManager.specialCircumstance,
)
}
@Suppress("MaxLineLength")
@Test
fun `cookie acquisition should emit NavigateToCookieAcquisition when vault unlocked with matching hostname`() =
@@ -57,6 +57,7 @@ class EnterpriseSignOnScreenTest : BitwardenComposeTest() {
sso = ssoLauncher,
webAuthn = mockk(),
cookie = mockk(),
premiumCheckout = mockk(),
),
intentManager = intentManager,
) {
@@ -62,6 +62,7 @@ class TwoFactorLoginScreenTest : BitwardenComposeTest() {
sso = mockk(),
webAuthn = webAuthnLauncher,
cookie = mockk(),
premiumCheckout = mockk(),
),
intentManager = intentManager,
nfcManager = nfcManager,
@@ -52,6 +52,7 @@ class CookieAcquisitionScreenTest : BitwardenComposeTest() {
sso = mockk(),
webAuthn = mockk(),
cookie = cookieLauncher,
premiumCheckout = mockk(),
),
intentManager = intentManager,
) {
@@ -111,7 +112,6 @@ class CookieAcquisitionScreenTest : BitwardenComposeTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `continue without syncing button click should send ContinueWithoutSyncingClick action`() {
composeTestRule
@@ -0,0 +1,326 @@
package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.core.net.toUri
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class PlanScreenTest : BitwardenComposeTest() {
private var onNavigateBackCalled = false
private val premiumCheckoutLauncher: ActivityResultLauncher<Intent> = mockk()
private val mutableEventFlow = bufferedMutableSharedFlow<PlanEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_FREE_STATE)
private val viewModel = mockk<PlanViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
private val intentManager = mockk<IntentManager> {
every {
startAuthTab(uri = any(), authTabData = any(), launcher = any())
} just runs
}
@Before
fun setUp() {
setContent(
authTabLaunchers = AuthTabLaunchers(
duo = mockk(),
sso = mockk(),
webAuthn = mockk(),
cookie = mockk(),
premiumCheckout = premiumCheckoutLauncher,
),
intentManager = intentManager,
) {
PlanScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
)
}
}
// region Events
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(PlanEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `LaunchBrowser event should call startAuthTab`() {
val url = "https://checkout.stripe.com/session123"
val authTabData = AuthTabData.CustomScheme(
callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL,
)
mutableEventFlow.tryEmit(
PlanEvent.LaunchBrowser(
url = url,
authTabData = authTabData,
),
)
verify {
intentManager.startAuthTab(
uri = url.toUri(),
authTabData = authTabData,
launcher = premiumCheckoutLauncher,
)
}
}
// endregion Events
// region Free content tests
@Test
fun `close button click should send BackClick action for Modal mode`() {
composeTestRule
.onNodeWithContentDescription("Close")
.performClick()
verify { viewModel.trySendAction(PlanAction.BackClick) }
}
@Test
fun `back button click should send BackClick action for Standard mode`() {
mutableStateFlow.update {
it.copy(planMode = PlanMode.Standard)
}
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify { viewModel.trySendAction(PlanAction.BackClick) }
}
@Test
fun `upgrade now button click should send UpgradeNowClick action`() {
composeTestRule
.onNodeWithTag("UpgradeNowButton")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(PlanAction.UpgradeNowClick)
}
}
@Test
fun `price and subtitle should render from state`() {
composeTestRule
.onNodeWithText("\$1.65")
.assertExists()
composeTestRule
.onNodeWithText("/ month")
.assertExists()
composeTestRule
.onNodeWithText(
"Unlock more advanced features with a Premium plan.",
)
.assertExists()
}
@Test
fun `feature list items should render`() {
composeTestRule
.onNodeWithText("Built-in authenticator")
.assertExists()
composeTestRule
.onNodeWithText("Emergency access")
.assertExists()
composeTestRule
.onNodeWithText("Secure file storage")
.assertExists()
composeTestRule
.onNodeWithText("Breach monitoring")
.assertExists()
}
@Test
fun `stripe footer should render`() {
composeTestRule
.onNodeWithTag("StripeFooterText")
.assertExists()
}
@Test
fun `upgrade now button should be enabled when dialogState is null`() {
composeTestRule
.onNodeWithTag("UpgradeNowButton")
.assertIsEnabled()
}
@Test
fun `upgrade now button should be disabled when dialogState is Loading`() {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.opening_checkout.asText(),
),
)
}
composeTestRule
.onNodeWithTag("UpgradeNowButton")
.assertIsNotEnabled()
}
@Test
fun `loading dialog should render when dialogState is Loading`() {
composeTestRule
.onAllNodesWithText("Opening checkout\u2026")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.opening_checkout.asText(),
),
)
}
composeTestRule
.onAllNodesWithText("Opening checkout\u2026")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `error dialog should render when dialogState is Error`() {
composeTestRule
.onAllNodesWithText("Secure checkout didn\u2019t load")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.CheckoutError,
)
}
composeTestRule
.onAllNodesWithText("Secure checkout didn\u2019t load")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `error dialog try again click should send RetryClick action`() {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.CheckoutError,
)
}
composeTestRule
.onAllNodesWithText("Try again")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(PlanAction.RetryClick) }
}
@Test
fun `error dialog close click should send DismissError action`() {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.CheckoutError,
)
}
composeTestRule
.onAllNodesWithText("Close")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(PlanAction.DismissError)
}
}
@Test
fun `waiting for payment dialog should render when dialogState is WaitingForPayment`() {
composeTestRule
.onAllNodesWithText("Payment not received yet")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState =
PlanState.DialogState.WaitingForPayment,
)
}
composeTestRule
.onAllNodesWithText("Payment not received yet")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `waiting for payment dialog go back click should send GoBackClick action`() {
mutableStateFlow.update {
it.copy(
dialogState =
PlanState.DialogState.WaitingForPayment,
)
}
composeTestRule
.onAllNodesWithText("Go back")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(PlanAction.GoBackClick)
}
}
@Test
fun `waiting for payment dialog close click should send CancelWaiting action`() {
mutableStateFlow.update {
it.copy(
dialogState =
PlanState.DialogState.WaitingForPayment,
)
}
composeTestRule
.onAllNodesWithText("Close")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(PlanAction.CancelWaiting)
}
}
// endregion Free content tests
}
private val DEFAULT_FREE_STATE = PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free(
rate = "$1.65",
),
dialogState = null,
)
@@ -0,0 +1,528 @@
package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class PlanViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val mockAuthRepository: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow
}
private val mockBillingRepository: BillingRepository = mockk()
private val mockSnackbarRelayManager: SnackbarRelayManager<SnackbarRelay> =
mockk {
every { sendSnackbarData(any(), any()) } just runs
}
private val mockSpecialCircumstanceManager: SpecialCircumstanceManager =
mockk(relaxed = true) {
every { specialCircumstance } returns null
}
@BeforeEach
fun setup() {
mockkStatic(SavedStateHandle::toPlanArgs)
}
@AfterEach
fun tearDown() {
unmockkStatic(SavedStateHandle::toPlanArgs)
}
// region PlanMode propagation
@Test
fun `initial state should use Standard PlanMode for Standard route`() =
runTest {
val viewModel = createViewModel(planMode = PlanMode.Standard)
viewModel.stateFlow.test {
assertEquals(PlanMode.Standard, awaitItem().planMode)
}
}
@Test
fun `initial state should use Modal PlanMode for Modal route`() =
runTest {
val viewModel = createViewModel(planMode = PlanMode.Modal)
viewModel.stateFlow.test {
assertEquals(PlanMode.Modal, awaitItem().planMode)
}
}
// endregion PlanMode propagation
// region Free user path
@Test
fun `initial state should be Free ViewState after pricing fetch`() =
runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_FREE_STATE, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `initial state should be WaitingForPayment when PremiumCheckoutResult special circumstance present`() =
runTest {
every {
mockSpecialCircumstanceManager.specialCircumstance
} returns SpecialCircumstance.PremiumCheckoutResult
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.WaitingForPayment,
),
awaitItem(),
)
}
verify {
mockSpecialCircumstanceManager.specialCircumstance = null
}
}
@Test
fun `BackClick should emit NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(PlanAction.BackClick)
assertEquals(PlanEvent.NavigateBack, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `UpgradeNowClick should transition to Loading then emit LaunchBrowser and WaitingForPayment on success`() =
runTest {
val checkoutUrl = "https://checkout.stripe.com/session123"
coEvery {
mockBillingRepository.getCheckoutSessionUrl()
} returns CheckoutSessionResult.Success(url = checkoutUrl)
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(DEFAULT_FREE_STATE, stateFlow.awaitItem())
viewModel.trySendAction(PlanAction.UpgradeNowClick)
assertEquals(
DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.opening_checkout.asText(),
),
),
stateFlow.awaitItem(),
)
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free(
rate = "$1.65",
checkoutUrl = checkoutUrl,
),
dialogState = PlanState.DialogState.WaitingForPayment,
),
stateFlow.awaitItem(),
)
assertEquals(
PlanEvent.LaunchBrowser(
url = checkoutUrl,
authTabData = AuthTabData.CustomScheme(
callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL,
),
),
eventFlow.awaitItem(),
)
}
}
@Test
fun `UpgradeNowClick should transition to Loading then Error on failure`() =
runTest {
coEvery {
mockBillingRepository.getCheckoutSessionUrl()
} returns CheckoutSessionResult.Error(
error = RuntimeException("Network error"),
)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_FREE_STATE, awaitItem())
viewModel.trySendAction(PlanAction.UpgradeNowClick)
assertEquals(
DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.opening_checkout.asText(),
),
),
awaitItem(),
)
assertEquals(
DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.CheckoutError,
),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `RetryClick should transition to Loading then emit LaunchBrowser and WaitingForPayment on success`() =
runTest {
val checkoutUrl = "https://checkout.stripe.com/session123"
coEvery {
mockBillingRepository.getCheckoutSessionUrl()
} returns CheckoutSessionResult.Success(url = checkoutUrl)
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.CheckoutError,
),
)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(
DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.CheckoutError,
),
stateFlow.awaitItem(),
)
viewModel.trySendAction(PlanAction.RetryClick)
assertEquals(
DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.opening_checkout.asText(),
),
),
stateFlow.awaitItem(),
)
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free(
rate = "$1.65",
checkoutUrl = checkoutUrl,
),
dialogState = PlanState.DialogState.WaitingForPayment,
),
stateFlow.awaitItem(),
)
assertEquals(
PlanEvent.LaunchBrowser(
url = checkoutUrl,
authTabData = AuthTabData.CustomScheme(
callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL,
),
),
eventFlow.awaitItem(),
)
}
}
@Test
fun `DismissError should transition from Error to Content`() = runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.CheckoutError,
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.CheckoutError,
),
awaitItem(),
)
viewModel.trySendAction(PlanAction.DismissError)
assertEquals(DEFAULT_FREE_STATE, awaitItem())
}
}
@Test
fun `CancelWaiting should transition from WaitingForPayment to Content`() =
runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.WaitingForPayment,
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.WaitingForPayment,
),
awaitItem(),
)
viewModel.trySendAction(PlanAction.CancelWaiting)
assertEquals(DEFAULT_FREE_STATE, awaitItem())
}
}
@Test
fun `GoBackClick should emit LaunchBrowser with checkout URL when URL is available`() =
runTest {
val checkoutUrl = "https://checkout.stripe.com/session123"
val freeState = PlanState.ViewState.Free(
rate = "$1.65",
checkoutUrl = checkoutUrl,
)
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
viewState = freeState,
dialogState = PlanState.DialogState.WaitingForPayment,
),
)
viewModel.eventFlow.test {
viewModel.trySendAction(PlanAction.GoBackClick)
assertEquals(
PlanEvent.LaunchBrowser(
url = checkoutUrl,
authTabData = AuthTabData.CustomScheme(
callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL,
),
),
awaitItem(),
)
}
}
@Test
fun `GoBackClick should not emit event when checkout URL is null`() =
runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState
.WaitingForPayment,
),
)
viewModel.eventFlow.test {
viewModel.trySendAction(PlanAction.GoBackClick)
expectNoEvents()
}
}
@Suppress("MaxLineLength")
@Test
fun `premium status flip should send snackbar and emit NavigateBack when in WaitingForPayment`() =
runTest {
val checkoutUrl = "https://checkout.stripe.com/session123"
coEvery {
mockBillingRepository.getCheckoutSessionUrl()
} returns CheckoutSessionResult.Success(url = checkoutUrl)
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(DEFAULT_FREE_STATE, stateFlow.awaitItem())
viewModel.trySendAction(PlanAction.UpgradeNowClick)
// Consume Loading and WaitingForPayment states.
stateFlow.awaitItem()
stateFlow.awaitItem()
assertEquals(
PlanEvent.LaunchBrowser(
url = checkoutUrl,
authTabData = AuthTabData.CustomScheme(
callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL,
),
),
eventFlow.awaitItem(),
)
// Simulate premium status flip.
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(
DEFAULT_ACCOUNT.copy(isPremium = true),
),
)
assertEquals(
PlanEvent.NavigateBack,
eventFlow.awaitItem(),
)
}
verify {
mockSnackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(
message = BitwardenString.upgraded_to_premium.asText(),
),
relay = SnackbarRelay.PREMIUM_UPGRADED,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `premium status flip should send snackbar and emit NavigateBack via special circumstance`() =
runTest {
every {
mockSpecialCircumstanceManager.specialCircumstance
} returns SpecialCircumstance.PremiumCheckoutResult
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(
DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.WaitingForPayment,
),
stateFlow.awaitItem(),
)
// Simulate premium status flip.
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(
DEFAULT_ACCOUNT.copy(isPremium = true),
),
)
assertEquals(
PlanEvent.NavigateBack,
eventFlow.awaitItem(),
)
}
verify {
mockSnackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(
message = BitwardenString.upgraded_to_premium.asText(),
),
relay = SnackbarRelay.PREMIUM_UPGRADED,
)
}
}
@Test
fun `premium status flip should not emit event when not in WaitingForPayment`() =
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),
),
)
expectNoEvents()
}
}
@Test
fun `saved state should be restored`() = runTest {
val savedState = DEFAULT_FREE_STATE.copy(
dialogState = PlanState.DialogState.CheckoutError,
)
val viewModel = createViewModel(initialState = savedState)
viewModel.stateFlow.test {
assertEquals(savedState, awaitItem())
}
}
// endregion Free user path
private fun createViewModel(
initialState: PlanState? = null,
planMode: PlanMode = PlanMode.Modal,
): PlanViewModel {
val savedStateHandle = SavedStateHandle().apply {
set("state", initialState)
every { toPlanArgs() } returns PlanArgs(planMode = planMode)
}
return PlanViewModel(
savedStateHandle = savedStateHandle,
authRepository = mockAuthRepository,
billingRepository = mockBillingRepository,
snackbarRelayManager = mockSnackbarRelayManager,
specialCircumstanceManager = mockSpecialCircumstanceManager,
)
}
}
private val DEFAULT_ACCOUNT = UserState.Account(
userId = "user-id-1",
name = "Test User",
email = "test@bitwarden.com",
avatarColorHex = "#000000",
environment = mockk(),
isPremium = false,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
onboardingStatus = mockk(),
firstTimeState = mockk(),
isExportable = false,
creationDate = null,
)
private val DEFAULT_USER_STATE = UserState(
activeUserId = "user-id-1",
accounts = listOf(DEFAULT_ACCOUNT),
hasPendingAccountAddition = false,
)
private val DEFAULT_FREE_STATE = PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free(
rate = "$1.65",
),
dialogState = null,
)
@@ -64,6 +64,7 @@ class VaultUnlockedNavBarScreenTest : BitwardenComposeTest() {
onNavigateToFlightRecorder = {},
onNavigateToRecordedLogs = {},
onNavigateToAboutPrivilegedApps = {},
onNavigateToPlan = {},
)
}
}
@@ -78,6 +78,7 @@ import org.junit.Test
class VaultScreenTest : BitwardenComposeTest() {
private var onNavigateToAboutCalled = false
private var onNavigateToAutofillCalled = false
private var onNavigateToPlanCalled = false
private var onNavigateToImportLoginsCalled = false
private var onNavigateToVaultAddItemScreenCalled = false
private var onNavigateToVaultItemArgs: VaultItemArgs? = null
@@ -121,6 +122,7 @@ class VaultScreenTest : BitwardenComposeTest() {
},
onNavigateToAboutScreen = { onNavigateToAboutCalled = true },
onNavigateToAutofillScreen = { onNavigateToAutofillCalled = true },
onNavigateToPlan = { onNavigateToPlanCalled = true },
)
}
}
@@ -2295,6 +2297,12 @@ class VaultScreenTest : BitwardenComposeTest() {
assertTrue(onNavigateToAutofillCalled)
}
@Test
fun `when NavigateToUpgradePremium is sent, it should call onNavigateToPlan`() {
mutableEventFlow.tryEmit(VaultEvent.NavigateToUpgradePremium)
assertTrue(onNavigateToPlanCalled)
}
@Test
fun `when ShowSnackbar is sent snackbar should be displayed`() {
val data = BitwardenSnackbarData("message".asText())
@@ -357,6 +357,24 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ActionCardClick with UpgradePremium should emit NavigateToUpgradePremium`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
VaultAction.ActionCardClick(
VaultState.ActionCardState.UpgradePremium,
),
)
assertEquals(
VaultEvent.NavigateToUpgradePremium,
awaitItem(),
)
}
}
@Test
fun `actionCard should return UpgradePremium when eligible and content is showing`() {
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
@@ -3354,6 +3372,22 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `when PREMIUM_UPGRADED relay emits, snackbar is shown`() =
runTest {
val viewModel = createViewModel()
val expectedSnackbarData = BitwardenSnackbarData(
message = BitwardenString.upgraded_to_premium.asText(),
)
viewModel.eventFlow.test {
mutableSnackbarDataFlow.tryEmit(expectedSnackbarData)
assertEquals(
VaultEvent.ShowSnackbar(expectedSnackbarData),
awaitItem(),
)
}
}
@Test
fun `when account switch action is handled, clear snackbar relay buffer should be called`() =
runTest {
+15
View File
@@ -1233,4 +1233,19 @@ Do you want to switch to this account?</string>
<string name="bitwarden_could_not_decrypt_this_file_so_the_preview_cannot_be_displayed">Bitwarden could not decrypt this file, so the preview cannot be displayed.</string>
<string name="preview_unavailable_for_this_file">Preview unavailable for this file. You can still download it to view on your device.</string>
<string name="preview_unavailable">Preview unavailable</string>
<string name="upgraded_to_premium">Upgraded to premium</string>
<string name="unlock_premium_features">Unlock more advanced features with a Premium plan.</string>
<string name="per_month">/ month</string>
<string name="built_in_authenticator">Built-in authenticator</string>
<string name="emergency_access">Emergency access</string>
<string name="secure_file_storage">Secure file storage</string>
<string name="breach_monitoring">Breach monitoring</string>
<string name="upgrade_now">Upgrade now</string>
<string name="stripe_checkout_footer">Youll go to Stripes secure checkout to complete your purchase.</string>
<string name="opening_checkout">Opening checkout…</string>
<string name="secure_checkout_didnt_load">Secure checkout didnt load</string>
<string name="trouble_opening_payment_page">We had trouble opening the payment page, so try again.</string>
<string name="payment_not_received_yet">Payment not received yet</string>
<string name="return_to_stripe_to_finish">Return to Stripe in your browser to finish your upgrade.</string>
<string name="go_back">Go back</string>
</resources>