[PM-37814] feat: Add debug flag to disable self-host premium check (#6954)

This commit is contained in:
Patrick Honkonen
2026-05-20 17:44:07 -04:00
committed by GitHub
parent 31011b5789
commit c6746fb369
11 changed files with 203 additions and 36 deletions

View File

@@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.billing.repository.BillingRepositoryImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
@@ -57,6 +58,7 @@ object BillingModule {
settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
featureFlagManager: FeatureFlagManager,
environmentRepository: EnvironmentRepository,
pushManager: PushManager,
clock: Clock,
dispatcherManager: DispatcherManager,
@@ -66,6 +68,7 @@ object BillingModule {
settingsDiskSource = settingsDiskSource,
vaultRepository = vaultRepository,
featureFlagManager = featureFlagManager,
environmentRepository = environmentRepository,
pushManager = pushManager,
clock = clock,
dispatcherManager = dispatcherManager,

View File

@@ -38,6 +38,18 @@ interface PremiumStateManager {
*/
val subscriptionStatusStateFlow: StateFlow<SubscriptionStatusState>
/**
* Emits whether the current state should be treated as self-hosted for premium upgrade
* gating. Reactive equivalent of [isSelfHosted].
*/
val isSelfHostedFlow: StateFlow<Boolean>
/**
* `true` when the current state should be treated as self-hosted for premium upgrade
* gating, or `false` otherwise.
*/
val isSelfHosted: Boolean
/**
* Returns `true` when the in-app upgrade flow is available, or `false` otherwise.
*/

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.billing.manager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.repository.model.Environment
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
@@ -13,6 +14,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.util.isActive
import com.x8bit.bitwarden.data.platform.util.scanPairs
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@@ -47,6 +49,7 @@ class PremiumStateManagerImpl(
private val settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
private val featureFlagManager: FeatureFlagManager,
private val environmentRepository: EnvironmentRepository,
pushManager: PushManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
@@ -140,6 +143,20 @@ class PremiumStateManagerImpl(
initialValue = false,
)
override val isSelfHostedFlow: StateFlow<Boolean> =
combine(
environmentRepository.environmentStateFlow,
featureFlagManager.getFeatureFlagFlow(FlagKey.DebugDisableSelfHostPremiumCheck),
) { environment, isDebugBypassEnabled ->
environment is Environment.SelfHosted && !isDebugBypassEnabled
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = environmentRepository.environment is Environment.SelfHosted &&
!featureFlagManager.getFeatureFlag(FlagKey.DebugDisableSelfHostPremiumCheck),
)
/**
* Eligibility is keyed on the user holding personal Premium (or being eligible to purchase
* it). Organization-granted Premium does not surface the Plan row, since the user has no
@@ -242,6 +259,8 @@ class PremiumStateManagerImpl(
.launchIn(unconfinedScope)
}
override val isSelfHosted: Boolean get() = isSelfHostedFlow.value
override fun isInAppUpgradeAvailable(): Boolean =
billingRepository.isInAppBillingSupportedFlow.value &&
featureFlagManager.getFeatureFlag(FlagKey.MobilePremiumUpgrade)

View File

@@ -6,7 +6,6 @@ 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.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
@@ -82,7 +81,7 @@ class PlanViewModel @Inject constructor(
?.isPremium == true
val showsPremiumView = isPremium ||
premiumStateManager.subscriptionStatusStateFlow.value.isPremiumViewEligible()
val isSelfHosted = environmentRepository.environment is Environment.SelfHosted
val isSelfHosted = premiumStateManager.isSelfHosted
PlanState(
planMode = planMode,
viewState = when {

View File

@@ -4,7 +4,6 @@ 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
@@ -16,7 +15,6 @@ 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
@@ -34,7 +32,6 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor(
specialCircumstanceManager: SpecialCircumstanceManager,
firstTimeActionManager: FirstTimeActionManager,
environmentRepository: EnvironmentRepository,
private val premiumStateManager: PremiumStateManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<SettingsState, SettingsEvent, SettingsAction>(
@@ -44,7 +41,7 @@ class SettingsViewModel @Inject constructor(
autoFillCount = firstTimeActionManager.allAutofillSettingsBadgeCountFlow.value,
vaultCount = firstTimeActionManager.allVaultSettingsBadgeCountFlow.value,
isPlanRowEligible = premiumStateManager.isPlanRowEligibleFlow.value,
isSelfHosted = environmentRepository.environment is Environment.SelfHosted,
isSelfHosted = premiumStateManager.isSelfHostedFlow.value,
isUpgradedToPremiumCardEligible = premiumStateManager
.isUpgradedToPremiumCardEligibleFlow
.value,
@@ -80,13 +77,9 @@ class SettingsViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
environmentRepository
.environmentStateFlow
.map {
SettingsAction.Internal.EnvironmentReceive(
isSelfHosted = it is Environment.SelfHosted,
)
}
premiumStateManager
.isSelfHostedFlow
.map { SettingsAction.Internal.SelfHostedStatusReceive(isSelfHosted = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
@@ -117,13 +110,13 @@ class SettingsViewModel @Inject constructor(
handleUpgradedToPremiumCardEligibilityReceive(action)
}
is SettingsAction.Internal.EnvironmentReceive -> {
handleEnvironmentReceive(action)
is SettingsAction.Internal.SelfHostedStatusReceive -> {
handleSelfHostedStatusReceive(action)
}
}
private fun handleEnvironmentReceive(
action: SettingsAction.Internal.EnvironmentReceive,
private fun handleSelfHostedStatusReceive(
action: SettingsAction.Internal.SelfHostedStatusReceive,
) {
mutableStateFlow.update {
it.copy(isSelfHosted = action.isSelfHosted)
@@ -364,9 +357,10 @@ sealed class SettingsAction {
) : Internal()
/**
* Indicates that the environment has been updated.
* Indicates that the effective self-hosted status for premium gating has been updated
* driven by environment changes or by the debug-only self-host-bypass flag.
*/
data class EnvironmentReceive(
data class SelfHostedStatusReceive(
val isSelfHosted: Boolean,
) : Internal()
}

View File

@@ -5,6 +5,7 @@ import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.vault.DecryptCipherListResult
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -19,6 +20,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSo
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.model.PremiumStatusChangedData
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherListView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
@@ -66,6 +68,7 @@ class PremiumStateManagerTest {
}
private val mutableMobilePremiumUpgradeFlagFlow = MutableStateFlow(true)
private val mutableDebugDisableSelfHostPremiumCheckFlagFlow = MutableStateFlow(false)
private val featureFlagManager: FeatureFlagManager = mockk {
every {
getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade)
@@ -73,8 +76,16 @@ class PremiumStateManagerTest {
every {
getFeatureFlag(FlagKey.MobilePremiumUpgrade)
} answers { mutableMobilePremiumUpgradeFlagFlow.value }
every {
getFeatureFlag(FlagKey.DebugDisableSelfHostPremiumCheck)
} answers { mutableDebugDisableSelfHostPremiumCheckFlagFlow.value }
every {
getFeatureFlagFlow(FlagKey.DebugDisableSelfHostPremiumCheck)
} returns mutableDebugDisableSelfHostPremiumCheckFlagFlow
}
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
private val dispatcherManager = FakeDispatcherManager()
private val mutablePremiumStatusChangedFlow =
@@ -89,6 +100,7 @@ class PremiumStateManagerTest {
settingsDiskSource = fakeSettingsDiskSource,
vaultRepository = vaultRepository,
featureFlagManager = featureFlagManager,
environmentRepository = fakeEnvironmentRepository,
pushManager = pushManager,
clock = fixedClock,
dispatcherManager = dispatcherManager,
@@ -365,6 +377,75 @@ class PremiumStateManagerTest {
assertFalse(manager.isInAppUpgradeAvailable())
}
@Test
fun `isSelfHosted should return false on cloud environment regardless of flag`() {
fakeEnvironmentRepository.environment = Environment.Us
val manager = createManager()
mutableDebugDisableSelfHostPremiumCheckFlagFlow.value = false
assertFalse(manager.isSelfHosted)
mutableDebugDisableSelfHostPremiumCheckFlagFlow.value = true
assertFalse(manager.isSelfHosted)
}
@Test
fun `isSelfHosted should return true on self-hosted environment when flag is disabled`() {
fakeEnvironmentRepository.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
mutableDebugDisableSelfHostPremiumCheckFlagFlow.value = false
val manager = createManager()
assertTrue(manager.isSelfHosted)
}
@Test
fun `isSelfHosted should return false on self-hosted environment when flag is enabled`() {
fakeEnvironmentRepository.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
mutableDebugDisableSelfHostPremiumCheckFlagFlow.value = true
val manager = createManager()
assertFalse(manager.isSelfHosted)
}
@Test
fun `isSelfHostedFlow should emit false on cloud environment regardless of flag`() = runTest {
fakeEnvironmentRepository.environment = Environment.Us
val manager = createManager()
manager.isSelfHostedFlow.test {
assertFalse(awaitItem())
mutableDebugDisableSelfHostPremiumCheckFlagFlow.value = true
expectNoEvents()
}
}
@Test
fun `isSelfHostedFlow should emit true on self-hosted environment when flag is disabled`() =
runTest {
fakeEnvironmentRepository.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
val manager = createManager()
manager.isSelfHostedFlow.test {
assertTrue(awaitItem())
}
}
@Test
fun `isSelfHostedFlow should re-emit when debug-disable flag toggles on self-hosted env`() =
runTest {
fakeEnvironmentRepository.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
val manager = createManager()
manager.isSelfHostedFlow.test {
assertTrue(awaitItem())
mutableDebugDisableSelfHostPremiumCheckFlagFlow.value = true
assertFalse(awaitItem())
mutableDebugDisableSelfHostPremiumCheckFlagFlow.value = false
assertTrue(awaitItem())
}
}
@Test
fun `dismissPremiumUpgradeBanner should store dismissed state for active user`() {
val manager = createManager()

View File

@@ -67,8 +67,10 @@ class PlanViewModelTest : BaseViewModelTest() {
}
private val mutableSubscriptionStatusStateFlow =
MutableStateFlow<SubscriptionStatusState>(SubscriptionStatusState.NoSubscription)
private var mockIsSelfHosted = false
private val mockPremiumStateManager: PremiumStateManager = mockk {
every { subscriptionStatusStateFlow } returns mutableSubscriptionStatusStateFlow
every { isSelfHosted } answers { mockIsSelfHosted }
}
private val mutableEnvironmentFlow = MutableStateFlow<Environment>(Environment.Us)
private val mockEnvironmentRepository: EnvironmentRepository = mockk {
@@ -612,6 +614,7 @@ class PlanViewModelTest : BaseViewModelTest() {
@Test
fun `initial state on self-hosted should be Free SelfHosted ViewState`() = runTest {
mockIsSelfHosted = true
mutableEnvironmentFlow.value = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
@@ -633,6 +636,7 @@ class PlanViewModelTest : BaseViewModelTest() {
@Test
fun `initial state on self-hosted should not fetch pricing`() = runTest {
mockIsSelfHosted = true
mutableEnvironmentFlow.value = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
@@ -643,6 +647,36 @@ class PlanViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `self-hosted with debug disable flag enabled should show Free Cloud and fetch pricing`() =
runTest {
// The PremiumStateManager helper reports false when the debug-disable flag is on,
// so the view model treats the self-hosted env as cloud for premium-upgrade purposes.
mockIsSelfHosted = false
mutableEnvironmentFlow.value = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
),
dialogState = null,
),
awaitItem(),
)
}
coVerify(exactly = 1) {
mockBillingRepository.getPremiumPlanPricing()
}
}
// endregion Self-hosted path
// region Pricing fetch

View File

@@ -2,14 +2,11 @@ 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
@@ -44,16 +41,13 @@ class SettingsViewModelTest : BaseViewModelTest() {
private val mutablePlanRowEligibleFlow = MutableStateFlow(false)
private val mutableUpgradedToPremiumCardEligibleFlow = MutableStateFlow(false)
private val mutableIsSelfHostedFlow = MutableStateFlow(false)
private val premiumStateManager: PremiumStateManager = mockk(relaxed = true) {
every { isPlanRowEligibleFlow } returns mutablePlanRowEligibleFlow
every {
isUpgradedToPremiumCardEligibleFlow
} returns mutableUpgradedToPremiumCardEligibleFlow
}
private val mutableEnvironmentFlow = MutableStateFlow<Environment>(Environment.Us)
private val environmentRepository: EnvironmentRepository = mockk {
every { environment } answers { mutableEnvironmentFlow.value }
every { environmentStateFlow } returns mutableEnvironmentFlow
every { isSelfHostedFlow } returns mutableIsSelfHostedFlow
}
@BeforeEach
@@ -332,11 +326,9 @@ class SettingsViewModelTest : BaseViewModelTest() {
}
@Test
fun `Plan row should be hidden when environment is self-hosted`() {
fun `Plan row should be hidden when self-hosted status flow emits true`() {
mutablePlanRowEligibleFlow.value = true
mutableEnvironmentFlow.value = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
mutableIsSelfHostedFlow.value = true
val viewModel = createViewModel()
assertFalse(
viewModel.stateFlow.value.settingRows
@@ -345,7 +337,7 @@ class SettingsViewModelTest : BaseViewModelTest() {
}
@Test
fun `Plan row should update when environment changes to self-hosted`() = runTest {
fun `Plan row should update when self-hosted status flow flips to true`() = runTest {
mutablePlanRowEligibleFlow.value = true
val viewModel = createViewModel()
assertTrue(
@@ -353,9 +345,7 @@ class SettingsViewModelTest : BaseViewModelTest() {
.contains(Settings.PLAN),
)
mutableEnvironmentFlow.value = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
mutableIsSelfHostedFlow.value = true
viewModel.stateFlow.test {
assertFalse(
awaitItem().settingRows.contains(Settings.PLAN),
@@ -363,10 +353,29 @@ class SettingsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `Plan row should re-appear when self-hosted status flow flips back to false`() = runTest {
// Simulates the debug-disable flag being toggled on while self-hosted: the flow emits
// false even though the environment is still self-hosted.
mutablePlanRowEligibleFlow.value = true
mutableIsSelfHostedFlow.value = true
val viewModel = createViewModel()
assertFalse(
viewModel.stateFlow.value.settingRows
.contains(Settings.PLAN),
)
mutableIsSelfHostedFlow.value = false
viewModel.stateFlow.test {
assertTrue(
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

@@ -45,6 +45,7 @@ sealed class FlagKey<out T : Any> {
V2EncryptionPassword,
V2EncryptionTde,
NewItemTypes,
DebugDisableSelfHostPremiumCheck,
)
}
}
@@ -180,6 +181,16 @@ sealed class FlagKey<out T : Any> {
override val defaultValue: Boolean = false
}
/**
* Debug-only flag that, when enabled, makes self-hosted environments behave as cloud
* environments for premium-upgrade gating. Used by QA to test the premium upgrade flow
* against internal self-hosted environments.
*/
data object DebugDisableSelfHostPremiumCheck : FlagKey<Boolean>() {
override val keyName: String = "debug-disable-self-host-premium-check"
override val defaultValue: Boolean = false
}
//region Dummy keys for testing
/**
* Data object holding the key for a [Boolean] flag to be used in tests.

View File

@@ -39,6 +39,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
FlagKey.V2EncryptionPassword,
FlagKey.V2EncryptionTde,
FlagKey.NewItemTypes,
FlagKey.DebugDisableSelfHostPremiumCheck,
-> {
@Suppress("UNCHECKED_CAST")
BooleanFlagItem(
@@ -99,4 +100,7 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
FlagKey.V2EncryptionPassword -> stringResource(BitwardenString.v2_encryption_password)
FlagKey.V2EncryptionTde -> stringResource(BitwardenString.v2_encryption_tde)
FlagKey.NewItemTypes -> stringResource(BitwardenString.new_item_types)
FlagKey.DebugDisableSelfHostPremiumCheck -> {
stringResource(BitwardenString.debug_disable_self_host_premium_check)
}
}

View File

@@ -53,6 +53,7 @@
<string name="v2_encryption_password">V2 Encryption - Password</string>
<string name="manage_devices">Manage devices</string>
<string name="new_item_types">New Item Types</string>
<string name="debug_disable_self_host_premium_check">Debug: Disable self-host premium check</string>
<!-- endregion Debug Menu -->
</resources>