mirror of
https://github.com/bitwarden/android.git
synced 2026-08-24 14:28:58 -05:00
PM-38356 PM-37177: Bug: Update premium subscription trouble-states (#7073)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.billing.manager
|
||||
|
||||
import com.x8bit.bitwarden.data.billing.model.PremiumCard
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -16,10 +17,9 @@ const val UPGRADED_TO_PREMIUM_LEARN_MORE_URL: String =
|
||||
interface PremiumStateManager {
|
||||
|
||||
/**
|
||||
* Emits `true` when the current user is eligible to see the Premium upgrade banner,
|
||||
* or `false` otherwise.
|
||||
* Emits a [PremiumCard] for the current user indicating what Premium card should be displayed.
|
||||
*/
|
||||
val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean>
|
||||
val premiumCardStateFlow: StateFlow<PremiumCard>
|
||||
|
||||
/**
|
||||
* Emits `true` while the active user is eligible to see the "Upgraded to Premium" action
|
||||
|
||||
+69
-51
@@ -5,8 +5,10 @@ import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
|
||||
import com.x8bit.bitwarden.data.billing.model.PremiumCard
|
||||
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
|
||||
@@ -50,7 +52,7 @@ class PremiumStateManagerImpl(
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
vaultRepository: VaultRepository,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
private val environmentRepository: EnvironmentRepository,
|
||||
environmentRepository: EnvironmentRepository,
|
||||
pushManager: PushManager,
|
||||
private val clock: Clock,
|
||||
dispatcherManager: DispatcherManager,
|
||||
@@ -123,62 +125,69 @@ class PremiumStateManagerImpl(
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean> =
|
||||
override val premiumCardStateFlow: StateFlow<PremiumCard> =
|
||||
combine(
|
||||
authDiskSource.userStateFlow,
|
||||
authDiskSource.userStateFlow.map { it?.activeAccount },
|
||||
billingRepository.isInAppBillingSupportedFlow,
|
||||
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
|
||||
authDiskSource.activeUserIdChangesFlow
|
||||
.flatMapLatest { userId ->
|
||||
userId
|
||||
?.let { id ->
|
||||
settingsDiskSource
|
||||
.getPremiumUpgradeBannerDismissedFlow(id)
|
||||
.map { it ?: false }
|
||||
}
|
||||
?: flowOf(false)
|
||||
},
|
||||
authDiskSource.activeUserIdChangesFlow.flatMapLatest { userId ->
|
||||
userId
|
||||
?.let { id ->
|
||||
settingsDiskSource
|
||||
.getPremiumUpgradeBannerDismissedFlow(id)
|
||||
.map { it ?: false }
|
||||
}
|
||||
?: flowOf(false)
|
||||
},
|
||||
vaultRepository.vaultDataStateFlow,
|
||||
) {
|
||||
userState,
|
||||
account,
|
||||
isInAppBillingSupported,
|
||||
featureFlagEnabled,
|
||||
isDismissed,
|
||||
isUpgradeCardDismissed,
|
||||
vaultDataState,
|
||||
->
|
||||
BannerInputs(
|
||||
userState = userState,
|
||||
account = account,
|
||||
isInAppBillingSupported = isInAppBillingSupported,
|
||||
featureFlagEnabled = featureFlagEnabled,
|
||||
isDismissed = isDismissed,
|
||||
isUpgradeCardDismissed = isUpgradeCardDismissed,
|
||||
vaultDataState = vaultDataState,
|
||||
)
|
||||
}
|
||||
.combine(upgradeLifecycleStateFlow) { inputs, lifecycle ->
|
||||
val profile = inputs.userState?.activeAccount?.profile
|
||||
?: return@combine false
|
||||
val isAccountOldEnough = profile.creationDate.isOlderThanDays(
|
||||
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
|
||||
clock = clock,
|
||||
)
|
||||
val itemCount = inputs.vaultDataState.activeVaultItemCount()
|
||||
val lifecycleAllowsBanner = lifecycle is UpgradeLifecycleState.Free ||
|
||||
(
|
||||
lifecycle is UpgradeLifecycleState.Premium &&
|
||||
lifecycle.subscriptionStatus.isInTroubleState()
|
||||
val profile = inputs.account?.profile ?: return@combine PremiumCard.NONE
|
||||
if (!inputs.featureFlagEnabled) return@combine PremiumCard.NONE
|
||||
val initialCard = when (lifecycle) {
|
||||
UpgradeLifecycleState.Free -> PremiumCard.UPGRADE
|
||||
UpgradeLifecycleState.UpgradePending -> PremiumCard.NONE
|
||||
is UpgradeLifecycleState.Premium -> {
|
||||
lifecycle.subscriptionStatus.premiumCardState()
|
||||
}
|
||||
}
|
||||
when (initialCard) {
|
||||
PremiumCard.UPGRADE -> {
|
||||
val isAccountOldEnough = profile.creationDate.isOlderThanDays(
|
||||
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
|
||||
clock = clock,
|
||||
)
|
||||
val itemCount = inputs.vaultDataState.activeVaultItemCount()
|
||||
val showCard = inputs.isInAppBillingSupported &&
|
||||
isAccountOldEnough &&
|
||||
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS &&
|
||||
!inputs.isUpgradeCardDismissed
|
||||
initialCard.takeIf { showCard } ?: PremiumCard.NONE
|
||||
}
|
||||
|
||||
lifecycleAllowsBanner &&
|
||||
inputs.isInAppBillingSupported &&
|
||||
inputs.featureFlagEnabled &&
|
||||
!inputs.isDismissed &&
|
||||
isAccountOldEnough &&
|
||||
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS
|
||||
PremiumCard.NEEDS_ATTENTION,
|
||||
PremiumCard.NONE,
|
||||
-> initialCard
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = false,
|
||||
initialValue = PremiumCard.NONE,
|
||||
)
|
||||
|
||||
override val isSelfHostedFlow: StateFlow<Boolean> =
|
||||
@@ -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.
|
||||
*/
|
||||
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
|
||||
private fun SubscriptionStatusState.premiumCardState(): PremiumCard =
|
||||
when (this) {
|
||||
is SubscriptionStatusState.Available -> {
|
||||
when (this.status) {
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT,
|
||||
-> PremiumCard.NEEDS_ATTENTION
|
||||
|
||||
PremiumSubscriptionStatus.ACTIVE,
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION,
|
||||
-> false
|
||||
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`.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -562,6 +562,7 @@ fun VaultContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun ActionCard(
|
||||
actionCardState: VaultState.ActionCardState,
|
||||
@@ -606,6 +607,16 @@ private fun ActionCard(
|
||||
)
|
||||
}
|
||||
|
||||
VaultState.ActionCardState.PremiumNeedsAttention -> {
|
||||
BitwardenActionCard(
|
||||
cardTitle = stringResource(id = BitwardenString.your_subscription_needs_attention),
|
||||
cardSubtitle = stringResource(id = BitwardenString.check_your_plan_for_details),
|
||||
actionText = stringResource(id = BitwardenString.view_plan),
|
||||
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
VaultState.ActionCardState.IntroducingArchive -> {
|
||||
BitwardenActionCard(
|
||||
cardTitle = stringResource(id = BitwardenString.introducing_archive),
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
|
||||
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
|
||||
import com.x8bit.bitwarden.data.billing.manager.UPGRADED_TO_PREMIUM_LEARN_MORE_URL
|
||||
import com.x8bit.bitwarden.data.billing.model.PremiumCard
|
||||
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
@@ -120,7 +121,7 @@ class VaultViewModel @Inject constructor(
|
||||
private val networkConnectionManager: NetworkConnectionManager,
|
||||
private val browserAutofillDialogManager: BrowserAutofillDialogManager,
|
||||
private val credentialExchangeRegistryManager: CredentialExchangeRegistryManager,
|
||||
private val buildInfoManager: BuildInfoManager,
|
||||
buildInfoManager: BuildInfoManager,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
|
||||
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
|
||||
@@ -268,10 +269,10 @@ class VaultViewModel @Inject constructor(
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
premiumStateManager
|
||||
.isPremiumUpgradeBannerEligibleFlow
|
||||
.premiumCardStateFlow
|
||||
.map {
|
||||
VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive(
|
||||
isEligible = it,
|
||||
premiumCard = it,
|
||||
)
|
||||
}
|
||||
.onEach(::sendAction)
|
||||
@@ -449,6 +450,10 @@ class VaultViewModel @Inject constructor(
|
||||
premiumStateManager.dismissPremiumUpgradeBanner()
|
||||
}
|
||||
|
||||
VaultState.ActionCardState.PremiumNeedsAttention -> {
|
||||
// No-op: The user must address the issue
|
||||
}
|
||||
|
||||
VaultState.ActionCardState.IntroducingArchive -> {
|
||||
settingsRepository.dismissIntroducingArchiveActionCard()
|
||||
}
|
||||
@@ -466,6 +471,10 @@ class VaultViewModel @Inject constructor(
|
||||
sendEvent(VaultEvent.NavigateToUpgradePremium)
|
||||
}
|
||||
|
||||
VaultState.ActionCardState.PremiumNeedsAttention -> {
|
||||
sendEvent(VaultEvent.NavigateToUpgradePremium)
|
||||
}
|
||||
|
||||
VaultState.ActionCardState.IntroducingArchive -> {
|
||||
settingsRepository.dismissIntroducingArchiveActionCard()
|
||||
sendEvent(
|
||||
@@ -1273,7 +1282,7 @@ class VaultViewModel @Inject constructor(
|
||||
action: VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(isPremiumUpgradeBannerEligible = action.isEligible)
|
||||
it.copy(premiumCard = action.premiumCard)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1747,7 +1756,7 @@ data class VaultState(
|
||||
val hasShownDecryptionFailureAlert: Boolean,
|
||||
val restrictItemTypesPolicyOrgIds: List<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>,
|
||||
@@ -1761,8 +1770,10 @@ data class VaultState(
|
||||
get() = (viewState as? ViewState.Content)?.let {
|
||||
ActionCardState.UpgradedToPremium
|
||||
.takeIf { isUpgradedToPremiumCardEligible }
|
||||
?: ActionCardState.UpgradePremium
|
||||
.takeIf { isPremiumUpgradeBannerEligible }
|
||||
?: ActionCardState.UpgradePremium.takeIf { premiumCard == PremiumCard.UPGRADE }
|
||||
?: ActionCardState.PremiumNeedsAttention.takeIf {
|
||||
premiumCard == PremiumCard.NEEDS_ATTENTION
|
||||
}
|
||||
?: ActionCardState.IntroducingArchive.takeIf {
|
||||
isPremium && !isIntroducingArchiveActionCardDismissed
|
||||
}
|
||||
@@ -2169,6 +2180,11 @@ data class VaultState(
|
||||
*/
|
||||
data object UpgradePremium : ActionCardState()
|
||||
|
||||
/**
|
||||
* Indicates that the user needs to address an issue with their Premium account.
|
||||
*/
|
||||
data object PremiumNeedsAttention : ActionCardState()
|
||||
|
||||
/**
|
||||
* Indicates that the archive feature is ready for use.
|
||||
*/
|
||||
@@ -2747,11 +2763,10 @@ sealed class VaultAction {
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the Premium upgrade banner eligibility has been
|
||||
* updated.
|
||||
* Indicates that the Premium upgrade banner eligibility has been updated.
|
||||
*/
|
||||
data class PremiumUpgradeBannerEligibilityReceive(
|
||||
val isEligible: Boolean,
|
||||
val premiumCard: PremiumCard,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
|
||||
+152
-71
@@ -10,6 +10,7 @@ import com.bitwarden.vault.DecryptCipherListResult
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
|
||||
import com.x8bit.bitwarden.data.billing.model.PremiumCard
|
||||
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
|
||||
@@ -108,160 +109,160 @@ class PremiumStateManagerTest {
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `eligible when all conditions met should emit true`() = runTest {
|
||||
fun `eligible when all conditions met should emit UPGRADE`() = runTest {
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertTrue(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.UPGRADE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ineligible when user is Premium should emit false`() = runTest {
|
||||
fun `ineligible when user is Premium should emit NONE`() = runTest {
|
||||
fakeAuthDiskSource.userState = userStateJsonWith(
|
||||
account = createAccountJson(hasPremiumPersonally = true),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ineligible when in-app billing is not supported should emit false`() =
|
||||
fun `ineligible when in-app billing is not supported should emit NONE`() =
|
||||
runTest {
|
||||
mutableIsInAppBillingSupportedFlow.value = false
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ineligible when feature flag is disabled should emit false`() = runTest {
|
||||
fun `ineligible when feature flag is disabled should emit NONE`() = runTest {
|
||||
mutableMobilePremiumUpgradeFlagFlow.value = false
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ineligible when banner is dismissed should emit false`() = runTest {
|
||||
fun `ineligible when banner is dismissed should emit NONE`() = runTest {
|
||||
fakeSettingsDiskSource.storePremiumUpgradeBannerDismissed(
|
||||
userId = ACTIVE_USER_ID,
|
||||
isDismissed = true,
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ineligible when account is too new should emit false`() = runTest {
|
||||
fun `ineligible when account is too new should emit NONE`() = runTest {
|
||||
fakeAuthDiskSource.userState = userStateJsonWith(
|
||||
account = createAccountJson(
|
||||
creationDate = Instant.parse("2023-10-25T12:00:00Z"),
|
||||
),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ineligible when creation date is null should emit false`() = runTest {
|
||||
fun `ineligible when creation date is null should emit NONE`() = runTest {
|
||||
fakeAuthDiskSource.userState = userStateJsonWith(
|
||||
account = createAccountJson(creationDate = null),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ineligible when vault has fewer than 5 items should emit false`() = runTest {
|
||||
fun `ineligible when vault has fewer than 5 items should emit NONE`() = runTest {
|
||||
mutableVaultDataStateFlow.value = DataState.Loaded(
|
||||
createVaultDataWithItemCount(count = 4),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ineligible when userState is null should emit false`() = runTest {
|
||||
fun `ineligible when userState is null should emit NONE`() = runTest {
|
||||
fakeAuthDiskSource.userState = null
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vault data Loading should emit false`() = runTest {
|
||||
fun `vault data Loading should emit NONE`() = runTest {
|
||||
mutableVaultDataStateFlow.value = DataState.Loading
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vault data Pending with enough items should emit true`() = runTest {
|
||||
fun `vault data Pending with enough items should emit UPGRADE`() = runTest {
|
||||
mutableVaultDataStateFlow.value = DataState.Pending(
|
||||
createVaultDataWithItemCount(count = 5),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertTrue(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.UPGRADE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vault data NoNetwork with enough items should emit true`() = runTest {
|
||||
fun `vault data NoNetwork with enough items should emit UPGRADE`() = runTest {
|
||||
mutableVaultDataStateFlow.value = DataState.NoNetwork(
|
||||
createVaultDataWithItemCount(count = 5),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertTrue(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.UPGRADE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vault data Error with enough items should emit true`() = runTest {
|
||||
fun `vault data Error with enough items should emit UPGRADE`() = runTest {
|
||||
mutableVaultDataStateFlow.value = DataState.Error(
|
||||
error = IllegalStateException("test"),
|
||||
data = createVaultDataWithItemCount(count = 5),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertTrue(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.UPGRADE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vault data NoNetwork without data should emit false`() = runTest {
|
||||
fun `vault data NoNetwork without data should emit NONE`() = runTest {
|
||||
mutableVaultDataStateFlow.value = DataState.NoNetwork(data = null)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vault data Error without data should emit false`() = runTest {
|
||||
fun `vault data Error without data should emit NONE`() = runTest {
|
||||
mutableVaultDataStateFlow.value = DataState.Error(
|
||||
error = IllegalStateException("test"),
|
||||
data = null,
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,8 +284,8 @@ class PremiumStateManagerTest {
|
||||
),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,13 +307,13 @@ class PremiumStateManagerTest {
|
||||
),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eligible when account age is exactly 7 days should emit true`() =
|
||||
fun `eligible when account age is exactly 7 days should emit UPGRADE`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = userStateJsonWith(
|
||||
account = createAccountJson(
|
||||
@@ -320,33 +321,33 @@ class PremiumStateManagerTest {
|
||||
),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertTrue(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.UPGRADE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eligible when vault items exactly 5 should emit true`() = runTest {
|
||||
fun `eligible when vault items exactly 5 should emit UPGRADE`() = runTest {
|
||||
mutableVaultDataStateFlow.value = DataState.Loaded(
|
||||
createVaultDataWithItemCount(count = 5),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertTrue(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.UPGRADE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eligibility should update when upstream flows change`() = runTest {
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertTrue(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.UPGRADE, awaitItem())
|
||||
|
||||
mutableIsInAppBillingSupportedFlow.value = false
|
||||
assertFalse(awaitItem())
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
|
||||
mutableIsInAppBillingSupportedFlow.value = true
|
||||
assertTrue(awaitItem())
|
||||
assertEquals(PremiumCard.UPGRADE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,14 +834,14 @@ class PremiumStateManagerTest {
|
||||
isPending = true,
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
// Clearing pending re-enables the banner.
|
||||
fakeSettingsDiskSource.storePremiumUpgradePending(
|
||||
userId = ACTIVE_USER_ID,
|
||||
isPending = false,
|
||||
)
|
||||
assertTrue(awaitItem())
|
||||
assertEquals(PremiumCard.UPGRADE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1026,18 +1027,40 @@ class PremiumStateManagerTest {
|
||||
subscription = createSubscriptionInfo(status = PremiumSubscriptionStatus.ACTIVE),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `banner eligible when account is premium but status is in a trouble state`() = runTest {
|
||||
fun `premium account with an EXPIRED or PAUSED status should emit UPGRADE`() = runTest {
|
||||
listOf(
|
||||
PremiumSubscriptionStatus.CANCELED,
|
||||
PremiumSubscriptionStatus.EXPIRED,
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.PAUSED,
|
||||
).forEach { status ->
|
||||
fakeAuthDiskSource.userState = userStateJsonWith(
|
||||
account = createAccountJson(hasPremiumPersonally = true),
|
||||
)
|
||||
coEvery {
|
||||
billingRepository.getSubscription()
|
||||
} returns SubscriptionResult.Success(
|
||||
subscription = createSubscriptionInfo(status = status),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(
|
||||
PremiumCard.UPGRADE,
|
||||
awaitItem(),
|
||||
"Expected UPGRADE for status=$status",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `premium account with a payment-trouble status should emit NEEDS_ATTENTION`() = runTest {
|
||||
listOf(
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT,
|
||||
).forEach { status ->
|
||||
fakeAuthDiskSource.userState = userStateJsonWith(
|
||||
@@ -1049,12 +1072,70 @@ class PremiumStateManagerTest {
|
||||
subscription = createSubscriptionInfo(status = status),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
assertTrue(awaitItem(), "Expected banner eligible for status=$status")
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(
|
||||
PremiumCard.NEEDS_ATTENTION,
|
||||
awaitItem(),
|
||||
"Expected NEEDS_ATTENTION for status=$status",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `premium account with a payment-trouble status emits NEEDS_ATTENTION when billing unsupported`() =
|
||||
runTest {
|
||||
// The NEEDS_ATTENTION card is not gated on in-app billing support (unlike the UPGRADE
|
||||
// card) — the user must resolve the payment issue regardless of platform billing.
|
||||
mutableIsInAppBillingSupportedFlow.value = false
|
||||
listOf(
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT,
|
||||
).forEach { status ->
|
||||
fakeAuthDiskSource.userState = userStateJsonWith(
|
||||
account = createAccountJson(hasPremiumPersonally = true),
|
||||
)
|
||||
coEvery {
|
||||
billingRepository.getSubscription()
|
||||
} returns SubscriptionResult.Success(
|
||||
subscription = createSubscriptionInfo(status = status),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(
|
||||
PremiumCard.NEEDS_ATTENTION,
|
||||
awaitItem(),
|
||||
"Expected NEEDS_ATTENTION for status=$status",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `premium account with an ACTIVE, CANCELED, or PENDING_CANCELLATION status should emit NONE`() =
|
||||
runTest {
|
||||
listOf(
|
||||
PremiumSubscriptionStatus.ACTIVE,
|
||||
PremiumSubscriptionStatus.CANCELED,
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION,
|
||||
).forEach { status ->
|
||||
fakeAuthDiskSource.userState = userStateJsonWith(
|
||||
account = createAccountJson(hasPremiumPersonally = true),
|
||||
)
|
||||
coEvery {
|
||||
billingRepository.getSubscription()
|
||||
} returns SubscriptionResult.Success(
|
||||
subscription = createSubscriptionInfo(status = status),
|
||||
)
|
||||
val manager = createManager()
|
||||
manager.premiumCardStateFlow.test {
|
||||
assertEquals(PremiumCard.NONE, awaitItem(), "Expected NONE for status=$status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `banner ineligible when account is premium and substate is still loading`() = runTest {
|
||||
fakeAuthDiskSource.userState = userStateJsonWith(
|
||||
@@ -1066,10 +1147,10 @@ class PremiumStateManagerTest {
|
||||
kotlinx.coroutines.awaitCancellation()
|
||||
}
|
||||
val manager = createManager()
|
||||
manager.isPremiumUpgradeBannerEligibleFlow.test {
|
||||
manager.premiumCardStateFlow.test {
|
||||
// Loading is not treated as a trouble state so a premium user is still effectively
|
||||
// premium during the initial fetch.
|
||||
assertFalse(awaitItem())
|
||||
assertEquals(PremiumCard.NONE, awaitItem())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import com.bitwarden.ui.util.performLogoutAccountClick
|
||||
import com.bitwarden.ui.util.performRemoveAccountClick
|
||||
import com.bitwarden.ui.util.performYesDialogButtonClick
|
||||
import com.bitwarden.vault.CipherType
|
||||
import com.x8bit.bitwarden.data.billing.model.PremiumCard
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager
|
||||
import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType
|
||||
@@ -1641,7 +1642,7 @@ class VaultScreenTest : BitwardenComposeTest() {
|
||||
@Test
|
||||
fun `UpgradePremium action card should display when eligible`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
isPremiumUpgradeBannerEligible = true,
|
||||
premiumCard = PremiumCard.UPGRADE,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
|
||||
@@ -1656,7 +1657,7 @@ class VaultScreenTest : BitwardenComposeTest() {
|
||||
@Test
|
||||
fun `UpgradePremium action card CTA click should send ActionCardClick`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
isPremiumUpgradeBannerEligible = true,
|
||||
premiumCard = PremiumCard.UPGRADE,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
|
||||
@@ -1677,7 +1678,7 @@ class VaultScreenTest : BitwardenComposeTest() {
|
||||
@Test
|
||||
fun `UpgradePremium action card dismiss click should send DismissActionCardClick`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
isPremiumUpgradeBannerEligible = true,
|
||||
premiumCard = PremiumCard.UPGRADE,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
|
||||
@@ -1693,6 +1694,42 @@ class VaultScreenTest : BitwardenComposeTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PremiumNeedsAttention action card should display when eligible`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
premiumCard = PremiumCard.NEEDS_ATTENTION,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Your subscription needs attention")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "View plan")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PremiumNeedsAttention action card CTA click should send ActionCardClick`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
premiumCard = PremiumCard.NEEDS_ATTENTION,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "View plan")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(
|
||||
VaultAction.ActionCardClick(
|
||||
actionCard = VaultState.ActionCardState.PremiumNeedsAttention,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UpgradedToPremium action card should display when eligible`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
|
||||
+49
-10
@@ -31,6 +31,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.createMockOrganization
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
|
||||
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
|
||||
import com.x8bit.bitwarden.data.billing.model.PremiumCard
|
||||
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
@@ -237,11 +238,12 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
every { getFeatureFlagFlow(FlagKey.NewItemTypes) } returns mutableNewItemTypesFlagFlow
|
||||
}
|
||||
|
||||
private val mutablePremiumUpgradeBannerEligibleFlow = MutableStateFlow(false)
|
||||
private val mutablePremiumUpgradeBannerEligibleFlow =
|
||||
MutableStateFlow(PremiumCard.NONE)
|
||||
private val mutableUpgradedToPremiumCardEligibleFlow = MutableStateFlow(false)
|
||||
private val premiumStateManager: PremiumStateManager = mockk {
|
||||
every {
|
||||
isPremiumUpgradeBannerEligibleFlow
|
||||
premiumCardStateFlow
|
||||
} returns mutablePremiumUpgradeBannerEligibleFlow
|
||||
every {
|
||||
isUpgradedToPremiumCardEligibleFlow
|
||||
@@ -407,14 +409,14 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutablePremiumUpgradeBannerEligibleFlow.value = true
|
||||
mutablePremiumUpgradeBannerEligibleFlow.value = PremiumCard.UPGRADE
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
isPremiumUpgradeBannerEligible = true,
|
||||
premiumCard = PremiumCard.UPGRADE,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
mutablePremiumUpgradeBannerEligibleFlow.value = false
|
||||
mutablePremiumUpgradeBannerEligibleFlow.value = PremiumCard.NONE
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
@@ -453,11 +455,48 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ActionCardClick with PremiumNeedsAttention should emit NavigateToUpgradePremium`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(
|
||||
VaultAction.ActionCardClick(
|
||||
VaultState.ActionCardState.PremiumNeedsAttention,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
VaultEvent.NavigateToUpgradePremium,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DismissActionCardClick with PremiumNeedsAttention should do nothing`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(
|
||||
VaultAction.DismissActionCardClick(
|
||||
VaultState.ActionCardState.PremiumNeedsAttention,
|
||||
),
|
||||
)
|
||||
expectNoEvents()
|
||||
}
|
||||
verify(exactly = 0) {
|
||||
premiumStateManager.dismissPremiumUpgradeBanner()
|
||||
premiumStateManager.dismissUpgradedToPremiumCard()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `actionCard should return UpgradePremium when eligible and content is showing`() {
|
||||
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
|
||||
val state = createMockVaultState(viewState = contentViewState).copy(
|
||||
isPremiumUpgradeBannerEligible = true,
|
||||
premiumCard = PremiumCard.UPGRADE,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
@@ -471,7 +510,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
|
||||
val state = createMockVaultState(viewState = contentViewState).copy(
|
||||
isUpgradedToPremiumCardEligible = true,
|
||||
isPremiumUpgradeBannerEligible = true,
|
||||
premiumCard = PremiumCard.UPGRADE,
|
||||
isPremium = true,
|
||||
isIntroducingArchiveActionCardDismissed = false,
|
||||
)
|
||||
@@ -525,7 +564,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
fun `actionCard should return IntroducingArchive when not eligible for Premium upgrade`() {
|
||||
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
|
||||
val state = createMockVaultState(viewState = contentViewState).copy(
|
||||
isPremiumUpgradeBannerEligible = false,
|
||||
premiumCard = PremiumCard.NONE,
|
||||
isPremium = true,
|
||||
isIntroducingArchiveActionCardDismissed = false,
|
||||
)
|
||||
@@ -540,7 +579,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
fun `actionCard should return null when not eligible for either card`() {
|
||||
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
|
||||
val state = createMockVaultState(viewState = contentViewState).copy(
|
||||
isPremiumUpgradeBannerEligible = false,
|
||||
premiumCard = PremiumCard.NONE,
|
||||
isPremium = false,
|
||||
isIntroducingArchiveActionCardDismissed = false,
|
||||
)
|
||||
@@ -4669,7 +4708,7 @@ private fun createMockVaultState(
|
||||
hasShownDecryptionFailureAlert = false,
|
||||
restrictItemTypesPolicyOrgIds = emptyList(),
|
||||
isIntroducingArchiveActionCardDismissed = false,
|
||||
isPremiumUpgradeBannerEligible = false,
|
||||
premiumCard = PremiumCard.NONE,
|
||||
validTotpIds = validTotpIds.toImmutableSet(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1214,6 +1214,9 @@ Do you want to switch to this account?</string>
|
||||
<string name="plan">Plan</string>
|
||||
<string name="to_manage_your_premium_subscription_youll_need_to_login_to_your_web_vault_on_a_computer">To manage your Premium subscription, you’ll need to login to your web vault on a computer.</string>
|
||||
<string name="unlock_advanced_security_features">Unlock advanced security features</string>
|
||||
<string name="your_subscription_needs_attention">Your subscription needs attention</string>
|
||||
<string name="check_your_plan_for_details">Check your plan for details.</string>
|
||||
<string name="view_plan">View plan</string>
|
||||
<string name="a_premium_plan_gives_you_more_tools_to_stay_secure_and_in_control">A Premium plan gives you more tools to stay secure and in control.</string>
|
||||
<string name="this_item_is_archived">This item is archived.</string>
|
||||
<string name="introducing_archive">Introducing archive</string>
|
||||
|
||||
Reference in New Issue
Block a user