mirror of
https://github.com/bitwarden/android.git
synced 2026-08-29 10:17:56 -05:00
[PM-33516] feat: Create PlanScreen, PlanViewModel, and modal navigation (#6715)
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
+90
@@ -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)
|
||||
}
|
||||
+304
@@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+376
@@ -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()
|
||||
}
|
||||
}
|
||||
+33
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+6
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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`() =
|
||||
|
||||
+1
@@ -57,6 +57,7 @@ class EnterpriseSignOnScreenTest : BitwardenComposeTest() {
|
||||
sso = ssoLauncher,
|
||||
webAuthn = mockk(),
|
||||
cookie = mockk(),
|
||||
premiumCheckout = mockk(),
|
||||
),
|
||||
intentManager = intentManager,
|
||||
) {
|
||||
|
||||
+1
@@ -62,6 +62,7 @@ class TwoFactorLoginScreenTest : BitwardenComposeTest() {
|
||||
sso = mockk(),
|
||||
webAuthn = webAuthnLauncher,
|
||||
cookie = mockk(),
|
||||
premiumCheckout = mockk(),
|
||||
),
|
||||
intentManager = intentManager,
|
||||
nfcManager = nfcManager,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+326
@@ -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,
|
||||
)
|
||||
+528
@@ -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,
|
||||
)
|
||||
+1
@@ -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 {
|
||||
|
||||
@@ -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">You’ll go to Stripe’s secure checkout to complete your purchase.</string>
|
||||
<string name="opening_checkout">Opening checkout…</string>
|
||||
<string name="secure_checkout_didnt_load">Secure checkout didn’t 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>
|
||||
|
||||
Reference in New Issue
Block a user