From d4ec4c2e0e3c50ff1c216bc747f7dfcc3ebfe4b2 Mon Sep 17 00:00:00 2001 From: Oleg Semenenko <146032743+oleg-livefront@users.noreply.github.com> Date: Thu, 11 Jan 2024 15:49:51 -0600 Subject: [PATCH] BIT-1337 Adding new section for verification codes (#567) --- .../ui/vault/feature/vault/VaultContent.kt | 26 ++++++ .../ui/vault/feature/vault/VaultScreen.kt | 11 +++ .../ui/vault/feature/vault/VaultViewModel.kt | 40 ++++++++- .../feature/vault/util/VaultDataExtensions.kt | 6 ++ app/src/main/res/drawable/access_time.xml | 18 ++++ .../ui/vault/feature/vault/VaultScreenTest.kt | 60 ++++++++++++- .../vault/feature/vault/VaultViewModelTest.kt | 25 +++++- .../vault/util/VaultDataExtensionsTest.kt | 88 ++++++++++++++++++- 8 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 app/src/main/res/drawable/access_time.xml diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index 6b902b531a..c0607c3009 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -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()) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index 46a845c913..cd578908b2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -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, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 6347b099c7..d3b03527a7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -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) { // 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.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, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index fddc66ed48..2948d3da01 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -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 }, diff --git a/app/src/main/res/drawable/access_time.xml b/app/src/main/res/drawable/access_time.xml new file mode 100644 index 0000000000..5b84755f06 --- /dev/null +++ b/app/src/main/res/drawable/access_time.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index a95b699fce..8716408cad 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index d0f65fe3ec..33626b9c35 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index 687831c4ed..45e5862a6e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -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, + ) + } }