PM-12772 Add notification action card to security settings when applicable (#4008)

This commit is contained in:
Dave Severns 2024-10-02 08:27:45 -04:00 committed by GitHub
parent 9e4119fe32
commit 8e092ef860
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 410 additions and 67 deletions

View file

@ -48,7 +48,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCardSmall
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
@ -213,7 +213,7 @@ private fun CompleteRegistrationContent(
.standardHorizontalMargin(), .standardHorizontalMargin(),
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
BitwardenActionCard( BitwardenActionCardSmall(
actionIcon = rememberVectorPainter(id = R.drawable.ic_tooltip), actionIcon = rememberVectorPainter(id = R.drawable.ic_tooltip),
actionText = stringResource(id = R.string.what_makes_a_password_strong), actionText = stringResource(id = R.string.what_makes_a_password_strong),
callToActionText = stringResource(id = R.string.learn_more), callToActionText = stringResource(id = R.string.learn_more),

View file

@ -35,7 +35,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCardSmall
import com.x8bit.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider import com.x8bit.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
@ -158,7 +158,7 @@ private fun TryGeneratorCard(
onCardClicked: () -> Unit, onCardClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BitwardenActionCard( BitwardenActionCardSmall(
actionIcon = rememberVectorPainter(id = R.drawable.ic_generator), actionIcon = rememberVectorPainter(id = R.drawable.ic_generator),
actionText = stringResource( actionText = stringResource(
R.string.use_the_generator_to_create_a_strong_unique_password, R.string.use_the_generator_to_create_a_strong_unique_password,

View file

@ -1,117 +1,123 @@
package com.x8bit.bitwarden.ui.platform.components.card package com.x8bit.bitwarden.ui.platform.components.card
import androidx.compose.foundation.layout.Box import android.content.res.Configuration
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.VectorPainter import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.badge.NotificationBadge
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/** /**
* A reusable card for displaying actions to the user. * A design component action card, which contains a title, action button, and a dismiss button
* by default, with optional leading icon content.
*
* @param cardTitle The title of the card.
* @param actionText The text content on the CTA button.
* @param onActionClick The action to perform when the CTA button is clicked.
* @param onDismissClick The action to perform when the dismiss button is clicked.
* @param leadingContent Optional content to display on the leading side of the
* [cardTitle] [Text].
*/ */
@Composable @Composable
fun BitwardenActionCard( fun BitwardenActionCard(
actionIcon: VectorPainter, cardTitle: String,
actionText: String, actionText: String,
callToActionText: String, onActionClick: () -> Unit,
onCardClicked: () -> Unit, onDismissClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trailingContent: (@Composable BoxScope.() -> Unit)? = null, leadingContent: @Composable (() -> Unit)? = null,
) { ) {
Card( Card(
onClick = onCardClicked,
shape = RoundedCornerShape(size = 16.dp),
modifier = modifier, modifier = modifier,
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, containerColor = BitwardenTheme.colorScheme.background.tertiary,
), ),
elevation = CardDefaults.elevatedCardElevation(), elevation = CardDefaults.elevatedCardElevation(),
border = BorderStroke(width = 1.dp, color = BitwardenTheme.colorScheme.stroke.border),
) { ) {
Row( Column(
modifier = Modifier modifier = Modifier.padding(16.dp),
.fillMaxWidth()
.padding(16.dp),
) { ) {
Icon( Row(
painter = actionIcon, modifier = Modifier.fillMaxWidth(),
contentDescription = null, verticalAlignment = Alignment.CenterVertically,
tint = MaterialTheme.colorScheme.primary, ) {
modifier = Modifier.size(24.dp), leadingContent?.let {
it()
Spacer(Modifier.width(12.dp))
}
Text(
text = cardTitle,
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
)
Spacer(Modifier.weight(1f))
BitwardenStandardIconButton(
painter = rememberVectorPainter(id = R.drawable.ic_close),
contentDescription = stringResource(id = R.string.close),
onClick = onDismissClick,
contentColor = BitwardenTheme.colorScheme.icon.primary,
modifier = Modifier.offset(x = 8.dp),
)
}
Spacer(Modifier.height(16.dp))
BitwardenFilledButton(
actionText,
onClick = onActionClick,
modifier = Modifier.fillMaxWidth(),
) )
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(weight = 1f),
) {
Text(
text = actionText,
style = BitwardenTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = callToActionText,
style = BitwardenTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(16.dp))
Box(
modifier = Modifier
.align(Alignment.CenterVertically),
) {
trailingContent?.invoke(this)
}
} }
} }
} }
@Preview @Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
private fun ActionCardPreview() { private fun BitwardenActionCard_preview() {
BitwardenTheme { BitwardenTheme {
BitwardenActionCard( BitwardenActionCard(
actionIcon = rememberVectorPainter(id = R.drawable.ic_generator), cardTitle = "Title",
actionText = "This is an action.", actionText = "Action",
callToActionText = "Take action", onActionClick = {},
onCardClicked = { }, onDismissClick = {},
) )
} }
} }
@Preview @Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
private fun ActionCardWithTrailingPreview() { private fun BitwardenActionCardWithLeadingContent_preview() {
BitwardenTheme { BitwardenTheme {
BitwardenActionCard( BitwardenActionCard(
actionIcon = rememberVectorPainter(id = R.drawable.ic_generator), cardTitle = "Title",
actionText = "An action with trailing content", actionText = "Action",
callToActionText = "Take action", onActionClick = {},
onCardClicked = {}, onDismissClick = {},
trailingContent = { leadingContent = {
Icon( NotificationBadge(
painter = rememberVectorPainter(id = R.drawable.ic_navigate_next), notificationCount = 1,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}, },
) )

View file

@ -0,0 +1,119 @@
package com.x8bit.bitwarden.ui.platform.components.card
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.VectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A reusable card for displaying actions to the user.
*/
@Composable
fun BitwardenActionCardSmall(
actionIcon: VectorPainter,
actionText: String,
callToActionText: String,
onCardClicked: () -> Unit,
modifier: Modifier = Modifier,
trailingContent: (@Composable BoxScope.() -> Unit)? = null,
) {
Card(
onClick = onCardClicked,
shape = RoundedCornerShape(size = 16.dp),
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = BitwardenTheme.colorScheme.background.tertiary,
),
elevation = CardDefaults.elevatedCardElevation(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Icon(
painter = actionIcon,
contentDescription = null,
tint = BitwardenTheme.colorScheme.icon.secondary,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(weight = 1f),
) {
Text(
text = actionText,
style = BitwardenTheme.typography.bodyLarge,
color = BitwardenTheme.colorScheme.text.primary,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = callToActionText,
style = BitwardenTheme.typography.labelLarge,
color = BitwardenTheme.colorScheme.text.primary,
)
}
Spacer(modifier = Modifier.width(16.dp))
Box(
modifier = Modifier
.align(Alignment.CenterVertically),
) {
trailingContent?.invoke(this)
}
}
}
}
@Preview
@Composable
private fun ActionCardSmall_preview() {
BitwardenTheme {
BitwardenActionCardSmall(
actionIcon = rememberVectorPainter(id = R.drawable.ic_generator),
actionText = "This is an action.",
callToActionText = "Take action",
onCardClicked = { },
)
}
}
@Preview
@Composable
private fun ActionCardSmallWithTrailingIcon_preview() {
BitwardenTheme {
BitwardenActionCardSmall(
actionIcon = rememberVectorPainter(id = R.drawable.ic_generator),
actionText = "An action with trailing content",
callToActionText = "Take action",
onCardClicked = {},
trailingContent = {
Icon(
painter = rememberVectorPainter(id = R.drawable.ic_navigate_next),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
}

View file

@ -96,8 +96,8 @@ data class SettingsState(
private val securityCount: Int, private val securityCount: Int,
) { ) {
val notificationBadgeCountMap: Map<Settings, Int> = mapOf( val notificationBadgeCountMap: Map<Settings, Int> = mapOf(
Settings.ACCOUNT_SECURITY to autoFillCount, Settings.ACCOUNT_SECURITY to securityCount,
Settings.AUTO_FILL to securityCount, Settings.AUTO_FILL to autoFillCount,
) )
} }

View file

@ -1,6 +1,9 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -21,6 +24,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -36,8 +40,11 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.badge.NotificationBadge
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
@ -175,6 +182,33 @@ fun AccountSecurityScreen(
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
AnimatedVisibility(
visible = state.shouldShowUnlockActionCard,
label = "UnlockActionCard",
exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top),
) {
BitwardenActionCard(
cardTitle = stringResource(id = R.string.set_up_unlock),
actionText = stringResource(R.string.get_started),
onActionClick = remember(viewModel) {
{
viewModel.trySendAction(AccountSecurityAction.UnlockActionCardCtaClick)
}
},
onDismissClick = remember(viewModel) {
{
viewModel.trySendAction(AccountSecurityAction.UnlockActionCardDismiss)
}
},
leadingContent = {
NotificationBadge(notificationCount = 1)
},
modifier = Modifier
.standardHorizontalMargin()
.padding(top = 12.dp, bottom = 16.dp),
)
}
BitwardenListHeaderText( BitwardenListHeaderText(
label = stringResource(id = R.string.approve_login_requests), label = stringResource(id = R.string.approve_login_requests),
modifier = Modifier modifier = Modifier

View file

@ -77,6 +77,7 @@ class AccountSecurityViewModel @Inject constructor(
vaultTimeoutAction = settingsRepository.vaultTimeoutAction, vaultTimeoutAction = settingsRepository.vaultTimeoutAction,
vaultTimeoutPolicyMinutes = null, vaultTimeoutPolicyMinutes = null,
vaultTimeoutPolicyAction = null, vaultTimeoutPolicyAction = null,
shouldShowUnlockActionCard = false,
) )
}, },
) { ) {
@ -113,6 +114,14 @@ class AccountSecurityViewModel @Inject constructor(
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
settingsRepository
.getShowUnlockBadgeFlow(state.userId)
.map {
AccountSecurityAction.Internal.ShowUnlockBadgeUpdated(it)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
viewModelScope.launch { viewModelScope.launch {
trySendAction( trySendAction(
AccountSecurityAction.Internal.FingerprintResultReceive( AccountSecurityAction.Internal.FingerprintResultReceive(
@ -146,6 +155,17 @@ class AccountSecurityViewModel @Inject constructor(
is AccountSecurityAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action) is AccountSecurityAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action)
is AccountSecurityAction.PushNotificationConfirm -> handlePushNotificationConfirm() is AccountSecurityAction.PushNotificationConfirm -> handlePushNotificationConfirm()
is AccountSecurityAction.Internal -> handleInternalAction(action) is AccountSecurityAction.Internal -> handleInternalAction(action)
AccountSecurityAction.UnlockActionCardCtaClick -> handleUnlockCardCtaClick()
AccountSecurityAction.UnlockActionCardDismiss -> handleUnlockCardDismiss()
}
private fun handleUnlockCardDismiss() {
dismissUnlockNotificationBadge()
}
private fun handleUnlockCardCtaClick() {
dismissUnlockNotificationBadge()
// TODO: Navigate to unlock set up screen PM-13067
} }
private fun handleAccountFingerprintPhraseClick() { private fun handleAccountFingerprintPhraseClick() {
@ -199,6 +219,7 @@ class AccountSecurityViewModel @Inject constructor(
) )
} }
} }
dismissUnlockNotificationBadge()
} }
private fun handleFingerPrintLearnMoreClick() { private fun handleFingerPrintLearnMoreClick() {
@ -310,6 +331,7 @@ class AccountSecurityViewModel @Inject constructor(
) )
} }
} }
dismissUnlockNotificationBadge()
} }
private fun handleInternalAction(action: AccountSecurityAction.Internal) { private fun handleInternalAction(action: AccountSecurityAction.Internal) {
@ -329,6 +351,20 @@ class AccountSecurityViewModel @Inject constructor(
is AccountSecurityAction.Internal.PolicyUpdateReceive -> { is AccountSecurityAction.Internal.PolicyUpdateReceive -> {
handlePolicyUpdateReceive(action) handlePolicyUpdateReceive(action)
} }
is AccountSecurityAction.Internal.ShowUnlockBadgeUpdated -> {
handleShowUnlockBadgeUpdated(action)
}
}
}
private fun handleShowUnlockBadgeUpdated(
action: AccountSecurityAction.Internal.ShowUnlockBadgeUpdated,
) {
mutableStateFlow.update {
it.copy(
shouldShowUnlockActionCard = action.showUnlockBadge,
)
} }
} }
@ -404,6 +440,14 @@ class AccountSecurityViewModel @Inject constructor(
mutableStateFlow.update { it.copy(vaultTimeoutAction = vaultTimeoutAction) } mutableStateFlow.update { it.copy(vaultTimeoutAction = vaultTimeoutAction) }
settingsRepository.vaultTimeoutAction = vaultTimeoutAction settingsRepository.vaultTimeoutAction = vaultTimeoutAction
} }
private fun dismissUnlockNotificationBadge() {
if (!state.shouldShowUnlockActionCard) return
settingsRepository.storeShowUnlockSettingBadge(
userId = state.userId,
showBadge = false,
)
}
} }
/** /**
@ -423,6 +467,7 @@ data class AccountSecurityState(
val vaultTimeoutAction: VaultTimeoutAction, val vaultTimeoutAction: VaultTimeoutAction,
val vaultTimeoutPolicyMinutes: Int?, val vaultTimeoutPolicyMinutes: Int?,
val vaultTimeoutPolicyAction: String?, val vaultTimeoutPolicyAction: String?,
val shouldShowUnlockActionCard: Boolean,
) : Parcelable { ) : Parcelable {
/** /**
* Indicates that there is a mechanism for unlocking your vault in place. * Indicates that there is a mechanism for unlocking your vault in place.
@ -633,6 +678,16 @@ sealed class AccountSecurityAction {
val unlockWithPinState: UnlockWithPinState, val unlockWithPinState: UnlockWithPinState,
) : AccountSecurityAction() ) : AccountSecurityAction()
/**
* User has dismissed the unlock action card.
*/
data object UnlockActionCardDismiss : AccountSecurityAction()
/**
* User has clicked the CTA on the unlock action card.
*/
data object UnlockActionCardCtaClick : AccountSecurityAction()
/** /**
* Models actions that can be sent by the view model itself. * Models actions that can be sent by the view model itself.
*/ */
@ -665,5 +720,10 @@ sealed class AccountSecurityAction {
data class PolicyUpdateReceive( data class PolicyUpdateReceive(
val vaultTimeoutPolicies: List<PolicyInformation.VaultTimeout>?, val vaultTimeoutPolicies: List<PolicyInformation.VaultTimeout>?,
) : Internal() ) : Internal()
/**
* The show unlock badge update has been received.
*/
data class ShowUnlockBadgeUpdated(val showUnlockBadge: Boolean) : Internal()
} }
} }

View file

@ -1009,4 +1009,5 @@ Do you want to switch to this account?</string>
<string name="error_connecting_with_the_duo_service_use_a_different_two_step_login_method_or_contact_duo_for_assistance">Error connecting with the Duo service. Use a different two-step login method or contact Duo for assistance.</string> <string name="error_connecting_with_the_duo_service_use_a_different_two_step_login_method_or_contact_duo_for_assistance">Error connecting with the Duo service. Use a different two-step login method or contact Duo for assistance.</string>
<string name="master_password_hint_not_specified">Master password hint</string> <string name="master_password_hint_not_specified">Master password hint</string>
<string name="master_password_important_hint">Important: Your master password cannot be recovered if you forget it! 12 characters minimum.</string> <string name="master_password_important_hint">Important: Your master password cannot be recovered if you forget it! 12 characters minimum.</string>
<string name="get_started">Get started</string>
</resources> </resources>

View file

@ -1488,6 +1488,42 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.performClick() .performClick()
verify { viewModel.trySendAction(AccountSecurityAction.AuthenticatorSyncToggle(true)) } verify { viewModel.trySendAction(AccountSecurityAction.AuthenticatorSyncToggle(true)) }
} }
@Test
fun `unlock action card should show when state is true and hide when false`() {
composeTestRule
.onNodeWithText("Get started")
.assertDoesNotExist()
mutableStateFlow.update { DEFAULT_STATE.copy(shouldShowUnlockActionCard = true) }
composeTestRule
.onNodeWithText("Get started")
.assertIsDisplayed()
mutableStateFlow.update { DEFAULT_STATE.copy(shouldShowUnlockActionCard = false) }
composeTestRule
.onNodeWithText("Get started")
.assertDoesNotExist()
}
@Test
fun `when unlock action card is visible clicking the cta button should send correct action`() {
mutableStateFlow.update { DEFAULT_STATE.copy(shouldShowUnlockActionCard = true) }
composeTestRule
.onNodeWithText("Get started")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(AccountSecurityAction.UnlockActionCardCtaClick) }
}
@Test
fun `when unlock action card is visible clicking dismissing should send correct action`() {
mutableStateFlow.update { DEFAULT_STATE.copy(shouldShowUnlockActionCard = true) }
composeTestRule
.onNodeWithContentDescription("Close")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(AccountSecurityAction.UnlockActionCardDismiss) }
}
} }
private val CIPHER = mockk<Cipher>() private val CIPHER = mockk<Cipher>()
@ -1505,4 +1541,5 @@ private val DEFAULT_STATE = AccountSecurityState(
vaultTimeoutAction = VaultTimeoutAction.LOCK, vaultTimeoutAction = VaultTimeoutAction.LOCK,
vaultTimeoutPolicyMinutes = null, vaultTimeoutPolicyMinutes = null,
vaultTimeoutPolicyAction = null, vaultTimeoutPolicyAction = null,
shouldShowUnlockActionCard = false,
) )

