diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManager.kt index 11b96a23d58..3dde24bf360 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManager.kt @@ -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 + val premiumCardStateFlow: StateFlow /** * Emits `true` while the active user is eligible to see the "Upgraded to Premium" action diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt index f49a40679cb..ad03fa5adcd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt @@ -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 = + override val premiumCardStateFlow: StateFlow = 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 = @@ -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, ) /** - * 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 - - PremiumSubscriptionStatus.ACTIVE, - PremiumSubscriptionStatus.PENDING_CANCELLATION, - -> false +private fun SubscriptionStatusState.premiumCardState(): PremiumCard = + when (this) { + is SubscriptionStatusState.Available -> { + when (this.status) { + PremiumSubscriptionStatus.PAST_DUE, + PremiumSubscriptionStatus.UPDATE_PAYMENT, + -> PremiumCard.NEEDS_ATTENTION + + 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`. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/model/PremiumCard.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/model/PremiumCard.kt new file mode 100644 index 00000000000..98c361c0b19 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/model/PremiumCard.kt @@ -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, +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index 17dc8afc797..09b726a5f5c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -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), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 7278dca70b8..5b5132d95e3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -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, ) : BaseViewModel( @@ -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, val isIntroducingArchiveActionCardDismissed: Boolean, - val isPremiumUpgradeBannerEligible: Boolean = false, + val premiumCard: PremiumCard = PremiumCard.NONE, val isUpgradedToPremiumCardEligible: Boolean = false, val isAwaitingKdfSync: Boolean = false, val validTotpIds: ImmutableSet, @@ -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() /** diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt index d9f57b0ad4e..ccc1b64476c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt @@ -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()) } } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 426505d20c6..94e97b79216 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 2b30adc14c8..0ac61a2ede7 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -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(), ) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index dc9012aa4f0..740066c9f66 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1214,6 +1214,9 @@ Do you want to switch to this account? Plan To manage your Premium subscription, you’ll need to login to your web vault on a computer. Unlock advanced security features + Your subscription needs attention + Check your plan for details. + View plan A Premium plan gives you more tools to stay secure and in control. This item is archived. Introducing archive