[PM-36886] fix: Gate premium upgrade flow on self-hosted environments (#6939)

This commit is contained in:
Patrick Honkonen
2026-05-20 11:29:31 -04:00
committed by GitHub
parent 8002794c59
commit fce814d6bd
7 changed files with 344 additions and 56 deletions

View File

@@ -45,11 +45,13 @@ import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.badge.BitwardenStatusBadge
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
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.icon.model.IconData
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
@@ -125,13 +127,17 @@ fun PlanScreen(
},
) {
when (val viewState = state.viewState) {
is PlanState.ViewState.Free -> {
FreeContent(
is PlanState.ViewState.Free.Cloud -> {
FreeCloudContent(
viewState = viewState,
handlers = handlers,
)
}
is PlanState.ViewState.Free.SelfHosted -> {
FreeSelfHostedContent()
}
is PlanState.ViewState.Premium -> {
PremiumContent(
viewState = viewState,
@@ -253,8 +259,8 @@ private fun PlanDialogs(
}
@Composable
private fun FreeContent(
viewState: PlanState.ViewState.Free,
private fun FreeCloudContent(
viewState: PlanState.ViewState.Free.Cloud,
handlers: PlanHandlers,
modifier: Modifier = Modifier,
) {
@@ -300,6 +306,83 @@ private fun FreeContent(
}
}
@Suppress("MaxLineLength")
@Composable
private fun FreeSelfHostedContent(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(12.dp))
BitwardenInfoCalloutCard(
text = stringResource(
id = BitwardenString
.to_manage_your_premium_subscription_youll_need_to_login_to_your_web_vault_on_a_computer,
),
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_info_circle),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.testTag("SelfHostedManageOnWebVaultCallout"),
)
Spacer(modifier = Modifier.height(16.dp))
PremiumFeaturesCard(
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun PremiumFeaturesCard(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.cardStyle(
cardStyle = CardStyle.Full,
// Override bottom padding to account for custom
// `BitwardenContentBlock` vertical padding, below.
paddingBottom = 0.dp,
),
) {
Text(
text = stringResource(id = BitwardenString.unlock_premium_features),
style = BitwardenTheme.typography.labelLarge,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier
.padding(bottom = 16.dp)
.standardHorizontalMargin(),
)
BitwardenHorizontalDivider()
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 = 8.dp),
)
}
}
}
@Composable
private fun PremiumDetailsCard(
rate: String,
@@ -633,11 +716,11 @@ private fun SubscriptionLineItem(
@Preview
@OmitFromCoverage
@Composable
private fun PlanScreenFreeAccount_preview() {
private fun PlanScreenFreeCloudAccount_preview() {
BitwardenTheme {
BitwardenScaffold {
FreeContent(
viewState = PlanState.ViewState.Free(
FreeCloudContent(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -665,6 +748,17 @@ private fun PlanScreenFreeAccount_preview() {
}
}
@Preview
@OmitFromCoverage
@Composable
private fun PlanScreenFreeSelfHostedFreeAccount_preview() {
BitwardenTheme {
BitwardenScaffold {
FreeSelfHostedContent()
}
}
}
@Preview
@OmitFromCoverage
@Composable

View File

@@ -6,6 +6,7 @@ import androidx.annotation.StringRes
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.util.toFormattedDateStyle
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
import com.bitwarden.ui.platform.resource.BitwardenDrawable
@@ -27,6 +28,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -65,6 +67,7 @@ class PlanViewModel @Inject constructor(
private val billingRepository: BillingRepository,
private val authRepository: AuthRepository,
private val premiumStateManager: PremiumStateManager,
private val environmentRepository: EnvironmentRepository,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val vaultRepository: VaultRepository,
private val clock: Clock,
@@ -78,12 +81,13 @@ class PlanViewModel @Inject constructor(
?.isPremium == true
val showsPremiumView = isPremium ||
premiumStateManager.subscriptionStatusStateFlow.value.isPremiumViewEligible()
val isSelfHosted = environmentRepository.environment is Environment.SelfHosted
PlanState(
planMode = planMode,
viewState = if (showsPremiumView) {
PlanState.ViewState.Premium()
} else {
PlanState.ViewState.Free(
viewState = when {
showsPremiumView -> PlanState.ViewState.Premium()
isSelfHosted -> PlanState.ViewState.Free.SelfHosted
else -> PlanState.ViewState.Free.Cloud(
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -120,7 +124,7 @@ class PlanViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
onFreeContent {
onFreeCloudContent {
viewModelScope.launch {
sendAction(
PlanAction.Internal.PricingResultReceive(
@@ -242,7 +246,7 @@ class PlanViewModel @Inject constructor(
}
private fun handleGoBackClick() {
onFreeContent { freeState ->
onFreeCloudContent { freeState ->
freeState.checkoutUrl?.let { url ->
sendEvent(
PlanEvent.LaunchBrowser(
@@ -269,7 +273,7 @@ class PlanViewModel @Inject constructor(
),
),
)
onFreeContent { freeState ->
onFreeCloudContent { freeState ->
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
@@ -386,7 +390,7 @@ class PlanViewModel @Inject constructor(
SubscriptionResult.NotFound -> {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -426,8 +430,8 @@ class PlanViewModel @Inject constructor(
val status = (action.state as? SubscriptionStatusState.Available)?.status
?: return
if (!status.isPremiumViewEligible()) return
onFreeContent { freeState ->
if (freeState.isAwaitingPremiumStatus) return@onFreeContent
onFreeCloudContent { freeState ->
if (freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Premium(),
@@ -453,8 +457,8 @@ class PlanViewModel @Inject constructor(
private fun handleUserStateUpdateReceive(
action: PlanAction.Internal.UserStateUpdateReceive,
) {
onFreeContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeContent
onFreeCloudContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
val isPremium = action.userState?.activeAccount?.isPremium == true
if (isPremium) {
@@ -471,7 +475,7 @@ class PlanViewModel @Inject constructor(
specialCircumstanceManager.specialCircumstance = null
if (checkoutResult.callbackResult is PremiumCheckoutCallbackResult.Canceled) {
onFreeContent { freeState ->
onFreeCloudContent { freeState ->
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
@@ -492,7 +496,7 @@ class PlanViewModel @Inject constructor(
if (isPremium) {
onPremiumUpgradeSuccess()
} else {
onFreeContent { freeState ->
onFreeCloudContent { freeState ->
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
@@ -516,8 +520,8 @@ class PlanViewModel @Inject constructor(
}
private fun handleSyncCompleteReceive() {
onFreeContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeContent
onFreeCloudContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
val isPremium = authRepository
.userStateFlow
@@ -537,7 +541,7 @@ class PlanViewModel @Inject constructor(
}
private fun onPremiumUpgradeSuccess() {
onFreeContent {
onFreeCloudContent {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Premium(),
@@ -556,7 +560,7 @@ class PlanViewModel @Inject constructor(
}
// The Upgraded to Premium route uses `launchSingleTop = true` so a duplicate event is a
// no-op for the user. The event itself is harmless to re-emit; the state mutation above
// is what's guarded by `onFreeContent`.
// is what's guarded by `onFreeCloudContent`.
sendEvent(PlanEvent.NavigateToUpgradedToPremium)
}
@@ -569,8 +573,10 @@ class PlanViewModel @Inject constructor(
.format(result.annualPrice / MONTHS_PER_YEAR)
mutableStateFlow.update { currentState ->
val updatedViewState = when (val vs = currentState.viewState) {
is PlanState.ViewState.Free -> vs.copy(rate = formattedRate)
is PlanState.ViewState.Premium -> vs
is PlanState.ViewState.Free.Cloud -> vs.copy(rate = formattedRate)
is PlanState.ViewState.Free.SelfHosted,
is PlanState.ViewState.Premium,
-> vs
}
currentState.copy(
viewState = updatedViewState,
@@ -610,10 +616,10 @@ class PlanViewModel @Inject constructor(
}
}
private inline fun onFreeContent(
block: (PlanState.ViewState.Free) -> Unit,
private inline fun onFreeCloudContent(
block: (PlanState.ViewState.Free.Cloud) -> Unit,
) {
(state.viewState as? PlanState.ViewState.Free)?.let(block)
(state.viewState as? PlanState.ViewState.Free.Cloud)?.let(block)
}
private inline fun onPremiumContent(
@@ -728,14 +734,30 @@ data class PlanState(
sealed class ViewState : Parcelable {
/**
* Free user view — shows upgrade pricing and feature list.
* Free user view — shows the upgrade flow for cloud accounts or a
* "manage on web vault" info card for self-hosted accounts.
*/
@Parcelize
data class Free(
val rate: String,
val checkoutUrl: String?,
val isAwaitingPremiumStatus: Boolean,
) : ViewState()
sealed class Free : ViewState() {
/**
* Free user on a cloud-hosted environment — shows upgrade pricing
* and feature list.
*/
@Parcelize
data class Cloud(
val rate: String,
val checkoutUrl: String?,
val isAwaitingPremiumStatus: Boolean,
) : Free()
/**
* Free user on a self-hosted environment — Stripe checkout is
* unavailable, so the screen redirects the user to manage their
* subscription on the web vault.
*/
@Parcelize
data object SelfHosted : Free()
}
/**
* Premium user view — shows subscription details and management options.

View File

@@ -4,6 +4,7 @@ import androidx.annotation.DrawableRes
import androidx.compose.material3.Text
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.DeferredBackgroundEvent
import com.bitwarden.ui.platform.resource.BitwardenDrawable
@@ -15,6 +16,7 @@ import com.x8bit.bitwarden.data.billing.manager.UPGRADED_TO_PREMIUM_LEARN_MORE_U
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -32,6 +34,7 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor(
specialCircumstanceManager: SpecialCircumstanceManager,
firstTimeActionManager: FirstTimeActionManager,
environmentRepository: EnvironmentRepository,
private val premiumStateManager: PremiumStateManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<SettingsState, SettingsEvent, SettingsAction>(
@@ -41,6 +44,7 @@ class SettingsViewModel @Inject constructor(
autoFillCount = firstTimeActionManager.allAutofillSettingsBadgeCountFlow.value,
vaultCount = firstTimeActionManager.allVaultSettingsBadgeCountFlow.value,
isPlanRowEligible = premiumStateManager.isPlanRowEligibleFlow.value,
isSelfHosted = environmentRepository.environment is Environment.SelfHosted,
isUpgradedToPremiumCardEligible = premiumStateManager
.isUpgradedToPremiumCardEligibleFlow
.value,
@@ -76,6 +80,16 @@ class SettingsViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
environmentRepository
.environmentStateFlow
.map {
SettingsAction.Internal.EnvironmentReceive(
isSelfHosted = it is Environment.SelfHosted,
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
when (specialCircumstanceManager.specialCircumstance) {
SpecialCircumstance.AccountSecurityShortcut -> {
sendEvent(SettingsEvent.NavigateAccountSecurityShortcut)
@@ -102,6 +116,18 @@ class SettingsViewModel @Inject constructor(
is SettingsAction.Internal.UpgradedToPremiumCardEligibilityReceive -> {
handleUpgradedToPremiumCardEligibilityReceive(action)
}
is SettingsAction.Internal.EnvironmentReceive -> {
handleEnvironmentReceive(action)
}
}
private fun handleEnvironmentReceive(
action: SettingsAction.Internal.EnvironmentReceive,
) {
mutableStateFlow.update {
it.copy(isSelfHosted = action.isSelfHosted)
}
}
private fun handleUpgradedToPremiumCardClick() {
@@ -185,6 +211,7 @@ data class SettingsState(
private val securityCount: Int,
private val vaultCount: Int,
private val isPlanRowEligible: Boolean,
private val isSelfHosted: Boolean = false,
private val isUpgradedToPremiumCardEligible: Boolean = false,
) {
val shouldShowCloseButton: Boolean = isPreAuth
@@ -199,9 +226,10 @@ data class SettingsState(
* Whether the plan row should be shown. The row is visible post-authentication when the user
* is eligible per [PremiumStateManager.isPlanRowEligibleFlow] — currently, when the in-app
* upgrade feature is enabled and the user is not relying solely on organization-granted
* Premium.
* Premium — and the account is on a cloud-hosted environment. Self-hosted users manage their
* subscription on the web vault.
*/
private val shouldShowPlanRow: Boolean = !isPreAuth && isPlanRowEligible
private val shouldShowPlanRow: Boolean = !isPreAuth && isPlanRowEligible && !isSelfHosted
val settingRows: ImmutableList<Settings> = Settings
.entries
@@ -334,6 +362,13 @@ sealed class SettingsAction {
data class UpgradedToPremiumCardEligibilityReceive(
val isEligible: Boolean,
) : Internal()
/**
* Indicates that the environment has been updated.
*/
data class EnvironmentReceive(
val isSelfHosted: Boolean,
) : Internal()
}
}

View File

@@ -1019,6 +1019,55 @@ class PlanScreenTest : BitwardenComposeTest() {
// endregion Premium-flow dialogs
// region Self-hosted free flow
@Test
fun `manage subscription info callout should render when self-hosted free`() {
mutableStateFlow.update {
it.copy(viewState = PlanState.ViewState.Free.SelfHosted)
}
composeTestRule
.onNodeWithText(
"To manage your Premium subscription, " +
"youll need to login to your web vault on a computer.",
)
.assertIsDisplayed()
composeTestRule
.onNodeWithTag("SelfHostedManageOnWebVaultCallout")
.assertIsDisplayed()
}
@Test
fun `premium features header should render when self-hosted free`() {
mutableStateFlow.update {
it.copy(viewState = PlanState.ViewState.Free.SelfHosted)
}
composeTestRule
.onNodeWithText("Unlock more advanced features with a Premium plan.")
.assertIsDisplayed()
}
@Test
fun `premium feature list items should render when self-hosted free`() {
mutableStateFlow.update {
it.copy(viewState = PlanState.ViewState.Free.SelfHosted)
}
composeTestRule
.onNodeWithText("Built-in authenticator")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Emergency access")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Secure file storage")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Breach monitoring")
.assertIsDisplayed()
}
// endregion Self-hosted free flow
// region LaunchPortal event
@Test
@@ -1033,7 +1082,7 @@ class PlanScreenTest : BitwardenComposeTest() {
private val DEFAULT_FREE_STATE = PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.65",
checkoutUrl = null,
isAwaitingPremiumStatus = false,

View File

@@ -2,6 +2,8 @@ package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
import com.bitwarden.ui.platform.resource.BitwardenString
@@ -21,6 +23,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import io.mockk.coEvery
@@ -67,6 +70,11 @@ class PlanViewModelTest : BaseViewModelTest() {
private val mockPremiumStateManager: PremiumStateManager = mockk {
every { subscriptionStatusStateFlow } returns mutableSubscriptionStatusStateFlow
}
private val mutableEnvironmentFlow = MutableStateFlow<Environment>(Environment.Us)
private val mockEnvironmentRepository: EnvironmentRepository = mockk {
every { environment } answers { mutableEnvironmentFlow.value }
every { environmentStateFlow } returns mutableEnvironmentFlow
}
@BeforeEach
fun setup() {
@@ -130,7 +138,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -210,7 +218,7 @@ class PlanViewModelTest : BaseViewModelTest() {
)
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
@@ -300,7 +308,7 @@ class PlanViewModelTest : BaseViewModelTest() {
)
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
@@ -372,7 +380,7 @@ class PlanViewModelTest : BaseViewModelTest() {
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(
val freeState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
@@ -420,7 +428,7 @@ class PlanViewModelTest : BaseViewModelTest() {
runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -462,7 +470,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -517,7 +525,7 @@ class PlanViewModelTest : BaseViewModelTest() {
// Sync completes without premium — PendingUpgrade shown.
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -541,7 +549,7 @@ class PlanViewModelTest : BaseViewModelTest() {
runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -600,6 +608,43 @@ class PlanViewModelTest : BaseViewModelTest() {
// endregion Free user path
// region Self-hosted path
@Test
fun `initial state on self-hosted should be Free SelfHosted ViewState`() = runTest {
mutableEnvironmentFlow.value = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
val viewModel = createViewModel(
pricingResult = null,
)
viewModel.stateFlow.test {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free.SelfHosted,
dialogState = null,
),
awaitItem(),
)
}
}
@Test
fun `initial state on self-hosted should not fetch pricing`() = runTest {
mutableEnvironmentFlow.value = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
createViewModel(pricingResult = null)
coVerify(exactly = 0) {
mockBillingRepository.getPremiumPlanPricing()
}
}
// endregion Self-hosted path
// region Pricing fetch
@Test
@@ -611,7 +656,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -636,7 +681,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -664,7 +709,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -687,7 +732,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -718,7 +763,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -736,7 +781,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -907,7 +952,7 @@ class PlanViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -1405,6 +1450,7 @@ class PlanViewModelTest : BaseViewModelTest() {
authRepository = mockAuthRepository,
billingRepository = mockBillingRepository,
premiumStateManager = mockPremiumStateManager,
environmentRepository = mockEnvironmentRepository,
specialCircumstanceManager = mockSpecialCircumstanceManager,
vaultRepository = mockVaultRepository,
clock = clock,
@@ -1443,7 +1489,7 @@ private val DEFAULT_USER_STATE = UserState(
private val DEFAULT_FREE_STATE = PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,

View File

@@ -2,11 +2,14 @@ package com.x8bit.bitwarden.ui.platform.feature.settings
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@@ -47,6 +50,11 @@ class SettingsViewModelTest : BaseViewModelTest() {
isUpgradedToPremiumCardEligibleFlow
} returns mutableUpgradedToPremiumCardEligibleFlow
}
private val mutableEnvironmentFlow = MutableStateFlow<Environment>(Environment.Us)
private val environmentRepository: EnvironmentRepository = mockk {
every { environment } answers { mutableEnvironmentFlow.value }
every { environmentStateFlow } returns mutableEnvironmentFlow
}
@BeforeEach
fun setup() {
@@ -323,9 +331,42 @@ class SettingsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `Plan row should be hidden when environment is self-hosted`() {
mutablePlanRowEligibleFlow.value = true
mutableEnvironmentFlow.value = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
val viewModel = createViewModel()
assertFalse(
viewModel.stateFlow.value.settingRows
.contains(Settings.PLAN),
)
}
@Test
fun `Plan row should update when environment changes to self-hosted`() = runTest {
mutablePlanRowEligibleFlow.value = true
val viewModel = createViewModel()
assertTrue(
viewModel.stateFlow.value.settingRows
.contains(Settings.PLAN),
)
mutableEnvironmentFlow.value = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
viewModel.stateFlow.test {
assertFalse(
awaitItem().settingRows.contains(Settings.PLAN),
)
}
}
private fun createViewModel(isPreAuth: Boolean = false) = SettingsViewModel(
firstTimeActionManager = firstTimeManager,
specialCircumstanceManager = specialCircumstanceManager,
environmentRepository = environmentRepository,
premiumStateManager = premiumStateManager,
savedStateHandle = SavedStateHandle().apply {
every { toSettingsArgs() } returns SettingsArgs(isPreAuth = isPreAuth)

View File

@@ -1217,6 +1217,7 @@ Do you want to switch to this account?</string>
<string name="archiving_items_is_a_premium_feature">Archiving items is a Premium feature. Your current plan does not include access to this feature.</string>
<string name="upgrade_to_premium">Upgrade to Premium</string>
<string name="plan">Plan</string>
<string name="to_manage_your_premium_subscription_youll_need_to_login_to_your_web_vault_on_a_computer">To manage your Premium subscription, youll need to login to your web vault on a computer.</string>
<string name="unlock_advanced_security_features">Unlock advanced security features</string>
<string name="a_premium_plan_gives_you_more_tools_to_stay_secure_and_in_control">A Premium plan gives you more tools to stay secure and in control.</string>
<string name="this_item_is_archived">This item is archived.</string>