View file

@ -37,6 +37,7 @@ import io.mockk.runs
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
@ -45,6 +46,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import javax.crypto.Cipher import javax.crypto.Cipher
@Suppress("LargeClass")
class AccountSecurityViewModelTest : BaseViewModelTest() { class AccountSecurityViewModelTest : BaseViewModelTest() {
private val fakeEnvironmentRepository = FakeEnvironmentRepository() private val fakeEnvironmentRepository = FakeEnvironmentRepository()
@ -53,6 +55,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
every { userStateFlow } returns mutableUserStateFlow every { userStateFlow } returns mutableUserStateFlow
} }
private val vaultRepository: VaultRepository = mockk(relaxed = true) private val vaultRepository: VaultRepository = mockk(relaxed = true)
private val mutableShowUnlockBadgeFlow = MutableStateFlow(false)
private val settingsRepository: SettingsRepository = mockk { private val settingsRepository: SettingsRepository = mockk {
every { isAuthenticatorSyncEnabled } returns false every { isAuthenticatorSyncEnabled } returns false
every { isUnlockWithBiometricsEnabled } returns false every { isUnlockWithBiometricsEnabled } returns false
@ -60,6 +63,8 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
every { vaultTimeout } returns VaultTimeout.ThirtyMinutes every { vaultTimeout } returns VaultTimeout.ThirtyMinutes
every { vaultTimeoutAction } returns VaultTimeoutAction.LOCK every { vaultTimeoutAction } returns VaultTimeoutAction.LOCK
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
every { getShowUnlockBadgeFlow(any()) } returns mutableShowUnlockBadgeFlow
every { storeShowUnlockSettingBadge(any(), false) } just runs
} }
private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>() private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk { private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk {
@ -359,6 +364,21 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
awaitItem(), awaitItem(),
) )
} }
verify(exactly = 0) {
settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false)
}
}
@Test
fun `on EnableBiometricsClick should update user show unlock badge status if shown`() {
mutableShowUnlockBadgeFlow.update { true }
val viewModel = createViewModel()
viewModel.trySendAction(AccountSecurityAction.EnableBiometricsClick)
verify(exactly = 1) {
settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false)
}
} }
@Test @Test
@ -576,6 +596,41 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
shouldRequireMasterPasswordOnRestart = true, shouldRequireMasterPasswordOnRestart = true,
) )
} }
verify(exactly = 0) {
settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false)
}
}
@Suppress("MaxLineLength")
@Test
fun `on UnlockWithPinToggle Enabled should update show unlock badge state if card is visible`() {
mutableShowUnlockBadgeFlow.update { true }
val initialState = DEFAULT_STATE.copy(
isUnlockWithPinEnabled = false,
)
every { settingsRepository.storeUnlockPin(any(), any()) } just runs
val viewModel = createViewModel(initialState = initialState)
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle(
UnlockWithPinState.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = true,
),
),
)
assertEquals(
initialState.copy(isUnlockWithPinEnabled = true, shouldShowUnlockActionCard = true),
viewModel.stateFlow.value,
)
verify {
settingsRepository.storeUnlockPin(
pin = "1234",
shouldRequireMasterPasswordOnRestart = true,
)
settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false)
}
} }
@Test @Test
@ -652,6 +707,36 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `when showUnlockBadgeFlow updates value, should update state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableShowUnlockBadgeFlow.update { true }
assertEquals(DEFAULT_STATE.copy(shouldShowUnlockActionCard = true), awaitItem())
}
}
@Test
fun `when UnlockActionCardDismiss action received, should dismiss unlock action card`() {
mutableShowUnlockBadgeFlow.update { true }
val viewModel = createViewModel()
viewModel.trySendAction(AccountSecurityAction.UnlockActionCardDismiss)
verify {
settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false)
}
}
@Test
fun `when UnlockActionCardCtaClick action received, should dismiss unlock action card`() {
mutableShowUnlockBadgeFlow.update { true }
val viewModel = createViewModel()
viewModel.trySendAction(AccountSecurityAction.UnlockActionCardCtaClick)
verify {
settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false)
}
}
@Suppress("LongParameterList") @Suppress("LongParameterList")
private fun createViewModel( private fun createViewModel(
initialState: AccountSecurityState? = DEFAULT_STATE, initialState: AccountSecurityState? = DEFAULT_STATE,
@ -716,4 +801,5 @@ private val DEFAULT_STATE: AccountSecurityState = AccountSecurityState(
vaultTimeoutPolicyMinutes = null, vaultTimeoutPolicyMinutes = null,
vaultTimeoutPolicyAction = null, vaultTimeoutPolicyAction = null,
shouldShowEnableAuthenticatorSync = false, shouldShowEnableAuthenticatorSync = false,
shouldShowUnlockActionCard = false,
) )