mirror of
https://github.com/bitwarden/android.git
synced 2026-05-26 14:34:19 -05:00
[PM-36886] fix: Gate premium upgrade flow on self-hosted environments (#6939)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, " +
|
||||
"you’ll 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, you’ll 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>
|
||||
|
||||
Reference in New Issue
Block a user