Skip to content

Commit 59b9cbf

Browse files
PM-38356 PM-37177: Bug: Update premium subscription trouble-states (#7073)
1 parent 5b8dd94 commit 59b9cbf

9 files changed

Lines changed: 363 additions & 149 deletions

File tree

app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManager.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.x8bit.bitwarden.data.billing.manager
22

3+
import com.x8bit.bitwarden.data.billing.model.PremiumCard
34
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
45
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
56
import kotlinx.coroutines.flow.StateFlow
@@ -16,10 +17,9 @@ const val UPGRADED_TO_PREMIUM_LEARN_MORE_URL: String =
1617
interface PremiumStateManager {
1718

1819
/**
19-
* Emits `true` when the current user is eligible to see the Premium upgrade banner,
20-
* or `false` otherwise.
20+
* Emits a [PremiumCard] for the current user indicating what Premium card should be displayed.
2121
*/
22-
val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean>
22+
val premiumCardStateFlow: StateFlow<PremiumCard>
2323

2424
/**
2525
* Emits `true` while the active user is eligible to see the "Upgraded to Premium" action

app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt

Lines changed: 70 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import com.bitwarden.core.data.manager.model.FlagKey
55
import com.bitwarden.core.data.repository.model.DataState
66
import com.bitwarden.data.repository.model.Environment
77
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
8+
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
89
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
910
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
11+
import com.x8bit.bitwarden.data.billing.model.PremiumCard
1012
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
1113
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
1214
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
@@ -50,7 +52,7 @@ class PremiumStateManagerImpl(
5052
private val settingsDiskSource: SettingsDiskSource,
5153
vaultRepository: VaultRepository,
5254
private val featureFlagManager: FeatureFlagManager,
53-
private val environmentRepository: EnvironmentRepository,
55+
environmentRepository: EnvironmentRepository,
5456
pushManager: PushManager,
5557
private val clock: Clock,
5658
dispatcherManager: DispatcherManager,
@@ -123,62 +125,69 @@ class PremiumStateManagerImpl(
123125
)
124126

125127
@OptIn(ExperimentalCoroutinesApi::class)
126-
override val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean> =
128+
override val premiumCardStateFlow: StateFlow<PremiumCard> =
127129
combine(
128-
authDiskSource.userStateFlow,
130+
authDiskSource.userStateFlow.map { it?.activeAccount },
129131
billingRepository.isInAppBillingSupportedFlow,
130132
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
131-
authDiskSource.activeUserIdChangesFlow
132-
.flatMapLatest { userId ->
133-
userId
134-
?.let { id ->
135-
settingsDiskSource
136-
.getPremiumUpgradeBannerDismissedFlow(id)
137-
.map { it ?: false }
138-
}
139-
?: flowOf(false)
140-
},
133+
authDiskSource.activeUserIdChangesFlow.flatMapLatest { userId ->
134+
userId
135+
?.let { id ->
136+
settingsDiskSource
137+
.getPremiumUpgradeBannerDismissedFlow(id)
138+
.map { it ?: false }
139+
}
140+
?: flowOf(false)
141+
},
141142
vaultRepository.vaultDataStateFlow,
142143
) {
143-
userState,
144+
account,
144145
isInAppBillingSupported,
145146
featureFlagEnabled,
146-
isDismissed,
147+
isUpgradeCardDismissed,
147148
vaultDataState,
148149
->
149150
BannerInputs(
150-
userState = userState,
151+
account = account,
151152
isInAppBillingSupported = isInAppBillingSupported,
152153
featureFlagEnabled = featureFlagEnabled,
153-
isDismissed = isDismissed,
154+
isUpgradeCardDismissed = isUpgradeCardDismissed,
154155
vaultDataState = vaultDataState,
155156
)
156157
}
157158
.combine(upgradeLifecycleStateFlow) { inputs, lifecycle ->
158-
val profile = inputs.userState?.activeAccount?.profile
159-
?: return@combine false
160-
val isAccountOldEnough = profile.creationDate.isOlderThanDays(
161-
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
162-
clock = clock,
163-
)
164-
val itemCount = inputs.vaultDataState.activeVaultItemCount()
165-
val lifecycleAllowsBanner = lifecycle is UpgradeLifecycleState.Free ||
166-
(
167-
lifecycle is UpgradeLifecycleState.Premium &&
168-
lifecycle.subscriptionStatus.isInTroubleState()
159+
val profile = inputs.account?.profile ?: return@combine PremiumCard.NONE
160+
if (!inputs.featureFlagEnabled) return@combine PremiumCard.NONE
161+
val initialCard = when (lifecycle) {
162+
UpgradeLifecycleState.Free -> PremiumCard.UPGRADE
163+
UpgradeLifecycleState.UpgradePending -> PremiumCard.NONE
164+
is UpgradeLifecycleState.Premium -> {
165+
lifecycle.subscriptionStatus.premiumCardState()
166+
}
167+
}
168+
when (initialCard) {
169+
PremiumCard.UPGRADE -> {
170+
val isAccountOldEnough = profile.creationDate.isOlderThanDays(
171+
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
172+
clock = clock,
169173
)
174+
val itemCount = inputs.vaultDataState.activeVaultItemCount()
175+
val showCard = inputs.isInAppBillingSupported &&
176+
isAccountOldEnough &&
177+
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS &&
178+
!inputs.isUpgradeCardDismissed
179+
initialCard.takeIf { showCard } ?: PremiumCard.NONE
180+
}
170181

171-
lifecycleAllowsBanner &&
172-
inputs.isInAppBillingSupported &&
173-
inputs.featureFlagEnabled &&
174-
!inputs.isDismissed &&
175-
isAccountOldEnough &&
176-
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS
182+
PremiumCard.NEEDS_ATTENTION,
183+
PremiumCard.NONE,
184+
-> initialCard
185+
}
177186
}
178187
.stateIn(
179188
scope = unconfinedScope,
180189
started = SharingStarted.Eagerly,
181-
initialValue = false,
190+
initialValue = PremiumCard.NONE,
182191
)
183192

184193
override val isSelfHostedFlow: StateFlow<Boolean> =
@@ -389,32 +398,41 @@ class PremiumStateManagerImpl(
389398
}
390399

391400
private data class BannerInputs(
392-
val userState: UserStateJson?,
401+
val account: AccountJson?,
393402
val isInAppBillingSupported: Boolean,
394403
val featureFlagEnabled: Boolean,
395-
val isDismissed: Boolean,
404+
val isUpgradeCardDismissed: Boolean,
396405
val vaultDataState: DataState<VaultData>,
397406
)
398407

399408
/**
400-
* Returns `true` when the given [SubscriptionStatusState] represents a subscription substate
401-
* that should disqualify a user from being treated as effectively premium.
409+
* Returns a [PremiumCard] for the given [SubscriptionStatusState] and subscription substate.
402410
*/
403-
private fun SubscriptionStatusState.isInTroubleState(): Boolean =
404-
this is SubscriptionStatusState.Available &&
405-
when (this.status) {
406-
PremiumSubscriptionStatus.CANCELED,
407-
PremiumSubscriptionStatus.EXPIRED,
408-
PremiumSubscriptionStatus.PAST_DUE,
409-
PremiumSubscriptionStatus.PAUSED,
410-
PremiumSubscriptionStatus.UPDATE_PAYMENT,
411-
-> true
412-
413-
PremiumSubscriptionStatus.ACTIVE,
414-
PremiumSubscriptionStatus.PENDING_CANCELLATION,
415-
-> false
411+
private fun SubscriptionStatusState.premiumCardState(): PremiumCard =
412+
when (this) {
413+
is SubscriptionStatusState.Available -> {
414+
when (this.status) {
415+
PremiumSubscriptionStatus.PAST_DUE,
416+
PremiumSubscriptionStatus.UPDATE_PAYMENT,
417+
-> PremiumCard.NEEDS_ATTENTION
418+
419+
PremiumSubscriptionStatus.EXPIRED,
420+
PremiumSubscriptionStatus.PAUSED,
421+
-> PremiumCard.UPGRADE
422+
423+
PremiumSubscriptionStatus.ACTIVE,
424+
PremiumSubscriptionStatus.CANCELED,
425+
PremiumSubscriptionStatus.PENDING_CANCELLATION,
426+
-> PremiumCard.NONE
427+
}
416428
}
417429

430+
is SubscriptionStatusState.Error,
431+
SubscriptionStatusState.Loading,
432+
SubscriptionStatusState.NoSubscription,
433+
-> PremiumCard.NONE
434+
}
435+
418436
/**
419437
* Returns `true` if this [Instant] is older than the given number of [days] based on
420438
* the provided [clock]. Returns `false` if the receiver is `null`.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.x8bit.bitwarden.data.billing.model
2+
3+
/**
4+
* Represents which premium card should be displayed.
5+
*/
6+
enum class PremiumCard {
7+
UPGRADE,
8+
NEEDS_ATTENTION,
9+
NONE,
10+
}

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,7 @@ fun VaultContent(
562562
}
563563
}
564564

565+
@Suppress("LongMethod")
565566
@Composable
566567
private fun ActionCard(
567568
actionCardState: VaultState.ActionCardState,
@@ -606,6 +607,16 @@ private fun ActionCard(
606607
)
607608
}
608609

610+
VaultState.ActionCardState.PremiumNeedsAttention -> {
611+
BitwardenActionCard(
612+
cardTitle = stringResource(id = BitwardenString.your_subscription_needs_attention),
613+
cardSubtitle = stringResource(id = BitwardenString.check_your_plan_for_details),
614+
actionText = stringResource(id = BitwardenString.view_plan),
615+
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
616+
modifier = modifier,
617+
)
618+
}
619+
609620
VaultState.ActionCardState.IntroducingArchive -> {
610621
BitwardenActionCard(
611622
cardTitle = stringResource(id = BitwardenString.introducing_archive),

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
3636
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
3737
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
3838
import com.x8bit.bitwarden.data.billing.manager.UPGRADED_TO_PREMIUM_LEARN_MORE_URL
39+
import com.x8bit.bitwarden.data.billing.model.PremiumCard
3940
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
4041
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
4142
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
@@ -120,7 +121,7 @@ class VaultViewModel @Inject constructor(
120121
private val networkConnectionManager: NetworkConnectionManager,
121122
private val browserAutofillDialogManager: BrowserAutofillDialogManager,
122123
private val credentialExchangeRegistryManager: CredentialExchangeRegistryManager,
123-
private val buildInfoManager: BuildInfoManager,
124+
buildInfoManager: BuildInfoManager,
124125
featureFlagManager: FeatureFlagManager,
125126
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
126127
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
@@ -268,10 +269,10 @@ class VaultViewModel @Inject constructor(
268269
.launchIn(viewModelScope)
269270

270271
premiumStateManager
271-
.isPremiumUpgradeBannerEligibleFlow
272+
.premiumCardStateFlow
272273
.map {
273274
VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive(
274-
isEligible = it,
275+
premiumCard = it,
275276
)
276277
}
277278
.onEach(::sendAction)
@@ -449,6 +450,10 @@ class VaultViewModel @Inject constructor(
449450
premiumStateManager.dismissPremiumUpgradeBanner()
450451
}
451452

453+
VaultState.ActionCardState.PremiumNeedsAttention -> {
454+
// No-op: The user must address the issue
455+
}
456+
452457
VaultState.ActionCardState.IntroducingArchive -> {
453458
settingsRepository.dismissIntroducingArchiveActionCard()
454459
}
@@ -466,6 +471,10 @@ class VaultViewModel @Inject constructor(
466471
sendEvent(VaultEvent.NavigateToUpgradePremium)
467472
}
468473

474+
VaultState.ActionCardState.PremiumNeedsAttention -> {
475+
sendEvent(VaultEvent.NavigateToUpgradePremium)
476+
}
477+
469478
VaultState.ActionCardState.IntroducingArchive -> {
470479
settingsRepository.dismissIntroducingArchiveActionCard()
471480
sendEvent(
@@ -1273,7 +1282,7 @@ class VaultViewModel @Inject constructor(
12731282
action: VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive,
12741283
) {
12751284
mutableStateFlow.update {
1276-
it.copy(isPremiumUpgradeBannerEligible = action.isEligible)
1285+
it.copy(premiumCard = action.premiumCard)
12771286
}
12781287
}
12791288

@@ -1747,7 +1756,7 @@ data class VaultState(
17471756
val hasShownDecryptionFailureAlert: Boolean,
17481757
val restrictItemTypesPolicyOrgIds: List<String>,
17491758
val isIntroducingArchiveActionCardDismissed: Boolean,
1750-
val isPremiumUpgradeBannerEligible: Boolean = false,
1759+
val premiumCard: PremiumCard = PremiumCard.NONE,
17511760
val isUpgradedToPremiumCardEligible: Boolean = false,
17521761
val isAwaitingKdfSync: Boolean = false,
17531762
val validTotpIds: ImmutableSet<String>,
@@ -1761,8 +1770,10 @@ data class VaultState(
17611770
get() = (viewState as? ViewState.Content)?.let {
17621771
ActionCardState.UpgradedToPremium
17631772
.takeIf { isUpgradedToPremiumCardEligible }
1764-
?: ActionCardState.UpgradePremium
1765-
.takeIf { isPremiumUpgradeBannerEligible }
1773+
?: ActionCardState.UpgradePremium.takeIf { premiumCard == PremiumCard.UPGRADE }
1774+
?: ActionCardState.PremiumNeedsAttention.takeIf {
1775+
premiumCard == PremiumCard.NEEDS_ATTENTION
1776+
}
17661777
?: ActionCardState.IntroducingArchive.takeIf {
17671778
isPremium && !isIntroducingArchiveActionCardDismissed
17681779
}
@@ -2169,6 +2180,11 @@ data class VaultState(
21692180
*/
21702181
data object UpgradePremium : ActionCardState()
21712182

2183+
/**
2184+
* Indicates that the user needs to address an issue with their Premium account.
2185+
*/
2186+
data object PremiumNeedsAttention : ActionCardState()
2187+
21722188
/**
21732189
* Indicates that the archive feature is ready for use.
21742190
*/
@@ -2747,11 +2763,10 @@ sealed class VaultAction {
27472763
) : Internal()
27482764

27492765
/**
2750-
* Indicates that the Premium upgrade banner eligibility has been
2751-
* updated.
2766+
* Indicates that the Premium upgrade banner eligibility has been updated.
27522767
*/
27532768
data class PremiumUpgradeBannerEligibilityReceive(
2754-
val isEligible: Boolean,
2769+
val premiumCard: PremiumCard,
27552770
) : Internal()
27562771

27572772
/**

0 commit comments

Comments
 (0)