PM-38356 PM-37177: Bug: Update premium subscription trouble-states (#7073)

This commit is contained in:
David Perez
2026-06-18 19:54:04 +00:00
committed by GitHub
parent 5b8dd94b07
commit 59b9cbfa31
9 changed files with 362 additions and 148 deletions
@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.billing.manager
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
import kotlinx.coroutines.flow.StateFlow
@@ -16,10 +17,9 @@ const val UPGRADED_TO_PREMIUM_LEARN_MORE_URL: String =
interface PremiumStateManager {
/**
* Emits `true` when the current user is eligible to see the Premium upgrade banner,
* or `false` otherwise.
* Emits a [PremiumCard] for the current user indicating what Premium card should be displayed.
*/
val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean>
val premiumCardStateFlow: StateFlow<PremiumCard>
/**
* Emits `true` while the active user is eligible to see the "Upgraded to Premium" action
@@ -5,8 +5,10 @@ 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.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
@@ -50,7 +52,7 @@ class PremiumStateManagerImpl(
private val settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
private val featureFlagManager: FeatureFlagManager,
private val environmentRepository: EnvironmentRepository,
environmentRepository: EnvironmentRepository,
pushManager: PushManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
@@ -123,62 +125,69 @@ class PremiumStateManagerImpl(
)
@OptIn(ExperimentalCoroutinesApi::class)
override val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean> =
override val premiumCardStateFlow: StateFlow<PremiumCard> =
combine(
authDiskSource.userStateFlow,
authDiskSource.userStateFlow.map { it?.activeAccount },
billingRepository.isInAppBillingSupportedFlow,
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
authDiskSource.activeUserIdChangesFlow
.flatMapLatest { userId ->
userId
?.let { id ->
settingsDiskSource
.getPremiumUpgradeBannerDismissedFlow(id)
.map { it ?: false }
}
?: flowOf(false)
},
authDiskSource.activeUserIdChangesFlow.flatMapLatest { userId ->
userId
?.let { id ->
settingsDiskSource
.getPremiumUpgradeBannerDismissedFlow(id)
.map { it ?: false }
}
?: flowOf(false)
},
vaultRepository.vaultDataStateFlow,
) {
userState,
account,
isInAppBillingSupported,
featureFlagEnabled,
isDismissed,
isUpgradeCardDismissed,
vaultDataState,
->
BannerInputs(
userState = userState,
account = account,
isInAppBillingSupported = isInAppBillingSupported,
featureFlagEnabled = featureFlagEnabled,
isDismissed = isDismissed,
isUpgradeCardDismissed = isUpgradeCardDismissed,
vaultDataState = vaultDataState,
)
}
.combine(upgradeLifecycleStateFlow) { inputs, lifecycle ->
val profile = inputs.userState?.activeAccount?.profile
?: return@combine false
val isAccountOldEnough = profile.creationDate.isOlderThanDays(
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
clock = clock,
)
val itemCount = inputs.vaultDataState.activeVaultItemCount()
val lifecycleAllowsBanner = lifecycle is UpgradeLifecycleState.Free ||
(
lifecycle is UpgradeLifecycleState.Premium &&
lifecycle.subscriptionStatus.isInTroubleState()
val profile = inputs.account?.profile ?: return@combine PremiumCard.NONE
if (!inputs.featureFlagEnabled) return@combine PremiumCard.NONE
val initialCard = when (lifecycle) {
UpgradeLifecycleState.Free -> PremiumCard.UPGRADE
UpgradeLifecycleState.UpgradePending -> PremiumCard.NONE
is UpgradeLifecycleState.Premium -> {
lifecycle.subscriptionStatus.premiumCardState()
}
}
when (initialCard) {
PremiumCard.UPGRADE -> {
val isAccountOldEnough = profile.creationDate.isOlderThanDays(
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
clock = clock,
)
val itemCount = inputs.vaultDataState.activeVaultItemCount()
val showCard = inputs.isInAppBillingSupported &&
isAccountOldEnough &&
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS &&
!inputs.isUpgradeCardDismissed
initialCard.takeIf { showCard } ?: PremiumCard.NONE
}
lifecycleAllowsBanner &&
inputs.isInAppBillingSupported &&
inputs.featureFlagEnabled &&
!inputs.isDismissed &&
isAccountOldEnough &&
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS
PremiumCard.NEEDS_ATTENTION,
PremiumCard.NONE,
-> initialCard
}
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = false,
initialValue = PremiumCard.NONE,
)
override val isSelfHostedFlow: StateFlow<Boolean> =
@@ -389,32 +398,41 @@ class PremiumStateManagerImpl(
}
private data class BannerInputs(
val userState: UserStateJson?,
val account: AccountJson?,
val isInAppBillingSupported: Boolean,
val featureFlagEnabled: Boolean,
val isDismissed: Boolean,
val isUpgradeCardDismissed: Boolean,
val vaultDataState: DataState<VaultData>,
)
/**
* Returns `true` when the given [SubscriptionStatusState] represents a subscription substate
* that should disqualify a user from being treated as effectively premium.
* Returns a [PremiumCard] for the given [SubscriptionStatusState] and subscription substate.
*/
private fun SubscriptionStatusState.isInTroubleState(): Boolean =
this is SubscriptionStatusState.Available &&
when (this.status) {
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> true
private fun SubscriptionStatusState.premiumCardState(): PremiumCard =
when (this) {
is SubscriptionStatusState.Available -> {
when (this.status) {
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> PremiumCard.NEEDS_ATTENTION
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
-> false
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAUSED,
-> PremiumCard.UPGRADE
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
-> PremiumCard.NONE
}
}
is SubscriptionStatusState.Error,
SubscriptionStatusState.Loading,
SubscriptionStatusState.NoSubscription,
-> PremiumCard.NONE
}
/**
* Returns `true` if this [Instant] is older than the given number of [days] based on
* the provided [clock]. Returns `false` if the receiver is `null`.
@@ -0,0 +1,10 @@
package com.x8bit.bitwarden.data.billing.model
/**
* Represents which premium card should be displayed.
*/
enum class PremiumCard {
UPGRADE,
NEEDS_ATTENTION,
NONE,
}
@@ -562,6 +562,7 @@ fun VaultContent(
}
}
@Suppress("LongMethod")
@Composable
private fun ActionCard(
actionCardState: VaultState.ActionCardState,
@@ -606,6 +607,16 @@ private fun ActionCard(
)
}
VaultState.ActionCardState.PremiumNeedsAttention -> {
BitwardenActionCard(
cardTitle = stringResource(id = BitwardenString.your_subscription_needs_attention),
cardSubtitle = stringResource(id = BitwardenString.check_your_plan_for_details),
actionText = stringResource(id = BitwardenString.view_plan),
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
modifier = modifier,
)
}
VaultState.ActionCardState.IntroducingArchive -> {
BitwardenActionCard(
cardTitle = stringResource(id = BitwardenString.introducing_archive),
@@ -36,6 +36,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.billing.manager.UPGRADED_TO_PREMIUM_LEARN_MORE_URL
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
@@ -120,7 +121,7 @@ class VaultViewModel @Inject constructor(
private val networkConnectionManager: NetworkConnectionManager,
private val browserAutofillDialogManager: BrowserAutofillDialogManager,
private val credentialExchangeRegistryManager: CredentialExchangeRegistryManager,
private val buildInfoManager: BuildInfoManager,
buildInfoManager: BuildInfoManager,
featureFlagManager: FeatureFlagManager,
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
@@ -268,10 +269,10 @@ class VaultViewModel @Inject constructor(
.launchIn(viewModelScope)
premiumStateManager
.isPremiumUpgradeBannerEligibleFlow
.premiumCardStateFlow
.map {
VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive(
isEligible = it,
premiumCard = it,
)
}
.onEach(::sendAction)
@@ -449,6 +450,10 @@ class VaultViewModel @Inject constructor(
premiumStateManager.dismissPremiumUpgradeBanner()
}
VaultState.ActionCardState.PremiumNeedsAttention -> {
// No-op: The user must address the issue
}
VaultState.ActionCardState.IntroducingArchive -> {
settingsRepository.dismissIntroducingArchiveActionCard()
}
@@ -466,6 +471,10 @@ class VaultViewModel @Inject constructor(
sendEvent(VaultEvent.NavigateToUpgradePremium)
}
VaultState.ActionCardState.PremiumNeedsAttention -> {
sendEvent(VaultEvent.NavigateToUpgradePremium)
}
VaultState.ActionCardState.IntroducingArchive -> {
settingsRepository.dismissIntroducingArchiveActionCard()
sendEvent(
@@ -1273,7 +1282,7 @@ class VaultViewModel @Inject constructor(
action: VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive,
) {
mutableStateFlow.update {
it.copy(isPremiumUpgradeBannerEligible = action.isEligible)
it.copy(premiumCard = action.premiumCard)
}
}
@@ -1747,7 +1756,7 @@ data class VaultState(
val hasShownDecryptionFailureAlert: Boolean,
val restrictItemTypesPolicyOrgIds: List<String>,
val isIntroducingArchiveActionCardDismissed: Boolean,
val isPremiumUpgradeBannerEligible: Boolean = false,
val premiumCard: PremiumCard = PremiumCard.NONE,
val isUpgradedToPremiumCardEligible: Boolean = false,
val isAwaitingKdfSync: Boolean = false,
val validTotpIds: ImmutableSet<String>,
@@ -1761,8 +1770,10 @@ data class VaultState(
get() = (viewState as? ViewState.Content)?.let {
ActionCardState.UpgradedToPremium
.takeIf { isUpgradedToPremiumCardEligible }
?: ActionCardState.UpgradePremium
.takeIf { isPremiumUpgradeBannerEligible }
?: ActionCardState.UpgradePremium.takeIf { premiumCard == PremiumCard.UPGRADE }
?: ActionCardState.PremiumNeedsAttention.takeIf {
premiumCard == PremiumCard.NEEDS_ATTENTION
}
?: ActionCardState.IntroducingArchive.takeIf {
isPremium && !isIntroducingArchiveActionCardDismissed
}
@@ -2169,6 +2180,11 @@ data class VaultState(
*/
data object UpgradePremium : ActionCardState()
/**
* Indicates that the user needs to address an issue with their Premium account.
*/
data object PremiumNeedsAttention : ActionCardState()
/**
* Indicates that the archive feature is ready for use.
*/
@@ -2747,11 +2763,10 @@ sealed class VaultAction {
) : Internal()
/**
* Indicates that the Premium upgrade banner eligibility has been
* updated.
* Indicates that the Premium upgrade banner eligibility has been updated.
*/
data class PremiumUpgradeBannerEligibilityReceive(
val isEligible: Boolean,
val premiumCard: PremiumCard,
) : Internal()
/**
@@ -10,6 +10,7 @@ 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
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
@@ -108,160 +109,160 @@ class PremiumStateManagerTest {
)
@Test
fun `eligible when all conditions met should emit true`() = runTest {
fun `eligible when all conditions met should emit UPGRADE`() = runTest {
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
}
}
@Test
fun `ineligible when user is Premium should emit false`() = runTest {
fun `ineligible when user is Premium should emit NONE`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `ineligible when in-app billing is not supported should emit false`() =
fun `ineligible when in-app billing is not supported should emit NONE`() =
runTest {
mutableIsInAppBillingSupportedFlow.value = false
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `ineligible when feature flag is disabled should emit false`() = runTest {
fun `ineligible when feature flag is disabled should emit NONE`() = runTest {
mutableMobilePremiumUpgradeFlagFlow.value = false
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `ineligible when banner is dismissed should emit false`() = runTest {
fun `ineligible when banner is dismissed should emit NONE`() = runTest {
fakeSettingsDiskSource.storePremiumUpgradeBannerDismissed(
userId = ACTIVE_USER_ID,
isDismissed = true,
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `ineligible when account is too new should emit false`() = runTest {
fun `ineligible when account is too new should emit NONE`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(
creationDate = Instant.parse("2023-10-25T12:00:00Z"),
),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `ineligible when creation date is null should emit false`() = runTest {
fun `ineligible when creation date is null should emit NONE`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(creationDate = null),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `ineligible when vault has fewer than 5 items should emit false`() = runTest {
fun `ineligible when vault has fewer than 5 items should emit NONE`() = runTest {
mutableVaultDataStateFlow.value = DataState.Loaded(
createVaultDataWithItemCount(count = 4),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `ineligible when userState is null should emit false`() = runTest {
fun `ineligible when userState is null should emit NONE`() = runTest {
fakeAuthDiskSource.userState = null
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `vault data Loading should emit false`() = runTest {
fun `vault data Loading should emit NONE`() = runTest {
mutableVaultDataStateFlow.value = DataState.Loading
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `vault data Pending with enough items should emit true`() = runTest {
fun `vault data Pending with enough items should emit UPGRADE`() = runTest {
mutableVaultDataStateFlow.value = DataState.Pending(
createVaultDataWithItemCount(count = 5),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
}
}
@Test
fun `vault data NoNetwork with enough items should emit true`() = runTest {
fun `vault data NoNetwork with enough items should emit UPGRADE`() = runTest {
mutableVaultDataStateFlow.value = DataState.NoNetwork(
createVaultDataWithItemCount(count = 5),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
}
}
@Test
fun `vault data Error with enough items should emit true`() = runTest {
fun `vault data Error with enough items should emit UPGRADE`() = runTest {
mutableVaultDataStateFlow.value = DataState.Error(
error = IllegalStateException("test"),
data = createVaultDataWithItemCount(count = 5),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
}
}
@Test
fun `vault data NoNetwork without data should emit false`() = runTest {
fun `vault data NoNetwork without data should emit NONE`() = runTest {
mutableVaultDataStateFlow.value = DataState.NoNetwork(data = null)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `vault data Error without data should emit false`() = runTest {
fun `vault data Error without data should emit NONE`() = runTest {
mutableVaultDataStateFlow.value = DataState.Error(
error = IllegalStateException("test"),
data = null,
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@@ -283,8 +284,8 @@ class PremiumStateManagerTest {
),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@@ -306,13 +307,13 @@ class PremiumStateManagerTest {
),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `eligible when account age is exactly 7 days should emit true`() =
fun `eligible when account age is exactly 7 days should emit UPGRADE`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(
@@ -320,33 +321,33 @@ class PremiumStateManagerTest {
),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
}
}
@Test
fun `eligible when vault items exactly 5 should emit true`() = runTest {
fun `eligible when vault items exactly 5 should emit UPGRADE`() = runTest {
mutableVaultDataStateFlow.value = DataState.Loaded(
createVaultDataWithItemCount(count = 5),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
}
}
@Test
fun `eligibility should update when upstream flows change`() = runTest {
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
mutableIsInAppBillingSupportedFlow.value = false
assertFalse(awaitItem())
assertEquals(PremiumCard.NONE, awaitItem())
mutableIsInAppBillingSupportedFlow.value = true
assertTrue(awaitItem())
assertEquals(PremiumCard.UPGRADE, awaitItem())
}
}
@@ -833,14 +834,14 @@ class PremiumStateManagerTest {
isPending = true,
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
// Clearing pending re-enables the banner.
fakeSettingsDiskSource.storePremiumUpgradePending(
userId = ACTIVE_USER_ID,
isPending = false,
)
assertTrue(awaitItem())
assertEquals(PremiumCard.UPGRADE, awaitItem())
}
}
@@ -1026,18 +1027,40 @@ class PremiumStateManagerTest {
subscription = createSubscriptionInfo(status = PremiumSubscriptionStatus.ACTIVE),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
}
}
@Test
fun `banner eligible when account is premium but status is in a trouble state`() = runTest {
fun `premium account with an EXPIRED or PAUSED status should emit UPGRADE`() = runTest {
listOf(
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
).forEach { status ->
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = status),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(
PremiumCard.UPGRADE,
awaitItem(),
"Expected UPGRADE for status=$status",
)
}
}
}
@Test
fun `premium account with a payment-trouble status should emit NEEDS_ATTENTION`() = runTest {
listOf(
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
).forEach { status ->
fakeAuthDiskSource.userState = userStateJsonWith(
@@ -1049,12 +1072,70 @@ class PremiumStateManagerTest {
subscription = createSubscriptionInfo(status = status),
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem(), "Expected banner eligible for status=$status")
manager.premiumCardStateFlow.test {
assertEquals(
PremiumCard.NEEDS_ATTENTION,
awaitItem(),
"Expected NEEDS_ATTENTION for status=$status",
)
}
}
}
@Suppress("MaxLineLength")
@Test
fun `premium account with a payment-trouble status emits NEEDS_ATTENTION when billing unsupported`() =
runTest {
// The NEEDS_ATTENTION card is not gated on in-app billing support (unlike the UPGRADE
// card) — the user must resolve the payment issue regardless of platform billing.
mutableIsInAppBillingSupportedFlow.value = false
listOf(
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
).forEach { status ->
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = status),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(
PremiumCard.NEEDS_ATTENTION,
awaitItem(),
"Expected NEEDS_ATTENTION for status=$status",
)
}
}
}
@Suppress("MaxLineLength")
@Test
fun `premium account with an ACTIVE, CANCELED, or PENDING_CANCELLATION status should emit NONE`() =
runTest {
listOf(
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
).forEach { status ->
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = status),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem(), "Expected NONE for status=$status")
}
}
}
@Test
fun `banner ineligible when account is premium and substate is still loading`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
@@ -1066,10 +1147,10 @@ class PremiumStateManagerTest {
kotlinx.coroutines.awaitCancellation()
}
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
manager.premiumCardStateFlow.test {
// Loading is not treated as a trouble state so a premium user is still effectively
// premium during the initial fetch.
assertFalse(awaitItem())
assertEquals(PremiumCard.NONE, awaitItem())
}
}
}
@@ -48,6 +48,7 @@ import com.bitwarden.ui.util.performLogoutAccountClick
import com.bitwarden.ui.util.performRemoveAccountClick
import com.bitwarden.ui.util.performYesDialogButtonClick
import com.bitwarden.vault.CipherType
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager
import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType
@@ -1641,7 +1642,7 @@ class VaultScreenTest : BitwardenComposeTest() {
@Test
fun `UpgradePremium action card should display when eligible`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
isPremiumUpgradeBannerEligible = true,
premiumCard = PremiumCard.UPGRADE,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
@@ -1656,7 +1657,7 @@ class VaultScreenTest : BitwardenComposeTest() {
@Test
fun `UpgradePremium action card CTA click should send ActionCardClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
isPremiumUpgradeBannerEligible = true,
premiumCard = PremiumCard.UPGRADE,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
@@ -1677,7 +1678,7 @@ class VaultScreenTest : BitwardenComposeTest() {
@Test
fun `UpgradePremium action card dismiss click should send DismissActionCardClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
isPremiumUpgradeBannerEligible = true,
premiumCard = PremiumCard.UPGRADE,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
@@ -1693,6 +1694,42 @@ class VaultScreenTest : BitwardenComposeTest() {
}
}
@Test
fun `PremiumNeedsAttention action card should display when eligible`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
premiumCard = PremiumCard.NEEDS_ATTENTION,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
composeTestRule
.onNodeWithText(text = "Your subscription needs attention")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "View plan")
.assertIsDisplayed()
}
@Test
fun `PremiumNeedsAttention action card CTA click should send ActionCardClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
premiumCard = PremiumCard.NEEDS_ATTENTION,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
composeTestRule
.onNodeWithText(text = "View plan")
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultAction.ActionCardClick(
actionCard = VaultState.ActionCardState.PremiumNeedsAttention,
),
)
}
}
@Test
fun `UpgradedToPremium action card should display when eligible`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
@@ -31,6 +31,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.createMockOrganization
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
@@ -237,11 +238,12 @@ class VaultViewModelTest : BaseViewModelTest() {
every { getFeatureFlagFlow(FlagKey.NewItemTypes) } returns mutableNewItemTypesFlagFlow
}
private val mutablePremiumUpgradeBannerEligibleFlow = MutableStateFlow(false)
private val mutablePremiumUpgradeBannerEligibleFlow =
MutableStateFlow(PremiumCard.NONE)
private val mutableUpgradedToPremiumCardEligibleFlow = MutableStateFlow(false)
private val premiumStateManager: PremiumStateManager = mockk {
every {
isPremiumUpgradeBannerEligibleFlow
premiumCardStateFlow
} returns mutablePremiumUpgradeBannerEligibleFlow
every {
isUpgradedToPremiumCardEligibleFlow
@@ -407,14 +409,14 @@ class VaultViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutablePremiumUpgradeBannerEligibleFlow.value = true
mutablePremiumUpgradeBannerEligibleFlow.value = PremiumCard.UPGRADE
assertEquals(
DEFAULT_STATE.copy(
isPremiumUpgradeBannerEligible = true,
premiumCard = PremiumCard.UPGRADE,
),
awaitItem(),
)
mutablePremiumUpgradeBannerEligibleFlow.value = false
mutablePremiumUpgradeBannerEligibleFlow.value = PremiumCard.NONE
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@@ -453,11 +455,48 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ActionCardClick with PremiumNeedsAttention should emit NavigateToUpgradePremium`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
VaultAction.ActionCardClick(
VaultState.ActionCardState.PremiumNeedsAttention,
),
)
assertEquals(
VaultEvent.NavigateToUpgradePremium,
awaitItem(),
)
}
}
@Test
fun `DismissActionCardClick with PremiumNeedsAttention should do nothing`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
VaultAction.DismissActionCardClick(
VaultState.ActionCardState.PremiumNeedsAttention,
),
)
expectNoEvents()
}
verify(exactly = 0) {
premiumStateManager.dismissPremiumUpgradeBanner()
premiumStateManager.dismissUpgradedToPremiumCard()
}
}
@Test
fun `actionCard should return UpgradePremium when eligible and content is showing`() {
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
val state = createMockVaultState(viewState = contentViewState).copy(
isPremiumUpgradeBannerEligible = true,
premiumCard = PremiumCard.UPGRADE,
)
assertEquals(
@@ -471,7 +510,7 @@ class VaultViewModelTest : BaseViewModelTest() {
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
val state = createMockVaultState(viewState = contentViewState).copy(
isUpgradedToPremiumCardEligible = true,
isPremiumUpgradeBannerEligible = true,
premiumCard = PremiumCard.UPGRADE,
isPremium = true,
isIntroducingArchiveActionCardDismissed = false,
)
@@ -525,7 +564,7 @@ class VaultViewModelTest : BaseViewModelTest() {
fun `actionCard should return IntroducingArchive when not eligible for Premium upgrade`() {
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
val state = createMockVaultState(viewState = contentViewState).copy(
isPremiumUpgradeBannerEligible = false,
premiumCard = PremiumCard.NONE,
isPremium = true,
isIntroducingArchiveActionCardDismissed = false,
)
@@ -540,7 +579,7 @@ class VaultViewModelTest : BaseViewModelTest() {
fun `actionCard should return null when not eligible for either card`() {
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
val state = createMockVaultState(viewState = contentViewState).copy(
isPremiumUpgradeBannerEligible = false,
premiumCard = PremiumCard.NONE,
isPremium = false,
isIntroducingArchiveActionCardDismissed = false,
)
@@ -4669,7 +4708,7 @@ private fun createMockVaultState(
hasShownDecryptionFailureAlert = false,
restrictItemTypesPolicyOrgIds = emptyList(),
isIntroducingArchiveActionCardDismissed = false,
isPremiumUpgradeBannerEligible = false,
premiumCard = PremiumCard.NONE,
validTotpIds = validTotpIds.toImmutableSet(),
)
+3
View File
@@ -1214,6 +1214,9 @@ Do you want to switch to this account?</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="your_subscription_needs_attention">Your subscription needs attention</string>
<string name="check_your_plan_for_details">Check your plan for details.</string>
<string name="view_plan">View plan</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>
<string name="introducing_archive">Introducing archive</string>