[PM-37232] fix: Hide upgrade CTAs while a Premium upgrade is pending (#6978)

This commit is contained in:
Patrick Honkonen
2026-05-27 16:53:06 -04:00
committed by GitHub
parent 58408bcd77
commit cc6fcecc5b
12 changed files with 554 additions and 37 deletions

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.billing.manager
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
import kotlinx.coroutines.flow.StateFlow
/**
@@ -38,6 +39,11 @@ interface PremiumStateManager {
*/
val subscriptionStatusStateFlow: StateFlow<SubscriptionStatusState>
/**
* Emits the active user's current [UpgradeLifecycleState].
*/
val upgradeLifecycleStateFlow: StateFlow<UpgradeLifecycleState>
/**
* Emits whether the current state should be treated as self-hosted for premium upgrade
* gating. Reactive equivalent of [isSelfHosted].
@@ -66,4 +72,10 @@ interface PremiumStateManager {
* never re-appears for that user.
*/
fun dismissUpgradedToPremiumCard()
/**
* Marks the active user as having a Premium upgrade in flight (Stripe checkout completed
* but the server has not yet flipped `isPremium`).
*/
fun markPremiumUpgradePending(userId: String)
}

View File

@@ -11,6 +11,7 @@ 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
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
@@ -85,6 +86,42 @@ class PremiumStateManagerImpl(
initialValue = SubscriptionStatusState.Loading,
)
@OptIn(ExperimentalCoroutinesApi::class)
override val upgradeLifecycleStateFlow: StateFlow<UpgradeLifecycleState> =
combine(
authDiskSource.userStateFlow,
subscriptionStatusStateFlow,
authDiskSource.activeUserIdChangesFlow
.flatMapLatest { userId ->
userId
?.let { id ->
settingsDiskSource
.getPremiumUpgradePendingFlow(id)
.map { it ?: false }
}
?: flowOf(false)
},
) { userState, subscriptionStatus, isPending ->
deriveLifecycleState(
userState = userState,
subscriptionStatus = subscriptionStatus,
isPending = isPending,
)
}
.distinctUntilChanged()
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = deriveLifecycleState(
userState = authDiskSource.userState,
subscriptionStatus = subscriptionStatusStateFlow.value,
isPending = authDiskSource.userState
?.activeUserId
?.let { settingsDiskSource.getPremiumUpgradePending(userId = it) }
?: false,
),
)
@OptIn(ExperimentalCoroutinesApi::class)
override val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean> =
combine(
@@ -117,7 +154,7 @@ class PremiumStateManagerImpl(
vaultDataState = vaultDataState,
)
}
.combine(subscriptionStatusStateFlow) { inputs, subscriptionStatus ->
.combine(upgradeLifecycleStateFlow) { inputs, lifecycle ->
val profile = inputs.userState?.activeAccount?.profile
?: return@combine false
val isAccountOldEnough = profile.creationDate.isOlderThanDays(
@@ -125,12 +162,13 @@ class PremiumStateManagerImpl(
clock = clock,
)
val itemCount = inputs.vaultDataState.activeVaultItemCount()
val hasPremium = profile.hasPremiumPersonally == true ||
profile.hasPremiumFromOrganization == true
val isEffectivelyPremium = hasPremium &&
!subscriptionStatus.isInTroubleState()
val lifecycleAllowsBanner = lifecycle is UpgradeLifecycleState.Free ||
(
lifecycle is UpgradeLifecycleState.Premium &&
lifecycle.subscriptionStatus.isInTroubleState()
)
!isEffectivelyPremium &&
lifecycleAllowsBanner &&
inputs.isInAppBillingSupported &&
inputs.featureFlagEnabled &&
!inputs.isDismissed &&
@@ -254,6 +292,7 @@ class PremiumStateManagerImpl(
// an upgrade.
if (previous?.first == currentUserId && !previous.second) {
markUpgradedToPremiumCardPending(userId = currentUserId)
clearPremiumUpgradePending(userId = currentUserId)
}
}
.launchIn(unconfinedScope)
@@ -285,6 +324,35 @@ class PremiumStateManagerImpl(
)
}
override fun markPremiumUpgradePending(userId: String) {
settingsDiskSource.storePremiumUpgradePending(
userId = userId,
isPending = true,
)
}
private fun clearPremiumUpgradePending(userId: String) {
settingsDiskSource.storePremiumUpgradePending(
userId = userId,
isPending = null,
)
}
private fun deriveLifecycleState(
userState: UserStateJson?,
subscriptionStatus: SubscriptionStatusState,
isPending: Boolean,
): UpgradeLifecycleState {
val profile = userState?.activeAccount?.profile ?: return UpgradeLifecycleState.Free
val hasPremium = profile.hasPremiumPersonally == true ||
profile.hasPremiumFromOrganization == true
return when {
hasPremium -> UpgradeLifecycleState.Premium(subscriptionStatus = subscriptionStatus)
isPending -> UpgradeLifecycleState.UpgradePending
else -> UpgradeLifecycleState.Free
}
}
private fun markUpgradedToPremiumCardPending(userId: String) {
// Don't re-arm the card if the user has already consumed it for this account.
if (settingsDiskSource.getUpgradedToPremiumCardConsumed(userId = userId) == true) {

View File

@@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.billing.repository.model
/**
* Represents the active user's position in the Premium upgrade lifecycle.
*
* Transitions:
* - [Free] → [UpgradePending] when the user completes Stripe checkout and the post-checkout
* sync still reports the user as non-premium — checkout is done, backend reconciliation
* is in flight.
* - [UpgradePending] → [Premium] when the server flips `isPremium` to `true`.
*
* Cancellation, expiration, and other terminal substates are surfaced via
* [Premium.subscriptionStatus] rather than as separate leaves.
*/
sealed class UpgradeLifecycleState {
/**
* The user has no Premium subscription and no upgrade is in flight.
*/
data object Free : UpgradeLifecycleState()
/**
* Stripe checkout completed but the server has not yet flipped `isPremium`.
*/
data object UpgradePending : UpgradeLifecycleState()
/**
* The user holds Premium; [subscriptionStatus] carries the substate (active, canceled, etc).
*/
data class Premium(
val subscriptionStatus: SubscriptionStatusState,
) : UpgradeLifecycleState()
}

View File

@@ -182,6 +182,25 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
fun getUpgradedToPremiumCardPendingFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the stored value of whether a Premium upgrade is awaiting server confirmation
* for the given [userId].
*/
fun getPremiumUpgradePending(userId: String): Boolean?
/**
* Stores whether a Premium upgrade is awaiting server confirmation for the given [userId].
*/
fun storePremiumUpgradePending(
userId: String,
isPending: Boolean?,
)
/**
* Emits updates that track [getPremiumUpgradePending] for the given [userId].
*/
fun getPremiumUpgradePendingFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the biometric integrity validity for the given [userId] and
* [systemBioIntegrityState].

View File

@@ -57,11 +57,13 @@ private const val UPGRADED_TO_PREMIUM_CARD_CONSUMED =
"upgradedToPremiumCardConsumed"
private const val UPGRADED_TO_PREMIUM_CARD_PENDING =
"upgradedToPremiumCardPending"
private const val PREMIUM_UPGRADE_PENDING =
"premiumUpgradePending"
/**
* Primary implementation of [SettingsDiskSource].
*/
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LargeClass")
class SettingsDiskSourceImpl(
private val sharedPreferences: SharedPreferences,
private val json: Json,
@@ -107,6 +109,9 @@ class SettingsDiskSourceImpl(
private val mutableUpgradedToPremiumCardPendingFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutablePremiumUpgradePendingFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
@@ -264,6 +269,7 @@ class SettingsDiskSourceImpl(
// - Premium upgrade banner dismissed
// - Upgraded to Premium action card consumed
// - Upgraded to Premium action card pending
// - Premium upgrade pending
}
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
@@ -346,6 +352,26 @@ class SettingsDiskSourceImpl(
getMutableUpgradedToPremiumCardPendingFlow(userId = userId)
.onSubscription { emit(getUpgradedToPremiumCardPending(userId = userId)) }
override fun getPremiumUpgradePending(userId: String): Boolean? =
getBoolean(
key = PREMIUM_UPGRADE_PENDING.appendIdentifier(identifier = userId),
)
override fun storePremiumUpgradePending(
userId: String,
isPending: Boolean?,
) {
putBoolean(
key = PREMIUM_UPGRADE_PENDING.appendIdentifier(identifier = userId),
value = isPending,
)
getMutablePremiumUpgradePendingFlow(userId = userId).tryEmit(isPending)
}
override fun getPremiumUpgradePendingFlow(userId: String): Flow<Boolean?> =
getMutablePremiumUpgradePendingFlow(userId = userId)
.onSubscription { emit(getPremiumUpgradePending(userId = userId)) }
override fun getAccountBiometricIntegrityValidity(
userId: String,
systemBioIntegrityState: String,
@@ -711,6 +737,13 @@ class SettingsDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePremiumUpgradePendingFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutablePremiumUpgradePendingFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =

View File

@@ -295,33 +295,14 @@ private fun FreeCloudContent(
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.upgrade_now),
onClick = { shouldShowUpgradeDialog = true },
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.testTag("UpgradeNowButton"),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(
id = BitwardenString
.youll_go_to_stripes_secure_checkout_to_complete_your_purchase,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.testTag("StripeFooterText"),
)
Spacer(modifier = Modifier.height(16.dp))
// Hide the Upgrade Now CTA (and its Stripe footer copy) while a Stripe upgrade is
// already in flight for the active user. CTAs reappear once the server flips the
// user to Premium.
if (!viewState.isPremiumUpgradePending) {
UpgradeNowCallToAction(
onUpgradeNowClick = { shouldShowUpgradeDialog = true },
)
}
Spacer(modifier = Modifier.navigationBarsPadding())
}
@@ -344,6 +325,38 @@ private fun FreeCloudContent(
}
}
@Composable
private fun UpgradeNowCallToAction(
onUpgradeNowClick: () -> Unit,
) {
BitwardenFilledButton(
label = stringResource(id = BitwardenString.upgrade_now),
onClick = onUpgradeNowClick,
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.testTag("UpgradeNowButton"),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(
id = BitwardenString.youll_go_to_stripes_secure_checkout_to_complete_your_purchase,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.testTag("StripeFooterText"),
)
Spacer(modifier = Modifier.height(16.dp))
}
@Suppress("MaxLineLength")
@Composable
private fun FreeSelfHostedContent(
@@ -811,6 +824,7 @@ private fun PlanScreenFreeCloudAccount_preview() {
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
handlers = PlanHandlers(
onBackClick = {},

View File

@@ -25,6 +25,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStat
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@@ -91,6 +92,9 @@ class PlanViewModel @Inject constructor(
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = premiumStateManager
.upgradeLifecycleStateFlow
.value is UpgradeLifecycleState.UpgradePending,
)
},
dialogState = null,
@@ -124,6 +128,12 @@ class PlanViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
premiumStateManager
.upgradeLifecycleStateFlow
.map { PlanAction.Internal.UpgradeLifecycleStateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
onFreeCloudContent {
viewModelScope.launch {
sendAction(
@@ -187,6 +197,10 @@ class PlanViewModel @Inject constructor(
is PlanAction.Internal.SubscriptionStatusUpdateReceive -> {
handleSubscriptionStatusUpdateReceive(action)
}
is PlanAction.Internal.UpgradeLifecycleStateReceive -> {
handleUpgradeLifecycleStateReceive(action)
}
}
}
@@ -403,6 +417,9 @@ class PlanViewModel @Inject constructor(
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = premiumStateManager
.upgradeLifecycleStateFlow
.value is UpgradeLifecycleState.UpgradePending,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading.asText(),
@@ -433,6 +450,22 @@ class PlanViewModel @Inject constructor(
}
}
private fun handleUpgradeLifecycleStateReceive(
action: PlanAction.Internal.UpgradeLifecycleStateReceive,
) {
val isPending = action.state is UpgradeLifecycleState.UpgradePending
onFreeCloudContent { freeState ->
if (freeState.isPremiumUpgradePending == isPending) return@onFreeCloudContent
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
isPremiumUpgradePending = isPending,
),
)
}
}
}
private fun handleSubscriptionStatusUpdateReceive(
action: PlanAction.Internal.SubscriptionStatusUpdateReceive,
) {
@@ -561,14 +594,19 @@ class PlanViewModel @Inject constructor(
onFreeCloudContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
val isPremium = authRepository
val activeAccount = authRepository
.userStateFlow
.value
?.activeAccount
?.isPremium == true
val isPremium = activeAccount?.isPremium == true
if (isPremium) {
onPremiumUpgradeSuccess()
} else {
// Persist the pending-upgrade signal so the Vault banner and the Plan-screen
// Upgrade Now CTA can suppress themselves while the server catches up.
activeAccount?.userId?.let { userId ->
premiumStateManager.markPremiumUpgradePending(userId = userId)
}
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.PendingUpgrade,
@@ -802,6 +840,7 @@ data class PlanState(
val rate: String,
val checkoutUrl: String?,
val isAwaitingPremiumStatus: Boolean,
val isPremiumUpgradePending: Boolean,
) : Free()
/**
@@ -1119,6 +1158,13 @@ sealed class PlanAction {
data class SubscriptionStatusUpdateReceive(
val state: SubscriptionStatusState,
) : Internal()
/**
* The shared [UpgradeLifecycleState] for the active user has updated.
*/
data class UpgradeLifecycleStateReceive(
val state: UpgradeLifecycleState,
) : Internal()
}
}

View File

@@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStat
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
@@ -724,6 +725,125 @@ class PremiumStateManagerTest {
)
}
@Test
fun `upgradeLifecycleStateFlow emits Free when nothing has been observed`() = runTest {
val manager = createManager()
manager.upgradeLifecycleStateFlow.test {
assertEquals(UpgradeLifecycleState.Free, awaitItem())
}
}
@Test
fun `upgradeLifecycleStateFlow emits Free when there is no active user`() = runTest {
fakeAuthDiskSource.userState = null
val manager = createManager()
manager.upgradeLifecycleStateFlow.test {
assertEquals(UpgradeLifecycleState.Free, awaitItem())
}
}
@Test
fun `markPremiumUpgradePending transitions upgradeLifecycleStateFlow to UpgradePending`() =
runTest {
val manager = createManager()
manager.upgradeLifecycleStateFlow.test {
assertEquals(UpgradeLifecycleState.Free, awaitItem())
manager.markPremiumUpgradePending(userId = ACTIVE_USER_ID)
assertEquals(UpgradeLifecycleState.UpgradePending, awaitItem())
fakeSettingsDiskSource.assertPremiumUpgradePending(
userId = ACTIVE_USER_ID,
expected = true,
)
}
}
@Test
fun `upgradeLifecycleStateFlow emits Premium when the active user holds personal Premium`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
val manager = createManager()
manager.upgradeLifecycleStateFlow.test {
assertEquals(
UpgradeLifecycleState.Premium(
subscriptionStatus = SubscriptionStatusState.NoSubscription,
),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `upgradeLifecycleStateFlow re-keys on active user change and only reflects the active user's flag`() =
runTest {
val otherUserId = "otherUserId"
fakeSettingsDiskSource.storePremiumUpgradePending(
userId = ACTIVE_USER_ID,
isPending = true,
)
val manager = createManager()
manager.upgradeLifecycleStateFlow.test {
assertEquals(UpgradeLifecycleState.UpgradePending, awaitItem())
// Switching active user — the other user has no pending flag set.
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(userId = otherUserId),
)
assertEquals(UpgradeLifecycleState.Free, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `userState transition from non-Premium to personal Premium transitions upgradeLifecycleStateFlow to Premium and clears the pending flag`() =
runTest {
// Free user with a pending upgrade in flight.
fakeAuthDiskSource.userState = userStateJsonWith(account = createAccountJson())
fakeSettingsDiskSource.storePremiumUpgradePending(
userId = ACTIVE_USER_ID,
isPending = true,
)
val manager = createManager()
manager.upgradeLifecycleStateFlow.test {
assertEquals(UpgradeLifecycleState.UpgradePending, awaitItem())
// Server flips personal Premium on — lifecycle transitions to Premium, the
// disk-backed pending flag auto-clears via the manager's init block.
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
assertEquals(
UpgradeLifecycleState.Premium(
subscriptionStatus = SubscriptionStatusState.NoSubscription,
),
awaitItem(),
)
fakeSettingsDiskSource.assertPremiumUpgradePending(
userId = ACTIVE_USER_ID,
expected = null,
)
}
}
@Test
fun `banner is ineligible while the active user has a pending Premium upgrade`() = runTest {
// Baseline: banner is eligible (the default DEFAULT_USER_STATE_JSON satisfies all gates).
fakeSettingsDiskSource.storePremiumUpgradePending(
userId = ACTIVE_USER_ID,
isPending = true,
)
val manager = createManager()
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
// Clearing pending re-enables the banner.
fakeSettingsDiskSource.storePremiumUpgradePending(
userId = ACTIVE_USER_ID,
isPending = false,
)
assertTrue(awaitItem())
}
}
@Test
fun `subscriptionStatusStateFlow emits NoSubscription when there is no active user`() =
runTest {

View File

@@ -154,6 +154,10 @@ class SettingsDiskSourceTest {
userId = userId,
isPending = true,
)
settingsDiskSource.storePremiumUpgradePending(
userId = userId,
isPending = true,
)
settingsDiskSource.storeInlineAutofillEnabled(
userId = userId,
isInlineAutofillEnabled = true,
@@ -196,6 +200,9 @@ class SettingsDiskSourceTest {
assertTrue(
settingsDiskSource.getUpgradedToPremiumCardPending(userId = userId) ?: false,
)
assertTrue(
settingsDiskSource.getPremiumUpgradePending(userId = userId) ?: false,
)
// These should be cleared
assertNull(settingsDiskSource.getVaultTimeoutInMinutes(userId = userId))
@@ -955,6 +962,70 @@ class SettingsDiskSourceTest {
}
}
@Test
fun `getPremiumUpgradePending when values are present should pull from SharedPreferences`() {
val baseKey = "bwPreferencesStorage:premiumUpgradePending"
val mockUserId = "mockUserId"
val key = "${baseKey}_$mockUserId"
assertNull(settingsDiskSource.getPremiumUpgradePending(userId = mockUserId))
fakeSharedPreferences.edit { putBoolean(key, true) }
assertEquals(
true,
settingsDiskSource.getPremiumUpgradePending(userId = mockUserId),
)
}
@Test
fun `storePremiumUpgradePending for non-null values should update SharedPreferences`() {
val baseKey = "bwPreferencesStorage:premiumUpgradePending"
val mockUserId = "mockUserId"
val key = "${baseKey}_$mockUserId"
settingsDiskSource.storePremiumUpgradePending(
userId = mockUserId,
isPending = true,
)
assertTrue(fakeSharedPreferences.getBoolean(key, false))
}
@Test
fun `storePremiumUpgradePending for null values should clear SharedPreferences`() {
val baseKey = "bwPreferencesStorage:premiumUpgradePending"
val mockUserId = "mockUserId"
val key = "${baseKey}_$mockUserId"
fakeSharedPreferences.edit { putBoolean(key, true) }
settingsDiskSource.storePremiumUpgradePending(
userId = mockUserId,
isPending = null,
)
assertFalse(fakeSharedPreferences.contains(key))
}
@Test
fun `getPremiumUpgradePendingFlow should react to changes in storePremiumUpgradePending`() =
runTest {
val mockUserId = "mockUserId"
settingsDiskSource
.getPremiumUpgradePendingFlow(userId = mockUserId)
.test {
assertNull(
settingsDiskSource.getPremiumUpgradePending(userId = mockUserId),
)
assertNull(awaitItem())
settingsDiskSource.storePremiumUpgradePending(
userId = mockUserId,
isPending = true,
)
assertEquals(true, awaitItem())
settingsDiskSource.storePremiumUpgradePending(
userId = mockUserId,
isPending = false,
)
assertEquals(false, awaitItem())
}
}
@Test
fun `storePullToRefreshEnabled for non-null values should update SharedPreferences`() {
val pullToRefreshBaseKey = "bwPreferencesStorage:syncOnRefresh"

View File

@@ -70,6 +70,7 @@ class FakeSettingsDiskSource(
private var storedPremiumUpgradeBannerDismissed = mutableMapOf<String, Boolean?>()
private val storedUpgradedToPremiumCardConsumed = mutableMapOf<String, Boolean?>()
private val storedUpgradedToPremiumCardPending = mutableMapOf<String, Boolean?>()
private val storedPremiumUpgradePending = mutableMapOf<String, Boolean?>()
private val storedInlineAutofillEnabled = mutableMapOf<String, Boolean?>()
private val storedBlockedAutofillUris = mutableMapOf<String, List<String>?>()
private var storedIsIconLoadingDisabled: Boolean? = null
@@ -124,6 +125,9 @@ class FakeSettingsDiskSource(
private val mutableUpgradedToPremiumCardPendingFlow =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutablePremiumUpgradePendingFlow =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
override var appLanguage: AppLanguage?
get() = storedAppLanguage
set(value) {
@@ -389,6 +393,18 @@ class FakeSettingsDiskSource(
getMutableUpgradedToPremiumCardPendingFlow(userId = userId)
.onSubscription { emit(getUpgradedToPremiumCardPending(userId = userId)) }
override fun getPremiumUpgradePending(userId: String): Boolean? =
storedPremiumUpgradePending[userId]
override fun storePremiumUpgradePending(userId: String, isPending: Boolean?) {
storedPremiumUpgradePending[userId] = isPending
getMutablePremiumUpgradePendingFlow(userId = userId).tryEmit(isPending)
}
override fun getPremiumUpgradePendingFlow(userId: String): Flow<Boolean?> =
getMutablePremiumUpgradePendingFlow(userId = userId)
.onSubscription { emit(getPremiumUpgradePending(userId = userId)) }
override fun getInlineAutofillEnabled(userId: String): Boolean? =
storedInlineAutofillEnabled[userId]
@@ -567,6 +583,13 @@ class FakeSettingsDiskSource(
assertEquals(expected, storedUpgradedToPremiumCardPending[userId])
}
/**
* Asserts that the stored "Premium upgrade pending" value matches the [expected] one.
*/
fun assertPremiumUpgradePending(userId: String, expected: Boolean?) {
assertEquals(expected, storedPremiumUpgradePending[userId])
}
/**
* Asserts that the stored last sync time matches the [expected] one.
*/
@@ -652,6 +675,13 @@ class FakeSettingsDiskSource(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePremiumUpgradePendingFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutablePremiumUpgradePendingFlow.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableShowAutoFillSettingBadgeFlow(
userId: String,
): MutableSharedFlow<Boolean?> = mutableShowAutoFillSettingBadgeFlowMap.getOrPut(userId) {

View File

@@ -1334,6 +1334,7 @@ private val DEFAULT_FREE_STATE = PlanState(
rate = "$1.65",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
)

View File

@@ -20,6 +20,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStat
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@@ -67,9 +68,12 @@ class PlanViewModelTest : BaseViewModelTest() {
}
private val mutableSubscriptionStatusStateFlow =
MutableStateFlow<SubscriptionStatusState>(SubscriptionStatusState.NoSubscription)
private val mutableLifecycleStateFlow =
MutableStateFlow<UpgradeLifecycleState>(UpgradeLifecycleState.Free)
private var mockIsSelfHosted = false
private val mockPremiumStateManager: PremiumStateManager = mockk {
private val mockPremiumStateManager: PremiumStateManager = mockk(relaxed = true) {
every { subscriptionStatusStateFlow } returns mutableSubscriptionStatusStateFlow
every { upgradeLifecycleStateFlow } returns mutableLifecycleStateFlow
every { isSelfHosted } answers { mockIsSelfHosted }
}
private val mutableEnvironmentFlow = MutableStateFlow<Environment>(Environment.Us)
@@ -144,6 +148,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.WaitingForPayment,
),
@@ -224,6 +229,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
),
@@ -314,6 +320,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
),
@@ -386,6 +393,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
)
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
@@ -434,6 +442,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.WaitingForPayment,
),
@@ -476,6 +485,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.WaitingForPayment,
),
@@ -531,6 +541,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.PendingUpgrade,
),
@@ -540,12 +551,61 @@ class PlanViewModelTest : BaseViewModelTest() {
verify {
mockSpecialCircumstanceManager.specialCircumstance = null
mockPremiumStateManager.markPremiumUpgradePending(userId = DEFAULT_ACCOUNT.userId)
}
coVerify {
mockVaultRepository.syncForResult(forced = true)
}
}
@Test
fun `ContinueClick dismisses the PendingUpgrade dialog and navigates back`() = runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
isPremiumUpgradePending = true,
),
dialogState = PlanState.DialogState.PendingUpgrade,
),
pricingResult = null,
)
viewModel.eventFlow.test {
viewModel.trySendAction(PlanAction.ContinueClick)
assertEquals(PlanEvent.NavigateBack, awaitItem())
}
}
@Test
fun `upgradeLifecycleStateFlow updates propagate onto Free Cloud view state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_FREE_STATE, awaitItem())
mutableLifecycleStateFlow.value = UpgradeLifecycleState.UpgradePending
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = true,
),
),
awaitItem(),
)
mutableLifecycleStateFlow.value = UpgradeLifecycleState.Free
assertEquals(DEFAULT_FREE_STATE, awaitItem())
}
}
@Test
fun `UserStateUpdateReceive premium during Loading should navigate to UpgradedToPremium`() =
runTest {
@@ -555,6 +615,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.confirming_your_upgrade.asText(),
@@ -666,6 +727,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
),
@@ -694,6 +756,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
),
@@ -719,6 +782,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.GetPricingError(
title = BitwardenString.pricing_unavailable.asText(),
@@ -747,6 +811,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.GetPricingError(
title = BitwardenString.pricing_unavailable.asText(),
@@ -770,6 +835,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading.asText(),
@@ -801,6 +867,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.GetPricingError(
title = BitwardenString.pricing_unavailable.asText(),
@@ -819,6 +886,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
),
@@ -990,6 +1058,7 @@ class PlanViewModelTest : BaseViewModelTest() {
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading.asText(),
@@ -1770,6 +1839,7 @@ private val DEFAULT_FREE_STATE = PlanState(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
)