[PM-33515] feat: Render premium upgrade banner in Vault UI (#6698)

This commit is contained in:
Patrick Honkonen
2026-03-20 21:27:18 +00:00
committed by GitHub
parent 6c7348ebd4
commit 2e29ab389d
16 changed files with 194 additions and 9 deletions
@@ -54,4 +54,9 @@ interface DebugMenuRepository {
* Clears all stored SSO cookie configurations.
*/
fun clearSsoCookies()
/**
* Resets the premium upgrade banner dismiss status for the current user.
*/
fun resetPremiumUpgradeBannerDismiss()
}
@@ -74,4 +74,12 @@ class DebugMenuRepositoryImpl(
override fun clearSsoCookies() {
cookieDiskSource.clearCookies()
}
override fun resetPremiumUpgradeBannerDismiss() {
val currentUserId = authDiskSource.userState?.activeUserId ?: return
settingsDiskSource.storePremiumUpgradeBannerDismissed(
userId = currentUserId,
isDismissed = null,
)
}
}
@@ -110,6 +110,15 @@ fun DebugMenuScreen(
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(BitwardenString.cookies),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.trigger_cookie_acquisition),
@@ -135,6 +144,27 @@ fun DebugMenuScreen(
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(BitwardenString.premium),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.reset_premium_upgrade_banner),
onClick = {
viewModel.trySendAction(
DebugMenuAction.ResetPremiumUpgradeBanner,
)
},
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(BitwardenString.error_reports),
modifier = Modifier
@@ -65,6 +65,7 @@ class DebugMenuViewModel @Inject constructor(
DebugMenuAction.GenerateErrorReportClick -> handleErrorReportClick()
DebugMenuAction.TriggerCookieAcquisition -> handleTriggerCookieAcquisition()
DebugMenuAction.ClearSsoCookies -> handleClearSsoCookies()
DebugMenuAction.ResetPremiumUpgradeBanner -> handleResetPremiumUpgradeBanner()
}
}
@@ -105,6 +106,10 @@ class DebugMenuViewModel @Inject constructor(
debugMenuRepository.clearSsoCookies()
}
private fun handleResetPremiumUpgradeBanner() {
debugMenuRepository.resetPremiumUpgradeBannerDismiss()
}
private fun handleTriggerCookieAcquisition() {
cookieAcquisitionRequestManager.setPendingCookieAcquisition(
data = CookieAcquisitionRequest(
@@ -206,6 +211,11 @@ sealed class DebugMenuAction {
*/
data object ClearSsoCookies : DebugMenuAction()
/**
* User has clicked to reset the premium upgrade banner dismiss status.
*/
data object ResetPremiumUpgradeBanner : DebugMenuAction()
/**
* Internal actions not triggered from the UI.
*/
@@ -488,7 +488,19 @@ private fun ActionCard(
) {
when (actionCardState) {
VaultState.ActionCardState.UpgradePremium -> {
// UI implementation in PM-33515.
BitwardenActionCard(
cardTitle = stringResource(
id = BitwardenString.unlock_advanced_security_features,
),
cardSubtitle = stringResource(
id = BitwardenString
.a_premium_plan_gives_you_more_tools_to_stay_secure_and_in_control,
),
actionText = stringResource(id = BitwardenString.upgrade_to_premium),
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
onDismissClick = { vaultHandlers.dismissActionCardClick(actionCardState) },
modifier = modifier,
)
}
VaultState.ActionCardState.IntroducingArchive -> {
@@ -182,6 +182,44 @@ class DebugMenuRepositoryTest {
mockCookieDiskSource.clearCookies()
}
}
@Test
fun `resetPremiumUpgradeBannerDismiss should store null for the current user`() {
val userId = "testUserId"
val mockUserStateJson = mockk<UserStateJson>(relaxed = true) {
every { activeUserId } returns userId
}
every { mockAuthDiskSource.userState } returns mockUserStateJson
every {
mockSettingsDiskSource.storePremiumUpgradeBannerDismissed(
userId = any(),
isDismissed = any(),
)
} just runs
debugMenuRepository.resetPremiumUpgradeBannerDismiss()
verify(exactly = 1) {
mockSettingsDiskSource.storePremiumUpgradeBannerDismissed(
userId = userId,
isDismissed = null,
)
}
}
@Test
fun `resetPremiumUpgradeBannerDismiss should do nothing if no active user`() {
every { mockAuthDiskSource.userState } returns null
debugMenuRepository.resetPremiumUpgradeBannerDismiss()
verify(exactly = 0) {
mockSettingsDiskSource.storePremiumUpgradeBannerDismissed(
userId = any(),
isDismissed = any(),
)
}
}
}
private const val TEST_STRING_VALUE = "test"
@@ -159,6 +159,18 @@ class DebugMenuScreenTest : BitwardenComposeTest() {
verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.ClearSsoCookies) }
}
@Test
fun `reset premium upgrade banner should send ResetPremiumUpgradeBanner action`() {
composeTestRule
.onNodeWithText("Reset premium upgrade banner")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(DebugMenuAction.ResetPremiumUpgradeBanner)
}
}
@Test
fun `reset all coach mark tours should send ResetCoachMarkTourStatuses action`() {
composeTestRule
@@ -152,6 +152,16 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `ResetPremiumUpgradeBanner should call resetPremiumUpgradeBannerDismiss on DebugMenuRepository`() {
val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.ResetPremiumUpgradeBanner)
verify(exactly = 1) {
mockDebugMenuRepository.resetPremiumUpgradeBannerDismiss()
}
}
@Test
fun `TriggerCookieAcquisition should set pending cookie acquisition`() =
runTest {
@@ -1047,7 +1047,7 @@ class SearchScreenTest : BitwardenComposeTest() {
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Upgrade to premium")
.onNodeWithText(text = "Upgrade to Premium")
.assert(hasAnyAncestor(isDialog()))
.performClick()
@@ -978,7 +978,7 @@ class AddEditSendScreenTest : BitwardenComposeTest() {
}
composeTestRule
.onNodeWithText("Upgrade to premium")
.onNodeWithText("Upgrade to Premium")
.performClick()
verify { viewModel.trySendAction(AddEditSendAction.UpgradeToPremiumClick) }
@@ -275,7 +275,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
)
composeTestRule
.onNodeWithText(text = "Upgrade to premium")
.onNodeWithText(text = "Upgrade to Premium")
.assert(hasAnyAncestor(isDialog()))
.performClick()
@@ -253,7 +253,7 @@ class VaultItemScreenTest : BitwardenComposeTest() {
}
composeTestRule
.onNodeWithText(text = "Upgrade to premium")
.onNodeWithText(text = "Upgrade to Premium")
.assert(hasAnyAncestor(isDialog()))
.performClick()
@@ -774,7 +774,7 @@ class VaultItemScreenTest : BitwardenComposeTest() {
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Upgrade to premium")
.onAllNodesWithText(text = "Upgrade to Premium")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
@@ -2516,7 +2516,7 @@ class VaultItemListingScreenTest : BitwardenComposeTest() {
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Upgrade to premium")
.onNodeWithText(text = "Upgrade to Premium")
.assert(hasAnyAncestor(isDialog()))
.performClick()
@@ -874,7 +874,7 @@ class VaultScreenTest : BitwardenComposeTest() {
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Upgrade to premium")
.onNodeWithText(text = "Upgrade to Premium")
.assert(hasAnyAncestor(isDialog()))
.performClick()
@@ -1554,6 +1554,61 @@ class VaultScreenTest : BitwardenComposeTest() {
}
}
@Test
fun `UpgradePremium action card should display when eligible`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
isPremiumUpgradeBannerEligible = true,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
composeTestRule
.onNodeWithText(text = "Unlock advanced security features")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Upgrade to Premium")
.assertIsDisplayed()
}
@Test
fun `UpgradePremium action card CTA click should send ActionCardClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
isPremiumUpgradeBannerEligible = true,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
composeTestRule
.onNodeWithText(text = "Upgrade to Premium")
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultAction.ActionCardClick(
actionCard = VaultState.ActionCardState.UpgradePremium,
),
)
}
}
@Test
fun `UpgradePremium action card dismiss click should send DismissActionCardClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
isPremiumUpgradeBannerEligible = true,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
composeTestRule
.onNodeWithContentDescription(label = "Close")
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultAction.DismissActionCardClick(VaultState.ActionCardState.UpgradePremium),
)
}
}
@Test
fun `collection data should update according to the state`() {
val collectionsHeader = "COLLECTIONS (1)"
+3 -1
View File
@@ -1204,7 +1204,9 @@ Do you want to switch to this account?</string>
<string name="item_moved_to_vault">Item moved to vault</string>
<string name="archive_unavailable">Archive unavailable</string>
<string name="archiving_items_is_a_premium_feature">Archiving items is a Premium feature. Your current plan does not include access to this feature.</string>
<string name="upgrade_to_premium">Upgrade to premium</string>
<string name="upgrade_to_premium">Upgrade to Premium</string>
<string name="unlock_advanced_security_features">Unlock advanced security features</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>
<string name="keep_items_you_dont_need_right_now_safe_but_out_sight">Keep items you dont need right now safe but out of sight.</string>
@@ -30,6 +30,9 @@
<string name="generate_crash">Generate crash</string>
<string name="generate_error_report">Generate error report</string>
<string name="error_reports">Error reports</string>
<string name="cookies">Cookies</string>
<string name="premium">Premium</string>
<string name="reset_premium_upgrade_banner">Reset premium upgrade banner</string>
<string name="bitwarden_authentication_enabled">Bitwarden authentication enabled</string>
<string name="import_format_label_bitwarden_json">Bitwarden (.json)</string>
<string name="import_format_label_2fas_json">2FAS (no password)</string>