PM-37287: Feat: Add premium prompt for TOTP codes (#7090)

This commit is contained in:
David Perez
2026-06-24 13:47:07 +00:00
committed by GitHub
parent 7db0d7c406
commit 21ae3e69db
8 changed files with 108 additions and 13 deletions
@@ -147,6 +147,7 @@ fun VaultItemLoginContent(
onCopyTotpClick = vaultLoginItemTypeHandlers.onCopyTotpCodeClick,
onAuthenticatorHelpToolTipClick = vaultLoginItemTypeHandlers
.onAuthenticatorHelpToolTipClick,
onPremiumRequiredClick = vaultCommonItemTypeHandlers.onPremiumRequiredClick,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
@@ -285,12 +286,14 @@ private fun PasswordField(
}
}
@Suppress("LongMethod")
@Composable
private fun TotpField(
totpCodeItemData: TotpCodeItemData,
enabled: Boolean,
onCopyTotpClick: () -> Unit,
onAuthenticatorHelpToolTipClick: () -> Unit,
onPremiumRequiredClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (enabled) {
@@ -333,7 +336,19 @@ private fun TotpField(
contentDescription = stringResource(id = BitwardenString.authenticator_key_help),
isExternalLink = true,
),
supportingText = stringResource(id = BitwardenString.premium_subscription_required),
supportingContentPadding = PaddingValues(),
supportingContent = {
BitwardenClickableText(
label = stringResource(id = BitwardenString.premium_subscription_required),
onClick = onPremiumRequiredClick,
style = BitwardenTheme.typography.labelMedium,
innerPadding = PaddingValues(all = 16.dp),
cornerSize = 0.dp,
modifier = Modifier
.fillMaxWidth()
.testTag(tag = "TotpPremiumRequiredButton"),
)
},
enabled = false,
singleLine = false,
onValueChange = { },
@@ -541,6 +541,7 @@ private val PREVIEW_COMMON_HANDLERS: VaultCommonItemTypeHandlers =
onAttachmentPreviewClick = {},
onCopyNotesClick = {},
onPasswordHistoryClick = {},
onPremiumRequiredClick = {},
onUpgradeToPremiumClick = {},
)
@@ -311,10 +311,10 @@ private fun VaultItemDialogs(
onUpgradeToPremiumClick: () -> Unit,
) {
when (dialog) {
is VaultItemState.DialogState.ArchiveRequiresPremium -> {
is VaultItemState.DialogState.RequiresPremium -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.premium_subscription_required),
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
message = dialog.message(),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = onUpgradeToPremiumClick,
@@ -303,6 +303,7 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Common.PasswordHistoryClick -> handlePasswordHistoryClick()
VaultItemAction.Common.ArchiveClick -> handleArchiveClick()
VaultItemAction.Common.UnarchiveClick -> handleUnarchiveClick()
VaultItemAction.Common.PremiumRequiredClick -> handlePremiumRequiredClick()
VaultItemAction.Common.UpgradeToPremiumClick -> handleUpgradeToPremiumClick()
}
}
@@ -690,7 +691,11 @@ class VaultItemViewModel @Inject constructor(
private fun handleArchiveClick() {
if (!state.hasPremium) {
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.ArchiveRequiresPremium)
it.copy(
dialog = VaultItemState.DialogState.RequiresPremium(
message = BitwardenString.archiving_items_is_a_premium_feature.asText(),
),
)
}
return
}
@@ -741,6 +746,16 @@ class VaultItemViewModel @Inject constructor(
}
}
private fun handlePremiumRequiredClick() {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.RequiresPremium(
message = BitwardenString.totp_is_a_premium_feature.asText(),
),
)
}
}
private fun handleUpgradeToPremiumClick() {
updateDialogState(dialog = null)
if (premiumStateManager.isInAppUpgradeAvailable()) {
@@ -2335,10 +2350,13 @@ data class VaultItemState(
sealed class DialogState : Parcelable {
/**
* Displays a dialog to the user indicating that archiving requires a Premium account.
* Displays a dialog to the user indicating that the feature they are interacting with
* requires a Premium account.
*/
@Parcelize
data object ArchiveRequiresPremium : DialogState()
data class RequiresPremium(
val message: Text,
) : DialogState()
/**
* Displays a generic dialog to the user.
@@ -2500,6 +2518,11 @@ sealed class VaultItemAction {
*/
data object UnarchiveClick : Common()
/**
* The user has clicked the Premium subscription required button.
*/
data object PremiumRequiredClick : Common()
/**
* The user has clicked the upgrade to Premium button.
*/
@@ -20,6 +20,7 @@ data class VaultCommonItemTypeHandlers(
val onAttachmentPreviewClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit,
val onCopyNotesClick: () -> Unit,
val onPasswordHistoryClick: () -> Unit,
val onPremiumRequiredClick: () -> Unit,
val onUpgradeToPremiumClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
@@ -61,6 +62,9 @@ data class VaultCommonItemTypeHandlers(
onPasswordHistoryClick = {
viewModel.trySendAction(VaultItemAction.Common.PasswordHistoryClick)
},
onPremiumRequiredClick = {
viewModel.trySendAction(VaultItemAction.Common.PremiumRequiredClick)
},
onUpgradeToPremiumClick = {
viewModel.trySendAction(VaultItemAction.Common.UpgradeToPremiumClick)
},
@@ -268,24 +268,35 @@ class VaultItemScreenTest : BitwardenComposeTest() {
}
@Test
fun `ArchiveRequiresPremium dialog should display based on state`() {
fun `RequiresPremium dialog should display based on state`() {
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.ArchiveRequiresPremium)
it.copy(
dialog = VaultItemState.DialogState.RequiresPremium(
message = "You need Premium".asText(),
),
)
}
composeTestRule
.onNodeWithText(text = "Premium subscription required")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "You need Premium")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Suppress("MaxLineLength")
@Test
fun `ArchiveRequiresPremium dialog on upgrade to Premium click should emit UpgradeToPremiumClick`() {
fun `RequiresPremium dialog on upgrade to Premium click should emit UpgradeToPremiumClick`() {
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.ArchiveRequiresPremium)
it.copy(
dialog = VaultItemState.DialogState.RequiresPremium(
message = "You need Premium".asText(),
),
)
}
composeTestRule
@@ -2374,6 +2385,28 @@ class VaultItemScreenTest : BitwardenComposeTest() {
}
}
@Test
fun `in login state, on premium required click should send PremiumRequiredClick`() {
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = DEFAULT_LOGIN_VIEW_STATE.copy(
type = DEFAULT_LOGIN.copy(
canViewTotpCode = false,
),
),
)
}
composeTestRule.onNodeWithTextAfterScroll(text = "Authenticator key")
composeTestRule
.onNodeWithText(text = "Premium subscription required")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.Common.PremiumRequiredClick)
}
}
@Test
fun `in login state, on totp help tooltip click should send AuthenticatorHelpToolTipClick`() {
mutableStateFlow.update { currentState ->
@@ -266,7 +266,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
@Test
fun `ArchiveClick without Premium should show ArchiveRequiresPremium dialog`() = runTest {
fun `ArchiveClick without Premium should show RequiresPremium dialog`() = runTest {
mutableUserStateFlow.update {
it?.copy(accounts = listOf(DEFAULT_USER_ACCOUNT.copy(isPremium = false)))
}
@@ -277,7 +277,25 @@ class VaultItemViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_STATE.copy(
hasPremium = false,
dialog = VaultItemState.DialogState.ArchiveRequiresPremium,
dialog = VaultItemState.DialogState.RequiresPremium(
message = BitwardenString.archiving_items_is_a_premium_feature.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `PremiumRequiredClick should show RequiresPremium dialog`() = runTest {
val viewModel = createViewModel(state = null)
viewModel.trySendAction(VaultItemAction.Common.PremiumRequiredClick)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultItemState.DialogState.RequiresPremium(
message = BitwardenString.totp_is_a_premium_feature.asText(),
),
),
viewModel.stateFlow.value,
)
+1
View File
@@ -1210,6 +1210,7 @@ Do you want to switch to this account?</string>
<string name="item_moved_to_archived">Item moved to archive</string>
<string name="item_moved_to_vault">Item moved to vault</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="totp_is_a_premium_feature">Authenticator key (TOTP) 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="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, youll need to login to your web vault on a computer.</string>