Skip to content

Commit d7e67b3

Browse files
committed
[PM-37287] feat: Make Premium subscription required text tappable under authenticator key
The "Premium subscription required" placeholder under the Authenticator key on the View and Edit login screens was inert text, leaving non-Premium users with no path to learn why TOTP is gated or how to upgrade. Tapping it now opens an upsell dialog that routes through the existing in-app upgrade flow, making the gated state actionable from where the user encounters it. The new behavior is gated on the in-app Premium upgrade flow being available (MobilePremiumUpgrade feature flag + in-app billing support). When the flow is unavailable, both screens fall back to their original presentation. The gate is observed reactively so the screens respond to flag changes while visible.
1 parent 3732672 commit d7e67b3

17 files changed

Lines changed: 446 additions & 16 deletions

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ fun LazyListScope.vaultAddEditLoginItems(
134134
TotpRow(
135135
totpKey = loginState.totp,
136136
canViewTotp = loginState.canViewPassword,
137+
isAuthenticatorKeyPremiumGated = loginState.isAuthenticatorKeyPremiumGated,
137138
loginItemTypeHandlers = loginItemTypeHandlers,
138139
onTotpSetupClick = onTotpSetupClick,
139140
modifier = Modifier.fillMaxWidth(),
@@ -362,6 +363,7 @@ private fun CoachMarkScope<AddEditItemCoachMark>.PasswordRow(
362363
private fun TotpRow(
363364
totpKey: String?,
364365
canViewTotp: Boolean,
366+
isAuthenticatorKeyPremiumGated: Boolean,
365367
loginItemTypeHandlers: VaultAddEditLoginTypeHandlers,
366368
onTotpSetupClick: () -> Unit,
367369
modifier: Modifier = Modifier,
@@ -393,18 +395,31 @@ private fun TotpRow(
393395
),
394396
supportingContentPadding = PaddingValues(),
395397
supportingContent = {
396-
BitwardenClickableText(
397-
label = stringResource(id = BitwardenString.set_up_authenticator_key),
398-
onClick = onTotpSetupClick,
399-
leadingIcon = painterResource(id = BitwardenDrawable.ic_camera_small),
400-
style = BitwardenTheme.typography.labelMedium,
401-
innerPadding = PaddingValues(all = 16.dp),
402-
isEnabled = canViewTotp,
403-
cornerSize = 0.dp,
404-
modifier = Modifier
405-
.fillMaxWidth()
406-
.testTag("SetupTotpButton"),
407-
)
398+
if (isAuthenticatorKeyPremiumGated) {
399+
BitwardenClickableText(
400+
label = stringResource(id = BitwardenString.premium_subscription_required),
401+
onClick = loginItemTypeHandlers.onTotpRequiresPremiumClick,
402+
style = BitwardenTheme.typography.labelMedium,
403+
innerPadding = PaddingValues(all = 16.dp),
404+
cornerSize = 0.dp,
405+
modifier = Modifier
406+
.fillMaxWidth()
407+
.testTag("LoginTotpPremiumRequired"),
408+
)
409+
} else {
410+
BitwardenClickableText(
411+
label = stringResource(id = BitwardenString.set_up_authenticator_key),
412+
onClick = onTotpSetupClick,
413+
leadingIcon = painterResource(id = BitwardenDrawable.ic_camera_small),
414+
style = BitwardenTheme.typography.labelMedium,
415+
innerPadding = PaddingValues(all = 16.dp),
416+
isEnabled = canViewTotp,
417+
cornerSize = 0.dp,
418+
modifier = Modifier
419+
.fillMaxWidth()
420+
.testTag("SetupTotpButton"),
421+
)
422+
}
408423
},
409424
passwordFieldTestTag = "LoginTotpEntry",
410425
cardStyle = CardStyle.Full,

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ fun VaultAddEditScreen(
299299
onUpgradeToPremiumClick = {
300300
viewModel.trySendAction(VaultAddEditAction.Common.UpgradeToPremiumClick)
301301
},
302+
onNavigateToPlanClick = {
303+
viewModel.trySendAction(VaultAddEditAction.Common.NavigateToPlanClick)
304+
},
302305
onCameraPermissionSettingsClick = {
303306
viewModel.trySendAction(
304307
VaultAddEditAction.Common.CameraPermissionSettingsClick,
@@ -495,6 +498,7 @@ private fun VaultAddEditItemDialogs(
495498
onRetryPinSetUpFido2Verification: () -> Unit,
496499
onDismissFido2Verification: () -> Unit,
497500
onUpgradeToPremiumClick: () -> Unit,
501+
onNavigateToPlanClick: () -> Unit,
498502
onCameraPermissionSettingsClick: () -> Unit,
499503
) {
500504
when (dialogState) {
@@ -510,6 +514,20 @@ private fun VaultAddEditItemDialogs(
510514
)
511515
}
512516

517+
is VaultAddEditState.DialogState.TotpRequiresPremium -> {
518+
BitwardenTwoButtonDialog(
519+
title = stringResource(id = BitwardenString.premium_subscription_required),
520+
message = stringResource(
521+
id = BitwardenString.authenticator_key_is_a_premium_feature,
522+
),
523+
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
524+
dismissButtonText = stringResource(id = BitwardenString.cancel),
525+
onConfirmClick = onNavigateToPlanClick,
526+
onDismissClick = onDismissRequest,
527+
onDismissRequest = onDismissRequest,
528+
)
529+
}
530+
513531
is VaultAddEditState.DialogState.Loading -> {
514532
BitwardenLoadingDialog(text = dialogState.label())
515533
}

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.util.appendFolderAndOwnerDat
7979
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toAvailableFolders
8080
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toDefaultAddTypeContent
8181
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toItemType
82+
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.withAuthenticatorKeyPremiumGate
8283
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toViewState
8384
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.validateCipherOrReturnErrorState
8485
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.messageResourceId
@@ -187,14 +188,17 @@ class VaultAddEditViewModel @Inject constructor(
187188
null
188189
}
189190

191+
val hasPremium =
192+
authRepository.userStateFlow.value?.activeAccount?.isPremium == true
193+
190194
VaultAddEditState(
191195
isCardScannerEnabled = featureFlagManager
192196
.getFeatureFlag(FlagKey.CardScanner) && !buildInfoManager.isFdroid,
193197
vaultAddEditType = vaultAddEditType,
194198
cipherType = vaultCipherType,
195199
viewState = when (vaultAddEditType) {
196200
is VaultAddEditType.AddItem -> {
197-
autofillSelectionData
201+
(autofillSelectionData
198202
?.toDefaultAddTypeContent(isIndividualVaultDisabled)
199203
?: autofillSaveItem?.toDefaultAddTypeContent(isIndividualVaultDisabled)
200204
?: providerCreateCredentialRequest?.toDefaultAddTypeContent(
@@ -209,7 +213,8 @@ class VaultAddEditViewModel @Inject constructor(
209213
),
210214
isIndividualVaultDisabled = isIndividualVaultDisabled,
211215
type = vaultCipherType.toItemType(),
212-
)
216+
))
217+
.withAuthenticatorKeyPremiumGate(isPremium = hasPremium)
213218
}
214219

215220
is VaultAddEditType.EditItem -> VaultAddEditState.ViewState.Loading
@@ -226,7 +231,7 @@ class VaultAddEditViewModel @Inject constructor(
226231
shouldShowCoachMarkTour = false,
227232
shouldClearSpecialCircumstance = autofillSelectionData == null,
228233
defaultUriMatchType = settingsRepository.defaultUriMatchType,
229-
hasPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true,
234+
hasPremium = hasPremium,
230235
)
231236
},
232237
) {
@@ -352,6 +357,7 @@ class VaultAddEditViewModel @Inject constructor(
352357
is VaultAddEditAction.Common.ArchiveClick -> handleArchiveClick()
353358
is VaultAddEditAction.Common.UnarchiveClick -> handleUnarchiveClick()
354359
VaultAddEditAction.Common.UpgradeToPremiumClick -> handleUpgradeToPremiumClick()
360+
VaultAddEditAction.Common.NavigateToPlanClick -> handleNavigateToPlanClick()
355361
is VaultAddEditAction.Common.ConfirmDeleteClick -> handleConfirmDeleteClick()
356362
is VaultAddEditAction.Common.CloseClick -> handleCloseClick()
357363
is VaultAddEditAction.Common.DismissDialog -> handleDismissDialog()
@@ -672,6 +678,7 @@ class VaultAddEditViewModel @Inject constructor(
672678
}
673679

674680
private fun handleUpgradeToPremiumClick() {
681+
mutableStateFlow.update { it.copy(dialog = null) }
675682
if (premiumStateManager.isInAppUpgradeAvailable()) {
676683
sendEvent(VaultAddEditEvent.NavigateToPlanModal)
677684
} else {
@@ -687,6 +694,11 @@ class VaultAddEditViewModel @Inject constructor(
687694
}
688695
}
689696

697+
private fun handleNavigateToPlanClick() {
698+
mutableStateFlow.update { it.copy(dialog = null) }
699+
sendEvent(VaultAddEditEvent.NavigateToPlanModal)
700+
}
701+
690702
private fun handleConfirmDeleteClick() {
691703
mutableStateFlow.update {
692704
it.copy(
@@ -1213,6 +1225,16 @@ class VaultAddEditViewModel @Inject constructor(
12131225
VaultAddEditAction.ItemType.LoginType.LearnMoreClick -> {
12141226
handleLearnMoreClick()
12151227
}
1228+
1229+
VaultAddEditAction.ItemType.LoginType.TotpRequiresPremiumClick -> {
1230+
handleTotpRequiresPremiumClick()
1231+
}
1232+
}
1233+
}
1234+
1235+
private fun handleTotpRequiresPremiumClick() {
1236+
mutableStateFlow.update {
1237+
it.copy(dialog = VaultAddEditState.DialogState.TotpRequiresPremium)
12161238
}
12171239
}
12181240

@@ -2998,6 +3020,10 @@ data class VaultAddEditState(
29983020
* @property canViewPassword Indicates whether the current user can view and copy
29993021
* passwords associated with the login item.
30003022
* @property canEditItem Indicates whether the current user can edit the login item.
3023+
* @property isAuthenticatorKeyPremiumGated `true` when the active user lacks the
3024+
* entitlement required to use the authenticator key (TOTP) for this cipher —
3025+
* neither Premium nor an `organizationUseTotp` grant. Used solely to swap the
3026+
* authenticator key supporting content for a Premium upsell.
30013027
* @property fido2CredentialCreationDateTime Date and time the FIDO 2 credential was
30023028
* created.
30033029
*/
@@ -3008,6 +3034,7 @@ data class VaultAddEditState(
30083034
val totp: String? = null,
30093035
val canViewPassword: Boolean = true,
30103036
val canEditItem: Boolean = true,
3037+
val isAuthenticatorKeyPremiumGated: Boolean = false,
30113038
val uriList: List<UriItem> = listOf(
30123039
UriItem(
30133040
id = UUID.randomUUID().toString(),
@@ -3369,6 +3396,12 @@ data class VaultAddEditState(
33693396
*/
33703397
data object ArchiveRequiresPremium : DialogState()
33713398

3399+
/**
3400+
* Displays a dialog to the user indicating that the authenticator key (TOTP) requires a
3401+
* Premium account.
3402+
*/
3403+
data object TotpRequiresPremium : DialogState()
3404+
33723405
/**
33733406
* Displays a generic dialog to the user.
33743407
*/
@@ -3658,6 +3691,11 @@ sealed class VaultAddEditAction {
36583691
*/
36593692
data object UpgradeToPremiumClick : Common()
36603693

3694+
/**
3695+
* The user has clicked an upgrade CTA that should always navigate to the Plan screen.
3696+
*/
3697+
data object NavigateToPlanClick : Common()
3698+
36613699
/**
36623700
* The user has confirmed to deleted the cipher.
36633701
*/
@@ -3952,6 +3990,12 @@ sealed class VaultAddEditAction {
39523990
* User has clicked the call to action on the learn more help link.
39533991
*/
39543992
data object LearnMoreClick : LoginType()
3993+
3994+
/**
3995+
* The user has clicked the Premium subscription required CTA shown in place of the
3996+
* authenticator key when the active account lacks the Premium entitlement.
3997+
*/
3998+
data object TotpRequiresPremiumClick : LoginType()
39553999
}
39564000

39574001
/**

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem
2828
* is clicked.
2929
* @property onAuthenticatorHelpToolTipClick Handles the action when the authenticator help tooltip
3030
* is clicked.
31+
* @property onTotpRequiresPremiumClick Handles the action when the Premium subscription required
32+
* CTA is clicked in place of the authenticator key for non-Premium accounts.
3133
*/
3234
@Suppress("LongParameterList")
3335
data class VaultAddEditLoginTypeHandlers(
@@ -47,6 +49,7 @@ data class VaultAddEditLoginTypeHandlers(
4749
val onStartLoginCoachMarkTour: () -> Unit,
4850
val onDismissLearnAboutLoginsCard: () -> Unit,
4951
val onAuthenticatorHelpToolTipClick: () -> Unit,
52+
val onTotpRequiresPremiumClick: () -> Unit,
5053
val onLearnMoreClick: () -> Unit,
5154
) {
5255
@Suppress("UndocumentedPublicClass")
@@ -112,6 +115,11 @@ data class VaultAddEditLoginTypeHandlers(
112115
VaultAddEditAction.ItemType.LoginType.AuthenticatorHelpToolTipClick,
113116
)
114117
},
118+
onTotpRequiresPremiumClick = {
119+
viewModel.trySendAction(
120+
VaultAddEditAction.ItemType.LoginType.TotpRequiresPremiumClick,
121+
)
122+
},
115123
onCopyTotpKeyClick = { totpKey ->
116124
viewModel.trySendAction(
117125
VaultAddEditAction.ItemType.LoginType.CopyTotpKeyClick(

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ fun CipherView.toViewState(
5757
totp = totpData?.uri ?: login?.totp,
5858
canViewPassword = this.viewPassword,
5959
canEditItem = this.edit,
60+
isAuthenticatorKeyPremiumGated = !isPremium && !this.organizationUseTotp,
6061
uriList = login?.uris.toUriItems(),
6162
fido2CredentialCreationDateTime = login
6263
?.fido2Credentials

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensions.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,19 @@ fun VaultItemCipherType.toItemType(): VaultAddEditState.ViewState.Content.ItemTy
3636
VaultAddEditState.ViewState.Content.ItemType.Passport()
3737
}
3838
}
39+
40+
/**
41+
* Returns a copy of the [VaultAddEditState.ViewState] with the authenticator key Premium gate
42+
* applied to its Login content (if any). Used to seed the gate for Add mode, where the Login
43+
* state is constructed by factories that have no premium context. Edit and Clone modes already
44+
* set the gate via `CipherView.toViewState`, which additionally honors `organizationUseTotp`.
45+
*/
46+
fun VaultAddEditState.ViewState.withAuthenticatorKeyPremiumGate(
47+
isPremium: Boolean,
48+
): VaultAddEditState.ViewState {
49+
val content = this as? VaultAddEditState.ViewState.Content ?: return this
50+
val login = content.type as? VaultAddEditState.ViewState.Content.ItemType.Login ?: return this
51+
return content.copy(
52+
type = login.copy(isAuthenticatorKeyPremiumGated = !isPremium),
53+
)
54+
}

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ fun VaultItemLoginContent(
147147
onCopyTotpClick = vaultLoginItemTypeHandlers.onCopyTotpCodeClick,
148148
onAuthenticatorHelpToolTipClick = vaultLoginItemTypeHandlers
149149
.onAuthenticatorHelpToolTipClick,
150+
onPremiumRequiredClick = vaultLoginItemTypeHandlers
151+
.onTotpRequiresPremiumClick,
150152
modifier = Modifier
151153
.standardHorizontalMargin()
152154
.fillMaxWidth()
@@ -285,12 +287,14 @@ private fun PasswordField(
285287
}
286288
}
287289

290+
@Suppress("LongMethod")
288291
@Composable
289292
private fun TotpField(
290293
totpCodeItemData: TotpCodeItemData,
291294
enabled: Boolean,
292295
onCopyTotpClick: () -> Unit,
293296
onAuthenticatorHelpToolTipClick: () -> Unit,
297+
onPremiumRequiredClick: () -> Unit,
294298
modifier: Modifier = Modifier,
295299
) {
296300
if (enabled) {
@@ -333,7 +337,19 @@ private fun TotpField(
333337
contentDescription = stringResource(id = BitwardenString.authenticator_key_help),
334338
isExternalLink = true,
335339
),
336-
supportingText = stringResource(id = BitwardenString.premium_subscription_required),
340+
supportingContentPadding = PaddingValues(),
341+
supportingContent = {
342+
BitwardenClickableText(
343+
label = stringResource(id = BitwardenString.premium_subscription_required),
344+
onClick = onPremiumRequiredClick,
345+
style = BitwardenTheme.typography.labelMedium,
346+
innerPadding = PaddingValues(all = 16.dp),
347+
cornerSize = 0.dp,
348+
modifier = Modifier
349+
.fillMaxWidth()
350+
.testTag("LoginTotpPremiumRequired"),
351+
)
352+
},
337353
enabled = false,
338354
singleLine = false,
339355
onValueChange = { },

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,9 @@ fun VaultItemScreen(
156156
onUpgradeToPremiumClick = {
157157
viewModel.trySendAction(VaultItemAction.Common.UpgradeToPremiumClick)
158158
},
159+
onNavigateToPlanClick = {
160+
viewModel.trySendAction(VaultItemAction.Common.NavigateToPlanClick)
161+
},
159162
)
160163

161164
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -301,6 +304,7 @@ fun VaultItemScreen(
301304
}
302305
}
303306

307+
@Suppress("LongMethod")
304308
@Composable
305309
private fun VaultItemDialogs(
306310
dialog: VaultItemState.DialogState?,
@@ -309,6 +313,7 @@ private fun VaultItemDialogs(
309313
onConfirmCloneWithoutFido2Credential: () -> Unit,
310314
onConfirmRestoreAction: () -> Unit,
311315
onUpgradeToPremiumClick: () -> Unit,
316+
onNavigateToPlanClick: () -> Unit,
312317
) {
313318
when (dialog) {
314319
is VaultItemState.DialogState.ArchiveRequiresPremium -> {
@@ -323,6 +328,20 @@ private fun VaultItemDialogs(
323328
)
324329
}
325330

331+
is VaultItemState.DialogState.TotpRequiresPremium -> {
332+
BitwardenTwoButtonDialog(
333+
title = stringResource(id = BitwardenString.premium_subscription_required),
334+
message = stringResource(
335+
id = BitwardenString.authenticator_key_is_a_premium_feature,
336+
),
337+
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
338+
dismissButtonText = stringResource(id = BitwardenString.cancel),
339+
onConfirmClick = onNavigateToPlanClick,
340+
onDismissClick = onDismissRequest,
341+
onDismissRequest = onDismissRequest,
342+
)
343+
}
344+
326345
is VaultItemState.DialogState.Generic -> BitwardenBasicDialog(
327346
title = null,
328347
message = dialog.message(),

0 commit comments

Comments
 (0)