From 7fa42e5fb84477292dbe923fb23033fc3369950b Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Fri, 15 Dec 2023 09:21:19 -0600 Subject: [PATCH] BIT-379, BIT-381, BIT-384: Add overflow menu to Vault Screen (#397) --- .../ui/platform/base/util/IntentHandler.kt | 14 +++ .../ui/vault/feature/vault/VaultScreen.kt | 53 ++++++++- .../ui/vault/feature/vault/VaultViewModel.kt | 36 ++++++ .../ui/vault/feature/vault/VaultScreenTest.kt | 110 ++++++++++++++++++ .../vault/feature/vault/VaultViewModelTest.kt | 28 +++++ 5 files changed, 240 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/IntentHandler.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/IntentHandler.kt index 50d48a104..169dd512c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/IntentHandler.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/IntentHandler.kt @@ -4,12 +4,26 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage /** * A utility class for simplifying the handling of Android Intents within a given context. */ +@OmitFromCoverage class IntentHandler(private val context: Context) { + /** + * Starts an intent to exit the application. + */ + fun exitApplication() { + // Note that we fire an explicit Intent rather than try to cast to an Activity and call + // finish to avoid assumptions about what kind of context we have. + val intent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + } + startActivity(intent) + } + /** * Start an activity using the provided [Intent]. */ 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 6c34a72c9..d3300f603 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 @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel 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.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher @@ -38,8 +39,11 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem +import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog +import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList /** @@ -54,6 +58,7 @@ fun VaultScreen( onNavigateToVaultEditItemScreen: (vaultItemId: String) -> Unit, onNavigateToVaultItemListingScreen: (vaultItemType: VaultItemListingType) -> Unit, onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, + intentHandler: IntentHandler = IntentHandler(LocalContext.current), ) { val context = LocalContext.current EventsEffect(viewModel = viewModel) { event -> @@ -75,6 +80,7 @@ fun VaultScreen( onNavigateToVaultItemListingScreen(event.itemListingType) } + VaultEvent.NavigateOutOfApp -> intentHandler.exitApplication() is VaultEvent.ShowToast -> { Toast .makeText(context, event.message, Toast.LENGTH_SHORT) @@ -102,6 +108,15 @@ fun VaultScreen( addAccountClickAction = remember(viewModel) { { viewModel.trySendAction(VaultAction.AddAccountClick) } }, + syncAction = remember(viewModel) { + { viewModel.trySendAction(VaultAction.SyncClick) } + }, + lockAction = remember(viewModel) { + { viewModel.trySendAction(VaultAction.LockClick) } + }, + exitConfirmationAction = remember(viewModel) { + { viewModel.trySendAction(VaultAction.ExitConfirmationClick) } + }, onDimBottomNavBarRequest = onDimBottomNavBarRequest, vaultItemClick = remember(viewModel) { { vaultItem -> viewModel.trySendAction(VaultAction.VaultItemClick(vaultItem)) } @@ -152,6 +167,9 @@ private fun VaultScreenScaffold( accountLogoutClickAction: (AccountSummary) -> Unit, accountSwitchClickAction: (AccountSummary) -> Unit, addAccountClickAction: () -> Unit, + syncAction: () -> Unit, + lockAction: () -> Unit, + exitConfirmationAction: () -> Unit, onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit, folderClick: (VaultState.ViewState.FolderItem) -> Unit, @@ -171,12 +189,14 @@ private fun VaultScreenScaffold( accountMenuVisible = shouldShowMenu onDimBottomNavBarRequest(shouldShowMenu) } + var shouldShowExitConfirmationDialog by rememberSaveable { mutableStateOf(false) } val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( state = rememberTopAppBarState(), canScroll = { !accountMenuVisible }, ) + // Dynamic dialogs when (val dialog = state.dialog) { is VaultState.DialogState.Error -> { BitwardenBasicDialog( @@ -191,6 +211,22 @@ private fun VaultScreenScaffold( null -> Unit } + // Static dialogs + if (shouldShowExitConfirmationDialog) { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.exit), + message = stringResource(id = R.string.exit_confirmation), + confirmButtonText = stringResource(id = R.string.yes), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + shouldShowExitConfirmationDialog = false + exitConfirmationAction() + }, + onDismissClick = { shouldShowExitConfirmationDialog = false }, + onDismissRequest = { shouldShowExitConfirmationDialog = false }, + ) + } + BitwardenScaffold( topBar = { BitwardenMediumTopAppBar( @@ -208,7 +244,22 @@ private fun VaultScreenScaffold( contentDescription = stringResource(id = R.string.search_vault), onClick = searchIconClickAction, ) - BitwardenOverflowActionItem() + BitwardenOverflowActionItem( + menuItemDataList = persistentListOf( + OverflowMenuItemData( + text = stringResource(id = R.string.sync), + onClick = syncAction, + ), + OverflowMenuItemData( + text = stringResource(id = R.string.lock), + onClick = lockAction, + ), + OverflowMenuItemData( + text = stringResource(id = R.string.exit), + onClick = { shouldShowExitConfirmationDialog = true }, + ), + ), + ) }, ) }, 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 808e363cd..8464f5c04 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 @@ -93,6 +93,9 @@ class VaultViewModel @Inject constructor( is VaultAction.LogoutAccountClick -> handleLogoutAccountClick(action) is VaultAction.SwitchAccountClick -> handleSwitchAccountClick(action) is VaultAction.AddAccountClick -> handleAddAccountClick() + is VaultAction.SyncClick -> handleSyncClick() + is VaultAction.LockClick -> handleLockClick() + is VaultAction.ExitConfirmationClick -> handleExitConfirmationClick() is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick() is VaultAction.TrashClick -> handleTrashClick() is VaultAction.VaultItemClick -> handleVaultItemClick(action) @@ -168,6 +171,18 @@ class VaultViewModel @Inject constructor( authRepository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition } + private fun handleSyncClick() { + vaultRepository.sync() + } + + private fun handleLockClick() { + vaultRepository.lockVaultForCurrentUser() + } + + private fun handleExitConfirmationClick() { + sendEvent(VaultEvent.NavigateOutOfApp) + } + private fun handleTrashClick() { sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.Trash)) } @@ -522,6 +537,11 @@ sealed class VaultEvent { val itemListingType: VaultItemListingType, ) : VaultEvent() + /** + * Navigate out of the app. + */ + data object NavigateOutOfApp : VaultEvent() + /** * Show a toast with the given [message]. */ @@ -571,6 +591,22 @@ sealed class VaultAction { */ data object AddAccountClick : VaultAction() + /** + * User clicked the Sync option in the overflow menu. + */ + data object SyncClick : VaultAction() + + /** + * User clicked the Lock option in the overflow menu. + */ + data object LockClick : VaultAction() + + /** + * User confirmed that they want to exit the app after clicking the Sync option in the overflow + * menu. + */ + data object ExitConfirmationClick : VaultAction() + /** * Action to trigger when a specific vault item is clicked. */ 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 10d10abaf..ff25bf091 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 @@ -8,12 +8,14 @@ import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasScrollToNodeAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isPopup import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed @@ -44,6 +46,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +@Suppress("LargeClass") class VaultScreenTest : BaseComposeTest() { private var onNavigateToVaultAddItemScreenCalled = false @@ -51,6 +54,7 @@ class VaultScreenTest : BaseComposeTest() { private var onNavigateToVaultEditItemId: String? = null private var onNavigateToVaultItemListingType: VaultItemListingType? = null private var onDimBottomNavBarRequestCalled = false + private val intentHandler = mockk(relaxed = true) private val mutableEventFlow = MutableSharedFlow( extraBufferCapacity = Int.MAX_VALUE, @@ -71,6 +75,7 @@ class VaultScreenTest : BaseComposeTest() { onNavigateToVaultEditItemScreen = { onNavigateToVaultEditItemId = it }, onNavigateToVaultItemListingScreen = { onNavigateToVaultItemListingType = it }, onDimBottomNavBarRequest = { onDimBottomNavBarRequestCalled = true }, + intentHandler = intentHandler, ) } } @@ -179,6 +184,105 @@ class VaultScreenTest : BaseComposeTest() { composeTestRule.assertNoDialogExists() } + @Test + fun `overflow button click should show the overflow menu`() { + composeTestRule.onNode(isPopup()).assertDoesNotExist() + composeTestRule.onNodeWithText("Sync").assertDoesNotExist() + composeTestRule.onNodeWithText("Lock").assertDoesNotExist() + composeTestRule.onNodeWithText("Exit").assertDoesNotExist() + + composeTestRule.onNodeWithContentDescription("More").performClick() + + composeTestRule.onNode(isPopup()).assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Sync") + .filterToOne(hasAnyAncestor(isPopup())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Lock") + .filterToOne(hasAnyAncestor(isPopup())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Exit") + .filterToOne(hasAnyAncestor(isPopup())) + .assertIsDisplayed() + } + + @Test + fun `sync click in the overflow menu should send SyncClick`() { + // Expand the overflow menu + composeTestRule.onNodeWithContentDescription("More").performClick() + + composeTestRule + .onAllNodesWithText("Sync") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + verify { viewModel.trySendAction(VaultAction.SyncClick) } + } + + @Test + fun `lock click in the overflow menu should send LockClick`() { + // Expand the overflow menu + composeTestRule.onNodeWithContentDescription("More").performClick() + + composeTestRule + .onAllNodesWithText("Lock") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + verify { viewModel.trySendAction(VaultAction.LockClick) } + } + + @Test + fun `exit click in the overflow menu should show a confirmation dialog`() { + // Expand the overflow menu + composeTestRule.onNodeWithContentDescription("More").performClick() + + composeTestRule + .onAllNodesWithText("Exit") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + composeTestRule + .onNode(isDialog()) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Exit") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Are you sure you want to exit Bitwarden?") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Yes") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `yes click in exit confirmation dialog should send ExitConfirmationClick`() { + // Expand the overflow menu and show the exit confirmation dialog + composeTestRule.onNodeWithContentDescription("More").performClick() + composeTestRule + .onAllNodesWithText("Exit") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + composeTestRule + .onAllNodesWithText("Yes") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule.assertNoDialogExists() + verify { viewModel.trySendAction(VaultAction.ExitConfirmationClick) } + } + @Test fun `error dialog should be shown or hidden according to the state`() { val errorTitle = "Error title" @@ -363,6 +467,12 @@ class VaultScreenTest : BaseComposeTest() { assertEquals(VaultItemListingType.Folder(mockFolderId), onNavigateToVaultItemListingType) } + @Test + fun `NavigateOutOfApp event should call exitApplication on the IntentHandler`() { + mutableEventFlow.tryEmit(VaultEvent.NavigateOutOfApp) + verify { intentHandler.exitApplication() } + } + @Test fun `clicking a favorite item should send VaultItemClick with the correct item`() { val itemText = "Test Item" 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 87b0f2545..f7a8e72d4 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 @@ -51,6 +51,7 @@ class VaultViewModelTest : BaseViewModelTest() { mockk { every { vaultDataStateFlow } returns mutableVaultDataStateFlow every { sync() } returns Unit + every { lockVaultForCurrentUser() } just runs every { lockVaultIfNecessary(any()) } just runs } @@ -257,6 +258,33 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `on SyncClick should call sync on the VaultRepository`() { + val viewModel = createViewModel() + viewModel.trySendAction(VaultAction.SyncClick) + verify { + vaultRepository.sync() + } + } + + @Test + fun `on LockClick should call lockVaultForCurrentUser on the VaultRepository`() { + val viewModel = createViewModel() + viewModel.trySendAction(VaultAction.LockClick) + verify { + vaultRepository.lockVaultForCurrentUser() + } + } + + @Test + fun `on ExitConfirmationClick should emit NavigateOutOfApp`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAction.ExitConfirmationClick) + assertEquals(VaultEvent.NavigateOutOfApp, awaitItem()) + } + } + @Test fun `vaultDataStateFlow Loaded with items should update state to Content`() = runTest { mutableVaultDataStateFlow.tryEmit(