BIT-1337 Adding new section for verification codes (#567)

This commit is contained in:
Oleg Semenenko 2024-01-11 15:49:51 -06:00 committed by Álison Fernandes
parent 9e6c49fb7c
commit fc5529e2ad
8 changed files with 264 additions and 10 deletions

View file

@ -27,6 +27,7 @@ fun VaultContent(
vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit,
folderClick: (VaultState.ViewState.FolderItem) -> Unit,
collectionClick: (VaultState.ViewState.CollectionItem) -> Unit,
totpItemsClick: () -> Unit,
loginGroupClick: () -> Unit,
cardGroupClick: () -> Unit,
identityGroupClick: () -> Unit,
@ -37,6 +38,31 @@ fun VaultContent(
LazyColumn(
modifier = modifier,
) {
if (state.totpItemsCount > 0) {
item {
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.totp),
supportingLabel = "1",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
BitwardenGroupItem(
startIcon = painterResource(id = R.drawable.access_time),
label = stringResource(id = R.string.verification_codes),
supportingLabel = state.totpItemsCount.toString(),
onClick = totpItemsClick,
showDivider = true,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
}
}
if (state.favoriteItems.isNotEmpty()) {

View file

@ -34,6 +34,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.showNotYetImplementedToast
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
@ -80,6 +81,11 @@ fun VaultScreen(
.show()
}
is VaultEvent.NavigateToVerificationCodeScreen -> {
// TODO Add Verification codes detail screen (BIT-1338)
showNotYetImplementedToast(context = context)
}
is VaultEvent.NavigateToVaultItem -> onNavigateToVaultItemScreen(event.itemId)
is VaultEvent.NavigateToEditVaultItem -> onNavigateToVaultEditItemScreen(event.itemId)
@ -140,6 +146,9 @@ fun VaultScreen(
viewModel.trySendAction(VaultAction.CollectionClick(collectionItem))
}
},
verificationCodesClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.VerificationCodesClick) }
},
loginGroupClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.LoginGroupClick) }
},
@ -186,6 +195,7 @@ private fun VaultScreenScaffold(
vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit,
folderClick: (VaultState.ViewState.FolderItem) -> Unit,
collectionClick: (VaultState.ViewState.CollectionItem) -> Unit,
verificationCodesClick: () -> Unit,
loginGroupClick: () -> Unit,
cardGroupClick: () -> Unit,
identityGroupClick: () -> Unit,
@ -345,6 +355,7 @@ private fun VaultScreenScaffold(
cardGroupClick = cardGroupClick,
identityGroupClick = identityGroupClick,
secureNoteGroupClick = secureNoteGroupClick,
totpItemsClick = verificationCodesClick,
trashClick = trashClick,
modifier = innerModifier,
)

View file

@ -56,6 +56,7 @@ class VaultViewModel @Inject constructor(
accountSummaries = accountSummaries,
vaultFilterData = vaultFilterData,
viewState = VaultState.ViewState.Loading,
isPremium = userState.activeAccount.isPremium,
)
},
) {
@ -86,6 +87,7 @@ class VaultViewModel @Inject constructor(
is VaultAction.FolderClick -> handleFolderItemClick(action)
is VaultAction.CollectionClick -> handleCollectionItemClick(action)
is VaultAction.IdentityGroupClick -> handleIdentityClick()
is VaultAction.VerificationCodesClick -> handleVerificationCodeClick()
is VaultAction.LoginGroupClick -> handleLoginClick()
is VaultAction.SearchIconClick -> handleSearchIconClick()
is VaultAction.LockAccountClick -> handleLockAccountClick(action)
@ -133,6 +135,10 @@ class VaultViewModel @Inject constructor(
)
}
private fun handleVerificationCodeClick() {
sendEvent(VaultEvent.NavigateToVerificationCodeScreen)
}
private fun handleIdentityClick() {
sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.Identity))
}
@ -268,6 +274,7 @@ class VaultViewModel @Inject constructor(
mutableStateFlow.updateToErrorStateOrDialog(
vaultData = vaultData.data,
vaultFilterType = vaultFilterTypeOrDefault,
isPremium = state.isPremium,
errorTitle = R.string.an_error_has_occurred.asText(),
errorMessage = R.string.generic_error_message.asText(),
)
@ -283,7 +290,10 @@ class VaultViewModel @Inject constructor(
}
mutableStateFlow.update {
it.copy(
viewState = vaultData.data.toViewState(vaultFilterTypeOrDefault),
viewState = vaultData.data.toViewState(
isPremium = state.isPremium,
vaultFilterType = vaultFilterTypeOrDefault,
),
dialog = null,
)
}
@ -297,6 +307,7 @@ class VaultViewModel @Inject constructor(
mutableStateFlow.updateToErrorStateOrDialog(
vaultData = vaultData.data,
vaultFilterType = vaultFilterTypeOrDefault,
isPremium = state.isPremium,
errorTitle = R.string.internet_connection_required_title.asText(),
errorMessage = R.string.internet_connection_required_message.asText(),
)
@ -305,7 +316,12 @@ class VaultViewModel @Inject constructor(
private fun vaultPendingReceive(vaultData: DataState.Pending<VaultData>) {
// TODO update state to refresh state BIT-505
mutableStateFlow.update {
it.copy(viewState = vaultData.data.toViewState(vaultFilterTypeOrDefault))
it.copy(
viewState = vaultData.data.toViewState(
isPremium = state.isPremium,
vaultFilterType = vaultFilterTypeOrDefault,
),
)
}
}
@ -321,6 +337,7 @@ class VaultViewModel @Inject constructor(
* @property viewState The specific view state representing loading, no items, or content state.
* @property dialog Information about any dialogs that may need to be displayed.
* @property isSwitchingAccounts Whether or not we are actively switching accounts.
* @property isPremium Whether the user is a premium user.
*/
@Parcelize
data class VaultState(
@ -333,6 +350,7 @@ data class VaultState(
val dialog: DialogState? = null,
// Internal-use properties
val isSwitchingAccounts: Boolean = false,
val isPremium: Boolean,
) : Parcelable {
/**
@ -390,6 +408,7 @@ data class VaultState(
/**
* Content state for the [VaultScreen] showing the actual content or items.
*
* @property totpItemsCount The count of totp code items.
* @property loginItemsCount The count of Login type items.
* @property cardItemsCount The count of Card type items.
* @property identityItemsCount The count of Identity type items.
@ -402,6 +421,7 @@ data class VaultState(
*/
@Parcelize
data class Content(
val totpItemsCount: Int,
val loginItemsCount: Int,
val cardItemsCount: Int,
val identityItemsCount: Int,
@ -608,6 +628,11 @@ sealed class VaultEvent {
val itemListingType: VaultItemListingType,
) : VaultEvent()
/**
* Navigate to the verification code screen.
*/
data object NavigateToVerificationCodeScreen : VaultEvent()
/**
* Navigate out of the app.
*/
@ -706,6 +731,11 @@ sealed class VaultAction {
val collectionItem: VaultState.ViewState.CollectionItem,
) : VaultAction()
/**
* User clicked on the verification codes button.
*/
data object VerificationCodesClick : VaultAction()
/**
* User clicked the login types button.
*/
@ -765,13 +795,17 @@ sealed class VaultAction {
private fun MutableStateFlow<VaultState>.updateToErrorStateOrDialog(
vaultData: VaultData?,
vaultFilterType: VaultFilterType,
isPremium: Boolean,
errorTitle: Text,
errorMessage: Text,
) {
this.update {
if (vaultData != null) {
it.copy(
viewState = vaultData.toViewState(vaultFilterType = vaultFilterType),
viewState = vaultData.toViewState(
isPremium = isPremium,
vaultFilterType = vaultFilterType,
),
dialog = VaultState.DialogState.Error(
title = errorTitle,
message = errorMessage,

View file

@ -13,6 +13,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
* Transforms [VaultData] into [VaultState.ViewState] using the given [vaultFilterType].
*/
fun VaultData.toViewState(
isPremium: Boolean,
vaultFilterType: VaultFilterType,
): VaultState.ViewState {
val filteredCipherViewList = cipherViewList.toFilteredList(vaultFilterType)
@ -23,6 +24,11 @@ fun VaultData.toViewState(
VaultState.ViewState.NoItems
} else {
VaultState.ViewState.Content(
totpItemsCount = if (isPremium) {
filteredCipherViewList.count { it.login?.totp != null }
} else {
0
},
loginItemsCount = filteredCipherViewList.count { it.type == CipherType.LOGIN },
cardItemsCount = filteredCipherViewList.count { it.type == CipherType.CARD },
identityItemsCount = filteredCipherViewList.count { it.type == CipherType.IDENTITY },

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M2,2h20v20h-20z"/>
<path
android:pathData="M12.625,7C12.97,7 13.25,7.28 13.25,7.625V12.732C13.25,13.229 13.052,13.706 12.701,14.058L10.567,16.192C10.323,16.436 9.927,16.436 9.683,16.192C9.439,15.948 9.439,15.552 9.683,15.308L11.817,13.174C11.934,13.057 12,12.898 12,12.732V7.625C12,7.28 12.28,7 12.625,7Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
<path
android:pathData="M12,20.75C16.833,20.75 20.75,16.833 20.75,12C20.75,7.168 16.833,3.25 12,3.25C7.168,3.25 3.25,7.168 3.25,12C3.25,16.833 7.168,20.75 12,20.75ZM12,22C17.523,22 22,17.523 22,12C22,6.477 17.523,2 12,2C6.477,2 2,6.477 2,12C2,17.523 6.477,22 12,22Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</group>
</vector>

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
@ -622,6 +623,61 @@ class VaultScreenTest : BaseComposeTest() {
verify { intentHandler.exitApplication() }
}
@Test
fun `totp section should be visible based on state`() {
mutableStateFlow.update { state ->
state.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
totpItemsCount = 2,
),
)
}
composeTestRule
.onNodeWithText("TOTP")
.assertTextEquals("TOTP", "1")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Verification codes")
.assertTextEquals("Verification codes", "2")
.assertIsDisplayed()
mutableStateFlow.update { state ->
state.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
totpItemsCount = 0,
),
)
}
composeTestRule
.onNodeWithText("TOTP")
.assertIsNotDisplayed()
composeTestRule
.onNodeWithText("Verification codes")
.assertIsNotDisplayed()
}
@Test
fun `clicking totp section should emit VerificationCodesClick action`() {
mutableStateFlow.update { state ->
state.copy(
isPremium = true,
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
totpItemsCount = 2,
),
)
}
composeTestRule
.onNodeWithText("Verification codes")
.performClick()
verify { viewModel.trySendAction(VaultAction.VerificationCodesClick) }
}
@Test
fun `clicking a favorite item should send VaultItemClick with the correct item`() {
val itemText = "Test Item"
@ -1003,14 +1059,15 @@ private val VAULT_FILTER_DATA = VaultFilterData(
)
private val DEFAULT_STATE: VaultState = VaultState(
appBarTitle = R.string.my_vault.asText(),
avatarColorString = "#aa00aa",
initials = "AU",
accountSummaries = persistentListOf(
ACTIVE_ACCOUNT_SUMMARY,
LOCKED_ACCOUNT_SUMMARY,
),
appBarTitle = R.string.my_vault.asText(),
viewState = VaultState.ViewState.Loading,
isPremium = false,
)
private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultState.ViewState.Content(
@ -1023,4 +1080,5 @@ private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultStat
noFolderItems = emptyList(),
collectionItems = emptyList(),
trashItemsCount = 0,
totpItemsCount = 0,
)

View file

@ -345,7 +345,10 @@ class VaultViewModelTest : BaseViewModelTest() {
)
val viewModel = createViewModel()
val initialState = createMockVaultState(
viewState = vaultData.toViewState(VaultFilterType.AllVaults),
viewState = vaultData.toViewState(
isPremium = true,
vaultFilterType = VaultFilterType.AllVaults,
),
)
.copy(
appBarTitle = R.string.vaults.asText(),
@ -363,7 +366,10 @@ class VaultViewModelTest : BaseViewModelTest() {
vaultFilterData = VAULT_FILTER_DATA.copy(
selectedVaultFilterType = VaultFilterType.MyVault,
),
viewState = vaultData.toViewState(VaultFilterType.MyVault),
viewState = vaultData.toViewState(
isPremium = true,
vaultFilterType = VaultFilterType.MyVault,
),
),
viewModel.stateFlow.value,
)
@ -408,6 +414,7 @@ class VaultViewModelTest : BaseViewModelTest() {
),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
),
),
viewModel.stateFlow.value,
@ -429,6 +436,7 @@ class VaultViewModelTest : BaseViewModelTest() {
collectionItems = listOf(),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
),
)
val viewModel = createViewModel()
@ -540,6 +548,7 @@ class VaultViewModelTest : BaseViewModelTest() {
),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
),
),
viewModel.stateFlow.value,
@ -637,6 +646,7 @@ class VaultViewModelTest : BaseViewModelTest() {
),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
),
dialog = VaultState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
@ -734,6 +744,7 @@ class VaultViewModelTest : BaseViewModelTest() {
),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
),
dialog = VaultState.DialogState.Error(
title = R.string.internet_connection_required_title.asText(),
@ -806,6 +817,15 @@ class VaultViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `VerificationCodesClick should emit NavigateToVerificationCodeScreen`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.VerificationCodesClick)
assertEquals(VaultEvent.NavigateToVerificationCodeScreen, awaitItem())
}
}
@Test
fun `AddItemClick should emit NavigateToAddItemScreen`() = runTest {
val viewModel = createViewModel()
@ -1057,4 +1077,5 @@ private fun createMockVaultState(
viewState = viewState,
dialog = dialog,
isSwitchingAccounts = false,
isPremium = true,
)

View file

@ -23,7 +23,10 @@ class VaultDataExtensionsTest {
sendViewList = listOf(createMockSendView(number = 1)),
)
val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.AllVaults)
val actual = vaultData.toViewState(
isPremium = true,
vaultFilterType = VaultFilterType.AllVaults,
)
assertEquals(
VaultState.ViewState.Content(
@ -48,6 +51,7 @@ class VaultDataExtensionsTest {
),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
),
actual,
)
@ -66,7 +70,10 @@ class VaultDataExtensionsTest {
sendViewList = listOf(createMockSendView(number = 1)),
)
val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.MyVault)
val actual = vaultData.toViewState(
isPremium = true,
vaultFilterType = VaultFilterType.MyVault,
)
assertEquals(
VaultState.ViewState.Content(
@ -85,6 +92,7 @@ class VaultDataExtensionsTest {
collectionItems = listOf(),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
),
actual,
)
@ -107,6 +115,7 @@ class VaultDataExtensionsTest {
)
val actual = vaultData.toViewState(
isPremium = true,
vaultFilterType = VaultFilterType.OrganizationVault(
organizationId = "mockOrganizationId-1",
organizationName = "Mock Organization 1",
@ -130,6 +139,7 @@ class VaultDataExtensionsTest {
),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
),
actual,
)
@ -144,7 +154,10 @@ class VaultDataExtensionsTest {
sendViewList = emptyList(),
)
val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.AllVaults)
val actual = vaultData.toViewState(
isPremium = true,
vaultFilterType = VaultFilterType.AllVaults,
)
assertEquals(
VaultState.ViewState.NoItems,
@ -161,11 +174,78 @@ class VaultDataExtensionsTest {
sendViewList = listOf(createMockSendView(number = 1)),
)
val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.AllVaults)
val actual = vaultData.toViewState(
isPremium = true,
vaultFilterType = VaultFilterType.AllVaults,
)
assertEquals(
VaultState.ViewState.NoItems,
actual,
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should return 1 for totpItemsCount if user has premium and has one totp item`() {
val vaultData = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
collectionViewList = listOf(),
folderViewList = listOf(),
sendViewList = listOf(),
)
val actual = vaultData.toViewState(
isPremium = true,
vaultFilterType = VaultFilterType.AllVaults,
)
assertEquals(
VaultState.ViewState.Content(
loginItemsCount = 1,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = listOf(),
folderItems = listOf(),
collectionItems = listOf(),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
),
actual,
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should return 0 for totpItemsCount if user does not have premium and has any totp items`() {
val vaultData = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
collectionViewList = listOf(),
folderViewList = listOf(),
sendViewList = listOf(),
)
val actual = vaultData.toViewState(
isPremium = false,
vaultFilterType = VaultFilterType.AllVaults,
)
assertEquals(
VaultState.ViewState.Content(
loginItemsCount = 1,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = listOf(),
folderItems = listOf(),
collectionItems = listOf(),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 0,
),
actual,
)
}
}