Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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>
Comment thread
david-livefront marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ”₯


/**
* Emits `true` while the active user is eligible to see the "Upgraded to Premium" action
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ‘

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> =
Expand Down Expand Up @@ -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.
*/
Comment thread
david-livefront marked this conversation as resolved.
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`.
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ fun VaultContent(
}
}

@Suppress("LongMethod")
@Composable
private fun ActionCard(
actionCardState: VaultState.ActionCardState,
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>(
Expand Down Expand Up @@ -268,10 +269,10 @@ class VaultViewModel @Inject constructor(
.launchIn(viewModelScope)

premiumStateManager
.isPremiumUpgradeBannerEligibleFlow
.premiumCardStateFlow
.map {
VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive(
isEligible = it,
premiumCard = it,
)
}
.onEach(::sendAction)
Expand Down Expand Up @@ -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()
}
Expand All @@ -466,6 +471,10 @@ class VaultViewModel @Inject constructor(
sendEvent(VaultEvent.NavigateToUpgradePremium)
}

VaultState.ActionCardState.PremiumNeedsAttention -> {
sendEvent(VaultEvent.NavigateToUpgradePremium)
}

VaultState.ActionCardState.IntroducingArchive -> {
settingsRepository.dismissIntroducingArchiveActionCard()
sendEvent(
Expand Down Expand Up @@ -1273,7 +1282,7 @@ class VaultViewModel @Inject constructor(
action: VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive,
) {
mutableStateFlow.update {
it.copy(isPremiumUpgradeBannerEligible = action.isEligible)
it.copy(premiumCard = action.premiumCard)
}
}

Expand Down Expand Up @@ -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>,
Expand All @@ -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
}
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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()

/**
Expand Down
Loading
Loading