mirror of
https://github.com/bitwarden/android.git
synced 2026-06-10 00:28:29 -05:00
[PM-37232] fix: Hide upgrade CTAs while a Premium upgrade is pending (#6978)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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].
|
||||
|
||||
@@ -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?> =
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1334,6 +1334,7 @@ private val DEFAULT_FREE_STATE = PlanState(
|
||||
rate = "$1.65",
|
||||
checkoutUrl = null,
|
||||
isAwaitingPremiumStatus = false,
|
||||
isPremiumUpgradePending = false,
|
||||
),
|
||||
dialogState = null,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user