diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt index da5d6cf537..989260c286 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt index eb5f96fbc6..3aa3b56986 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt @@ -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. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt index 3732597afd..c99e95f12a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt @@ -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( @@ -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 .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() } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt index 188e716d64..39b587fa77 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt @@ -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, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt index b4716806e3..dcccce1334 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt @@ -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.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, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt index 1a6ee6f36b..11b6fa4869 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -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.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) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 14ca84a6b7..fcf69300a7 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1217,6 +1217,7 @@ Do you want to switch to this account? Archiving items is a Premium feature. Your current plan does not include access to this feature. Upgrade to Premium Plan + To manage your Premium subscription, you’ll need to login to your web vault on a computer. Unlock advanced security features A Premium plan gives you more tools to stay secure and in control. This item is archived.