From 413677852b7987100e6620433cd051d486b0b84a Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 18 Jan 2024 16:45:09 -0600 Subject: [PATCH] Add overflow items to sends listings (#665) --- .../itemlisting/VaultItemListingContent.kt | 16 +- .../itemlisting/VaultItemListingScreen.kt | 8 + .../itemlisting/VaultItemListingViewModel.kt | 77 ++++++++- .../handlers/VaultItemListingHandlers.kt | 2 + .../util/VaultItemListingDataExtensions.kt | 49 +++++- .../itemlisting/VaultItemListingScreenTest.kt | 150 ++++++++++++++++++ .../VaultItemListingViewModelTest.kt | 71 ++++++++- .../VaultItemListingDataExtensionsTest.kt | 71 ++++++++- .../util/VaultItemListingDataUtil.kt | 93 ++++++++++- 9 files changed, 516 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt index f92cceb9e8..c411d72f00 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt @@ -10,7 +10,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderTextWithSupportLabel -import com.x8bit.bitwarden.ui.vault.feature.vault.VaultEntryListItem +import com.x8bit.bitwarden.ui.platform.components.BitwardenListItem +import com.x8bit.bitwarden.ui.platform.components.SelectionItemData +import kotlinx.collections.immutable.toPersistentList /** * Content view for the [VaultItemListingScreen]. @@ -19,6 +21,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.VaultEntryListItem fun VaultItemListingContent( state: VaultItemListingState.ViewState.Content, vaultItemClick: (id: String) -> Unit, + onOverflowItemClick: (action: VaultItemListingsAction) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -34,11 +37,20 @@ fun VaultItemListingContent( ) } items(state.displayItemList) { - VaultEntryListItem( + BitwardenListItem( startIcon = it.iconData, label = it.title, supportingLabel = it.subtitle, onClick = { vaultItemClick(it.id) }, + selectionDataList = it + .overflowOptions + .map { option -> + SelectionItemData( + text = option.title(), + onClick = { onOverflowItemClick(option.action) }, + ) + } + .toPersistentList(), modifier = Modifier .fillMaxWidth() .padding( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index f976a44a71..e84544730e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -30,6 +30,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager import com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers.VaultItemListingHandlers import kotlinx.collections.immutable.persistentListOf @@ -43,6 +45,7 @@ fun VaultItemListingScreen( onNavigateToVaultAddItemScreen: () -> Unit, onNavigateToAddSendItem: () -> Unit, onNavigateToEditSendItem: (sendId: String) -> Unit, + intentManager: IntentManager = LocalIntentManager.current, viewModel: VaultItemListingViewModel = hiltViewModel(), ) { val state by viewModel.stateFlow.collectAsState() @@ -56,6 +59,10 @@ fun VaultItemListingScreen( onNavigateToVaultItem(event.id) } + is VaultItemListingEvent.ShowShareSheet -> { + intentManager.shareText(event.content) + } + is VaultItemListingEvent.ShowToast -> { Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() } @@ -168,6 +175,7 @@ private fun VaultItemListingScaffold( VaultItemListingContent( state = state.viewState, vaultItemClick = vaultItemListingHandlers.itemClick, + onOverflowItemClick = vaultItemListingHandlers.overflowItemClick, modifier = modifier, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 5f8a9bea54..347c3cde06 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -4,10 +4,12 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl +import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.BaseViewModel @@ -34,6 +36,7 @@ import javax.inject.Inject @Suppress("MagicNumber", "TooManyFunctions") class VaultItemListingViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val clipboardManager: BitwardenClipboardManager, private val vaultRepository: VaultRepository, private val environmentRepository: EnvironmentRepository, private val settingsRepository: SettingsRepository, @@ -43,6 +46,7 @@ class VaultItemListingViewModel @Inject constructor( .vaultItemListingType .toItemListingType(), viewState = VaultItemListingState.ViewState.Loading, + baseWebSendUrl = environmentRepository.environment.environmentUrlData.baseWebSendUrl, baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl, isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, dialogState = null, @@ -70,9 +74,17 @@ class VaultItemListingViewModel @Inject constructor( is VaultItemListingsAction.ItemClick -> handleItemClick(action) is VaultItemListingsAction.AddVaultItemClick -> handleAddVaultItemClick() is VaultItemListingsAction.RefreshClick -> handleRefreshClick() + is VaultItemListingsAction.CopySendUrlClick -> handleCopySendUrlClick(action) + is VaultItemListingsAction.DeleteSendClick -> handleDeleteSendClick(action) + is VaultItemListingsAction.ShareSendUrlClick -> handleShareSendUrlClick(action) + is VaultItemListingsAction.RemoveSendPasswordClick -> { + handleRemoveSendPasswordClick(action) + } + is VaultItemListingsAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) - is VaultItemListingsAction.Internal.IconLoadingSettingReceive -> + is VaultItemListingsAction.Internal.IconLoadingSettingReceive -> { handleIconsSettingReceived(action) + } } } @@ -81,6 +93,26 @@ class VaultItemListingViewModel @Inject constructor( vaultRepository.sync() } + private fun handleCopySendUrlClick(action: VaultItemListingsAction.CopySendUrlClick) { + clipboardManager.setText(text = action.sendUrl) + } + + private fun handleDeleteSendClick(action: VaultItemListingsAction.DeleteSendClick) { + // TODO: Implement deletion (BIT-1411) + sendEvent(VaultItemListingEvent.ShowToast("Not yet implemented".asText())) + } + + private fun handleShareSendUrlClick(action: VaultItemListingsAction.ShareSendUrlClick) { + sendEvent(VaultItemListingEvent.ShowShareSheet(action.sendUrl)) + } + + private fun handleRemoveSendPasswordClick( + action: VaultItemListingsAction.RemoveSendPasswordClick, + ) { + // TODO: Implement password removal (BIT-1411) + sendEvent(VaultItemListingEvent.ShowToast("Not yet implemented".asText())) + } + private fun handleAddVaultItemClick() { val event = when (state.itemListingType) { is VaultItemListingState.ItemListingType.Vault -> { @@ -233,7 +265,7 @@ class VaultItemListingViewModel @Inject constructor( .filter { sendView -> sendView.determineListingPredicate(listingType) } - .toViewState() + .toViewState(baseWebSendUrl = state.baseWebSendUrl) } }, dialogState = currentState.dialogState.takeUnless { clearDialogState }, @@ -248,6 +280,7 @@ class VaultItemListingViewModel @Inject constructor( data class VaultItemListingState( val itemListingType: ItemListingType, val viewState: ViewState, + val baseWebSendUrl: String, val baseIconUrl: String, val isIconLoadingDisabled: Boolean, val dialogState: DialogState?, @@ -309,13 +342,26 @@ data class VaultItemListingState( * @property title title of the item. * @property subtitle subtitle of the item (nullable). * @property iconData data for the icon to be displayed (nullable). + * @property overflowOptions list of options for the item's overflow menu. */ data class DisplayItem( val id: String, val title: String, val subtitle: String?, val iconData: IconData, - ) + val overflowOptions: List, + ) { + /** + * Represents a single option to be displayed in an [DisplayItem]s overflow menu. + * + * @property title the display title of the option. + * @property action the action to be sent back to the view model when the option is clicks. + */ + data class OverflowItem( + val title: Text, + val action: VaultItemListingsAction, + ) + } /** * Represents different types of item listing. @@ -470,6 +516,11 @@ sealed class VaultItemListingEvent { */ data object NavigateToVaultSearchScreen : VaultItemListingEvent() + /** + * Show a share sheet with the given content. + */ + data class ShowShareSheet(val content: String) : VaultItemListingEvent() + /** * Show a toast with the given message. * @@ -520,6 +571,26 @@ sealed class VaultItemListingsAction { */ data class ItemClick(val id: String) : VaultItemListingsAction() + /** + * Click on the copy send URL overflow option. + */ + data class CopySendUrlClick(val sendUrl: String) : VaultItemListingsAction() + + /** + * Click on the share send URL overflow option. + */ + data class ShareSendUrlClick(val sendUrl: String) : VaultItemListingsAction() + + /** + * Click on the remove password send overflow option. + */ + data class RemoveSendPasswordClick(val sendId: String) : VaultItemListingsAction() + + /** + * Click on the delete send overflow option. + */ + data class DeleteSendClick(val sendId: String) : VaultItemListingsAction() + /** * Models actions that the [VaultItemListingViewModel] itself might send. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt index c2a0745060..57679142b1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt @@ -15,6 +15,7 @@ data class VaultItemListingHandlers( val refreshClick: () -> Unit, val syncClick: () -> Unit, val lockClick: () -> Unit, + val overflowItemClick: (action: VaultItemListingsAction) -> Unit, ) { companion object { /** @@ -36,6 +37,7 @@ data class VaultItemListingHandlers( refreshClick = { viewModel.trySendAction(VaultItemListingsAction.RefreshClick) }, syncClick = { viewModel.trySendAction(VaultItemListingsAction.SyncClick) }, lockClick = { viewModel.trySendAction(VaultItemListingsAction.LockClick) }, + overflowItemClick = { viewModel.trySendAction(it) }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 16cc02596a..97fb2e3f95 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -8,8 +8,11 @@ import com.bitwarden.core.FolderView import com.bitwarden.core.SendType import com.bitwarden.core.SendView import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingsAction import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData /** @@ -87,9 +90,13 @@ fun List.toViewState( /** * Transforms a list of [CipherView] into [VaultItemListingState.ViewState]. */ -fun List.toViewState(): VaultItemListingState.ViewState = +fun List.toViewState( + baseWebSendUrl: String, +): VaultItemListingState.ViewState = if (isNotEmpty()) { - VaultItemListingState.ViewState.Content(displayItemList = toDisplayItemList()) + VaultItemListingState.ViewState.Content( + displayItemList = toDisplayItemList(baseWebSendUrl = baseWebSendUrl), + ) } else { VaultItemListingState.ViewState.NoItems } @@ -134,8 +141,10 @@ private fun List.toDisplayItemList( ) } -private fun List.toDisplayItemList(): List = - this.map { it.toDisplayItem() } +private fun List.toDisplayItemList( + baseWebSendUrl: String, +): List = + this.map { it.toDisplayItem(baseWebSendUrl = baseWebSendUrl) } private fun CipherView.toDisplayItem( baseIconUrl: String, @@ -149,6 +158,7 @@ private fun CipherView.toDisplayItem( baseIconUrl = baseIconUrl, isIconLoadingDisabled = isIconLoadingDisabled, ), + overflowOptions = emptyList(), ) private fun CipherView.toIconData( @@ -169,7 +179,9 @@ private fun CipherView.toIconData( } } -private fun SendView.toDisplayItem(): VaultItemListingState.DisplayItem = +private fun SendView.toDisplayItem( + baseWebSendUrl: String, +): VaultItemListingState.DisplayItem = VaultItemListingState.DisplayItem( id = id.orEmpty(), title = name, @@ -180,6 +192,33 @@ private fun SendView.toDisplayItem(): VaultItemListingState.DisplayItem = SendType.FILE -> R.drawable.ic_send_file }, ), + overflowOptions = listOfNotNull( + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.edit.asText(), + action = VaultItemListingsAction.ItemClick(id = id.orEmpty()), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.copy_link.asText(), + action = VaultItemListingsAction.CopySendUrlClick( + sendUrl = toSendUrl(baseWebSendUrl), + ), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.share_link.asText(), + action = VaultItemListingsAction.ShareSendUrlClick( + sendUrl = toSendUrl(baseWebSendUrl), + ), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.remove_password.asText(), + action = VaultItemListingsAction.RemoveSendPasswordClick(sendId = id.orEmpty()), + ) + .takeIf { hasPassword }, + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.delete.asText(), + action = VaultItemListingsAction.DeleteSendClick(sendId = id.orEmpty()), + ), + ), ) @Suppress("MagicNumber") diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index bf35c835c5..fd4acc6314 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isPopup import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -19,13 +20,18 @@ import androidx.compose.ui.test.performScrollToNode import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl +import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.util.assertNoDialogExists import com.x8bit.bitwarden.ui.util.isProgressBar import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -42,6 +48,9 @@ class VaultItemListingScreenTest : BaseComposeTest() { private var onNavigateToEditSendItemId: String? = null private var onNavigateToVaultItemId: String? = null + private val intentManager: IntentManager = mockk { + every { shareText(any()) } just runs + } private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk(relaxed = true) { @@ -54,6 +63,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { composeTestRule.setContent { VaultItemListingScreen( viewModel = viewModel, + intentManager = intentManager, onNavigateBack = { onNavigateBackCalled = true }, onNavigateToVaultItem = { onNavigateToVaultItemId = it }, onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true }, @@ -113,6 +123,15 @@ class VaultItemListingScreenTest : BaseComposeTest() { verify { viewModel.trySendAction(VaultItemListingsAction.RefreshClick) } } + @Test + fun `ShowShareSheet event should call shareText in intentManager`() { + val content = "content" + mutableEventFlow.tryEmit(VaultItemListingEvent.ShowShareSheet(content = content)) + verify { + intentManager.shareText(content) + } + } + @Test fun `NavigateToAdd VaultItem event should call NavigateToVaultAddItemScreen`() { mutableEventFlow.tryEmit(VaultItemListingEvent.NavigateToAddVaultItem) @@ -476,6 +495,114 @@ class VaultItemListingScreenTest : BaseComposeTest() { } } + @Test + fun `on send item overflow click should display dialog`() { + val number = 1 + mutableStateFlow.update { + it.copy( + viewState = VaultItemListingState.ViewState.Content( + displayItemList = listOf(createDisplayItem(number = number)), + ), + ) + } + composeTestRule.assertNoDialogExists() + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + + composeTestRule + .onNode(isDialog()) + .onChildren() + .filterToOne(hasText("mockTitle-$number")) + .assertIsDisplayed() + } + + @Test + fun `on send item overflow option click should emit the appropriate action`() { + val number = 1 + mutableStateFlow.update { + it.copy( + viewState = VaultItemListingState.ViewState.Content( + displayItemList = listOf(createDisplayItem(number = number)), + ), + ) + } + + composeTestRule.assertNoDialogExists() + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("Edit") + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemListingsAction.ItemClick(id = "mockId-$number"), + ) + } + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("Copy link") + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemListingsAction.CopySendUrlClick(sendUrl = "www.test.com"), + ) + } + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("Share link") + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemListingsAction.ShareSendUrlClick(sendUrl = "www.test.com"), + ) + } + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("Remove password") + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemListingsAction.RemoveSendPasswordClick(sendId = "mockId-$number"), + ) + } + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("Delete") + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemListingsAction.DeleteSendClick(sendId = "mockId-$number"), + ) + } + } + @Test fun `loading dialog should be displayed according to state`() { val loadingMessage = "syncing" @@ -500,6 +627,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { private val DEFAULT_STATE = VaultItemListingState( itemListingType = VaultItemListingState.ItemListingType.Vault.Login, viewState = VaultItemListingState.ViewState.Loading, + baseWebSendUrl = Environment.Us.environmentUrlData.baseWebSendUrl, isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, dialogState = null, @@ -511,4 +639,26 @@ private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem = title = "mockTitle-$number", subtitle = "mockSubtitle-$number", iconData = IconData.Local(R.drawable.ic_card_item), + overflowOptions = listOf( + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.edit.asText(), + action = VaultItemListingsAction.ItemClick(id = "mockId-$number"), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.copy_link.asText(), + action = VaultItemListingsAction.CopySendUrlClick(sendUrl = "www.test.com"), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.share_link.asText(), + action = VaultItemListingsAction.ShareSendUrlClick(sendUrl = "www.test.com"), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.remove_password.asText(), + action = VaultItemListingsAction.RemoveSendPasswordClick(sendId = "mockId-$number"), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.delete.asText(), + action = VaultItemListingsAction.DeleteSendClick(sendId = "mockId-$number"), + ), + ), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 2cc7002383..1ed8cd1215 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -4,11 +4,13 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl +import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView @@ -18,7 +20,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.concat -import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.createMockItemListingDisplayItem +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.createMockDisplayItemForCipher import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import io.mockk.every import io.mockk.just @@ -36,6 +38,8 @@ import org.junit.jupiter.api.Test class VaultItemListingViewModelTest : BaseViewModelTest() { + private val clipboardManager: BitwardenClipboardManager = mockk() + private val mutableVaultDataStateFlow = MutableStateFlow>(DataState.Loading) private val vaultRepository: VaultRepository = mockk { @@ -48,7 +52,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } private val mutableIsIconLoadingDisabledFlow = MutableStateFlow(false) - private val settingsRepository: SettingsRepository = mockk() { + private val settingsRepository: SettingsRepository = mockk { every { isIconLoadingDisabled } returns false every { isIconLoadingDisabledFlow } returns mutableIsIconLoadingDisabledFlow } @@ -163,6 +167,59 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { verify { vaultRepository.sync() } } + @Test + fun `CopySendUrlClick should call setText on clipboardManager`() { + val sendUrl = "www.test.com" + every { clipboardManager.setText(sendUrl) } just runs + val viewModel = createVaultItemListingViewModel() + viewModel.actionChannel.trySend(VaultItemListingsAction.CopySendUrlClick(sendUrl = sendUrl)) + verify(exactly = 1) { + clipboardManager.setText(text = sendUrl) + } + } + + @Test + fun `DeleteSendClick should emit ShowToast`() = runTest { + val sendId = "sendId" + val viewModel = createVaultItemListingViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend( + VaultItemListingsAction.DeleteSendClick(sendId = sendId), + ) + assertEquals( + VaultItemListingEvent.ShowToast("Not yet implemented".asText()), + awaitItem(), + ) + } + } + + @Test + fun `ShareSendUrlClick should emit ShowShareSheet`() = runTest { + val sendUrl = "www.test.com" + val viewModel = createVaultItemListingViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend( + VaultItemListingsAction.ShareSendUrlClick(sendUrl = sendUrl), + ) + assertEquals(VaultItemListingEvent.ShowShareSheet(sendUrl), awaitItem()) + } + } + + @Test + fun `RemoveSendPasswordClick should emit ShowToast`() = runTest { + val sendId = "sendId" + val viewModel = createVaultItemListingViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend( + VaultItemListingsAction.RemoveSendPasswordClick(sendId = sendId), + ) + assertEquals( + VaultItemListingEvent.ShowToast("Not yet implemented".asText()), + awaitItem(), + ) + } + } + @Test fun `vaultDataStateFlow Loaded with items should update ViewState to Content`() = runTest { @@ -190,7 +247,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createVaultItemListingState( viewState = VaultItemListingState.ViewState.Content( displayItemList = listOf( - createMockItemListingDisplayItem(number = 1), + createMockDisplayItemForCipher(number = 1), ), ), ), @@ -273,7 +330,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createVaultItemListingState( viewState = VaultItemListingState.ViewState.Content( displayItemList = listOf( - createMockItemListingDisplayItem(number = 1), + createMockDisplayItemForCipher(number = 1), ), ), ), @@ -365,7 +422,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createVaultItemListingState( viewState = VaultItemListingState.ViewState.Content( displayItemList = listOf( - createMockItemListingDisplayItem(number = 1), + createMockDisplayItemForCipher(number = 1), ), ), ), @@ -464,7 +521,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createVaultItemListingState( viewState = VaultItemListingState.ViewState.Content( displayItemList = listOf( - createMockItemListingDisplayItem(number = 1), + createMockDisplayItemForCipher(number = 1), ), ), ), @@ -577,6 +634,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ): VaultItemListingViewModel = VaultItemListingViewModel( savedStateHandle = savedStateHandle, + clipboardManager = clipboardManager, vaultRepository = vaultRepository, environmentRepository = environmentRepository, settingsRepository = settingsRepository, @@ -590,6 +648,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { VaultItemListingState( itemListingType = itemListingType, viewState = viewState, + baseWebSendUrl = Environment.Us.environmentUrlData.baseWebSendUrl, baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl, isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, dialogState = null, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index 501bc30d59..94933f330d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -2,11 +2,14 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util import android.net.Uri import com.bitwarden.core.CipherType +import com.bitwarden.core.SendType import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl +import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState import io.mockk.every import io.mockk.mockk @@ -249,6 +252,44 @@ class VaultItemListingDataExtensionsTest { } } + @Test + fun `determineListingPredicate should return the correct predicate for File sendView`() { + val sendView = createMockSendView(number = 1, type = SendType.FILE) + + mapOf( + VaultItemListingState.ItemListingType.Send.SendFile to true, + VaultItemListingState.ItemListingType.Send.SendText to false, + ) + .forEach { (type, expected) -> + val result = sendView.determineListingPredicate( + itemListingType = type, + ) + assertEquals( + expected, + result, + ) + } + } + + @Test + fun `determineListingPredicate should return the correct predicate for Text sendView`() { + val sendView = createMockSendView(number = 1, type = SendType.TEXT) + + mapOf( + VaultItemListingState.ItemListingType.Send.SendFile to false, + VaultItemListingState.ItemListingType.Send.SendText to true, + ) + .forEach { (type, expected) -> + val result = sendView.determineListingPredicate( + itemListingType = type, + ) + assertEquals( + expected, + result, + ) + } + } + @Test fun `toViewState should transform a list of CipherViews into a ViewState`() { mockkStatic(Uri::class) @@ -287,19 +328,19 @@ class VaultItemListingDataExtensionsTest { assertEquals( VaultItemListingState.ViewState.Content( displayItemList = listOf( - createMockItemListingDisplayItem( + createMockDisplayItemForCipher( number = 1, cipherType = CipherType.LOGIN, ), - createMockItemListingDisplayItem( + createMockDisplayItemForCipher( number = 2, cipherType = CipherType.CARD, ), - createMockItemListingDisplayItem( + createMockDisplayItemForCipher( number = 3, cipherType = CipherType.SECURE_NOTE, ), - createMockItemListingDisplayItem( + createMockDisplayItemForCipher( number = 4, cipherType = CipherType.IDENTITY, ), @@ -311,6 +352,28 @@ class VaultItemListingDataExtensionsTest { unmockkStatic(Uri::class) } + @Test + fun `toViewState should transform a list of SendViews into a ViewState`() { + val sendViewList = listOf( + createMockSendView(number = 1, type = SendType.FILE), + createMockSendView(number = 2, type = SendType.TEXT), + ) + + val result = sendViewList.toViewState( + baseWebSendUrl = Environment.Us.environmentUrlData.baseWebSendUrl, + ) + + assertEquals( + VaultItemListingState.ViewState.Content( + displayItemList = listOf( + createMockDisplayItemForSend(number = 1, sendType = SendType.FILE), + createMockDisplayItemForSend(number = 2, sendType = SendType.TEXT), + ), + ), + result, + ) + } + @Test fun `updateWithAdditionalDataIfNecessary should update a folder itemListingType`() { val folderViewList = listOf( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt index cb9a856e3c..dc5dc9464b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt @@ -1,14 +1,17 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util import com.bitwarden.core.CipherType +import com.bitwarden.core.SendType import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingsAction /** * Create a mock [VaultItemListingState.DisplayItem] with a given [number]. */ -fun createMockItemListingDisplayItem( +fun createMockDisplayItemForCipher( number: Int, cipherType: CipherType = CipherType.LOGIN, ): VaultItemListingState.DisplayItem = @@ -22,6 +25,7 @@ fun createMockItemListingDisplayItem( "https://vault.bitwarden.com/icons/www.mockuri.com/icon.png", fallbackIconRes = R.drawable.ic_login_item, ), + overflowOptions = emptyList(), ) } @@ -31,6 +35,7 @@ fun createMockItemListingDisplayItem( title = "mockName-$number", subtitle = null, iconData = IconData.Local(R.drawable.ic_secure_note_item), + overflowOptions = emptyList(), ) } @@ -40,6 +45,7 @@ fun createMockItemListingDisplayItem( title = "mockName-$number", subtitle = "er-$number", iconData = IconData.Local(R.drawable.ic_card_item), + overflowOptions = emptyList(), ) } @@ -49,6 +55,91 @@ fun createMockItemListingDisplayItem( title = "mockName-$number", subtitle = "mockFirstName-${number}mockLastName-$number", iconData = IconData.Local(R.drawable.ic_identity_item), + overflowOptions = emptyList(), + ) + } + } + +/** + * Create a mock [VaultItemListingState.DisplayItem] with a given [number]. + */ +@Suppress("MaxLineLength") +fun createMockDisplayItemForSend( + number: Int, + sendType: SendType = SendType.FILE, +): VaultItemListingState.DisplayItem = + when (sendType) { + SendType.FILE -> { + VaultItemListingState.DisplayItem( + id = "mockId-$number", + title = "mockName-$number", + subtitle = "2023-10-27T12:00:00Z", + iconData = IconData.Local(R.drawable.ic_send_file), + overflowOptions = listOfNotNull( + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.edit.asText(), + action = VaultItemListingsAction.ItemClick(id = "mockId-$number"), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.copy_link.asText(), + action = VaultItemListingsAction.CopySendUrlClick( + sendUrl = "https://vault.bitwarden.com/#/send/mockAccessId-$number/mockKey-$number", + ), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.share_link.asText(), + action = VaultItemListingsAction.ShareSendUrlClick( + sendUrl = "https://vault.bitwarden.com/#/send/mockAccessId-$number/mockKey-$number", + ), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.remove_password.asText(), + action = VaultItemListingsAction.RemoveSendPasswordClick( + sendId = "mockId-$number", + ), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.delete.asText(), + action = VaultItemListingsAction.DeleteSendClick(sendId = "mockId-$number"), + ), + ), + ) + } + + SendType.TEXT -> { + VaultItemListingState.DisplayItem( + id = "mockId-$number", + title = "mockName-$number", + subtitle = "2023-10-27T12:00:00Z", + iconData = IconData.Local(R.drawable.ic_send_text), + overflowOptions = listOfNotNull( + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.edit.asText(), + action = VaultItemListingsAction.ItemClick(id = "mockId-$number"), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.copy_link.asText(), + action = VaultItemListingsAction.CopySendUrlClick( + sendUrl = "https://vault.bitwarden.com/#/send/mockAccessId-$number/mockKey-$number", + ), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.share_link.asText(), + action = VaultItemListingsAction.ShareSendUrlClick( + sendUrl = "https://vault.bitwarden.com/#/send/mockAccessId-$number/mockKey-$number", + ), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.remove_password.asText(), + action = VaultItemListingsAction.RemoveSendPasswordClick( + sendId = "mockId-$number", + ), + ), + VaultItemListingState.DisplayItem.OverflowItem( + title = R.string.delete.asText(), + action = VaultItemListingsAction.DeleteSendClick(sendId = "mockId-$number"), + ), + ), ) } }