From 8f3f1fa3bad05ff46e71fe48c353ace7f1af4509 Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 17 Oct 2025 07:58:17 -0500 Subject: [PATCH] PM-27071: Add overflow menu to authenticator search (#6044) --- .../itemlisting/ItemListingGraphNavigation.kt | 1 + .../feature/search/ItemSearchContent.kt | 8 +- .../feature/search/ItemSearchNavigation.kt | 2 + .../feature/search/ItemSearchScreen.kt | 62 ++++- .../feature/search/ItemSearchViewModel.kt | 221 ++++++++++++++-- .../feature/search/handlers/SearchHandlers.kt | 12 + .../feature/search/ItemSearchViewModelTest.kt | 246 +++++++++++++++++- 7 files changed, 520 insertions(+), 32 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt index 119679c67a..a61f2d1da2 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt @@ -49,6 +49,7 @@ fun NavGraphBuilder.itemListingGraph( ) itemSearchDestination( onNavigateBack = { navController.popBackStack() }, + onNavigateToEdit = navigateToEditItem, ) qrCodeScanDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchContent.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchContent.kt index 27ffb2d496..b130cbd080 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchContent.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchContent.kt @@ -16,6 +16,8 @@ import androidx.compose.ui.unit.dp import com.bitwarden.authenticator.ui.authenticator.feature.search.handlers.SearchHandlers import com.bitwarden.authenticator.ui.platform.components.listitem.VaultVerificationCodeItem import com.bitwarden.authenticator.ui.platform.components.listitem.model.SharedCodesDisplayState +import com.bitwarden.authenticator.ui.platform.components.listitem.model.VaultDropdownMenuAction +import com.bitwarden.authenticator.ui.platform.components.listitem.model.VerificationCodeDisplayItem import com.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.bitwarden.ui.platform.base.util.toListItemCardStyle import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText @@ -53,7 +55,7 @@ fun ItemSearchContent( VaultVerificationCodeItem( displayItem = item, onItemClick = { searchHandlers.onItemClick(item.authCode) }, - onDropdownMenuClick = { }, + onDropdownMenuClick = { searchHandlers.onDropdownMenuClick(it, item) }, cardStyle = viewState.itemList.toListItemCardStyle(index = index), modifier = Modifier .fillMaxWidth() @@ -70,6 +72,7 @@ fun ItemSearchContent( sharedCodes( sharedItems = viewState.sharedItems, onCopyClick = searchHandlers.onItemClick, + onDropdownMenuClick = searchHandlers.onDropdownMenuClick, ) item { @@ -82,6 +85,7 @@ fun ItemSearchContent( private fun LazyListScope.sharedCodes( sharedItems: SharedCodesDisplayState, onCopyClick: (authCode: String) -> Unit, + onDropdownMenuClick: (VaultDropdownMenuAction, VerificationCodeDisplayItem) -> Unit, ) { when (sharedItems) { is SharedCodesDisplayState.Codes -> { @@ -101,7 +105,7 @@ private fun LazyListScope.sharedCodes( VaultVerificationCodeItem( displayItem = item, onItemClick = { onCopyClick(item.authCode) }, - onDropdownMenuClick = { }, + onDropdownMenuClick = { onDropdownMenuClick(it, item) }, cardStyle = section.codes.toListItemCardStyle(index = index), modifier = Modifier .fillMaxWidth() diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchNavigation.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchNavigation.kt index 6bb71bc0a0..869178f1e1 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchNavigation.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchNavigation.kt @@ -16,10 +16,12 @@ data object ItemSearchRoute */ fun NavGraphBuilder.itemSearchDestination( onNavigateBack: () -> Unit, + onNavigateToEdit: (String) -> Unit, ) { composableWithSlideTransitions { ItemSearchScreen( onNavigateBack = onNavigateBack, + onNavigateToEdit = onNavigateToEdit, ) } } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchScreen.kt index d8c67ea6a0..de2893a495 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchScreen.kt @@ -1,6 +1,5 @@ package com.bitwarden.authenticator.ui.authenticator.feature.search -import android.widget.Toast import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults @@ -10,8 +9,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -22,36 +19,41 @@ import com.bitwarden.ui.platform.base.util.EventsEffect import com.bitwarden.ui.platform.base.util.bottomDivider import com.bitwarden.ui.platform.components.appbar.BitwardenSearchTopAppBar import com.bitwarden.ui.platform.components.appbar.NavigationIcon +import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog +import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost +import com.bitwarden.ui.platform.components.snackbar.model.rememberBitwardenSnackbarHostState import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString /** * The search screen for authenticator items. */ -@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun ItemSearchScreen( viewModel: ItemSearchViewModel = hiltViewModel(), onNavigateBack: () -> Unit, + onNavigateToEdit: (String) -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val searchHandlers = remember(viewModel) { SearchHandlers.create(viewModel) } - val context = LocalContext.current - val resources = LocalResources.current - + val snackbarHostState = rememberBitwardenSnackbarHostState() EventsEffect(viewModel = viewModel) { event -> when (event) { is ItemSearchEvent.NavigateBack -> onNavigateBack() - is ItemSearchEvent.ShowToast -> { - Toast - .makeText(context, event.message(resources), Toast.LENGTH_SHORT) - .show() - } + is ItemSearchEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) + is ItemSearchEvent.NavigateToEditItem -> onNavigateToEdit(event.itemId) } } + ItemSearchDialogs( + dialogState = state.dialog, + searchHandlers = searchHandlers, + ) + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier @@ -73,6 +75,7 @@ fun ItemSearchScreen( ), ) }, + snackbarHost = { BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) }, ) { when (val viewState = state.viewState) { is ItemSearchState.ViewState.Content -> { @@ -92,3 +95,38 @@ fun ItemSearchScreen( } } } + +@Composable +private fun ItemSearchDialogs( + dialogState: ItemSearchState.DialogState?, + searchHandlers: SearchHandlers, +) { + when (dialogState) { + is ItemSearchState.DialogState.Error -> { + BitwardenBasicDialog( + title = dialogState.title(), + message = dialogState.message(), + throwable = dialogState.throwable, + onDismissRequest = searchHandlers.onDismissDialog, + ) + } + + is ItemSearchState.DialogState.DeleteConfirmationPrompt -> { + BitwardenTwoButtonDialog( + title = stringResource(id = BitwardenString.delete), + message = dialogState.message(), + confirmButtonText = stringResource(id = BitwardenString.okay), + dismissButtonText = stringResource(id = BitwardenString.cancel), + onConfirmClick = { searchHandlers.onConfirmDeleteClick(dialogState.itemId) }, + onDismissClick = searchHandlers.onDismissDialog, + onDismissRequest = searchHandlers.onDismissDialog, + ) + } + + ItemSearchState.DialogState.Loading -> { + BitwardenLoadingDialog(text = stringResource(id = BitwardenString.loading)) + } + + null -> Unit + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModel.kt index 1c89988f8a..591d3e7cca 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModel.kt @@ -5,15 +5,19 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager import com.bitwarden.authenticator.ui.authenticator.feature.util.toDisplayItem import com.bitwarden.authenticator.ui.authenticator.feature.util.toSharedCodesDisplayState import com.bitwarden.authenticator.ui.platform.components.listitem.model.SharedCodesDisplayState +import com.bitwarden.authenticator.ui.platform.components.listitem.model.VaultDropdownMenuAction import com.bitwarden.authenticator.ui.platform.components.listitem.model.VerificationCodeDisplayItem +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.platform.base.util.removeDiacritics +import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText @@ -22,9 +26,11 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -39,11 +45,13 @@ class ItemSearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val clipboardManager: BitwardenClipboardManager, private val authenticatorRepository: AuthenticatorRepository, + private val authenticatorBridgeManager: AuthenticatorBridgeManager, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: ItemSearchState( searchTerm = "", viewState = ItemSearchState.ViewState.Empty(message = null), + dialog = null, ), ) { @@ -60,27 +68,92 @@ class ItemSearchViewModel @Inject constructor( override fun handleAction(action: ItemSearchAction) { when (action) { - is ItemSearchAction.BackClick -> { - sendEvent(ItemSearchEvent.NavigateBack) + is ItemSearchAction.BackClick -> handleBackClick() + is ItemSearchAction.ConfirmDeleteClick -> handleConfirmDeleteClick(action) + is ItemSearchAction.DismissDialog -> handleDismissDialog() + is ItemSearchAction.SearchTermChange -> handleSearchTermChange(action) + is ItemSearchAction.ItemClick -> handleItemClick(action) + is ItemSearchAction.DropdownMenuClick -> handleDropdownMenuClick(action) + is ItemSearchAction.Internal -> handleInternalAction(action) + } + } + + private fun handleBackClick() { + sendEvent(ItemSearchEvent.NavigateBack) + } + + private fun handleConfirmDeleteClick(action: ItemSearchAction.ConfirmDeleteClick) { + mutableStateFlow.update { + it.copy(dialog = ItemSearchState.DialogState.Loading) + } + + viewModelScope.launch { + trySendAction( + ItemSearchAction.Internal.DeleteItemReceive( + result = authenticatorRepository.hardDeleteItem(action.itemId), + ), + ) + } + } + + private fun handleDismissDialog() { + mutableStateFlow.update { it.copy(dialog = null) } + } + + private fun handleSearchTermChange(action: ItemSearchAction.SearchTermChange) { + mutableStateFlow.update { it.copy(searchTerm = action.searchTerm) } + recalculateViewState() + } + + private fun handleItemClick(action: ItemSearchAction.ItemClick) { + clipboardManager.setText(action.authCode) + } + + private fun handleDropdownMenuClick(action: ItemSearchAction.DropdownMenuClick) { + when (action.menuAction) { + VaultDropdownMenuAction.COPY_CODE -> clipboardManager.setText(action.item.authCode) + VaultDropdownMenuAction.COPY_TO_BITWARDEN -> { + viewModelScope.launch { + val item = authenticatorRepository + .getItemStateFlow(itemId = action.item.id) + .first { it.data != null } + val isSuccess = authenticatorBridgeManager.startAddTotpLoginItemFlow( + totpUri = item.data?.toOtpAuthUriString().orEmpty(), + ) + sendAction(ItemSearchAction.Internal.AddTotpLoginItemFlowResult(isSuccess)) + } } - is ItemSearchAction.SearchTermChange -> { - mutableStateFlow.update { it.copy(searchTerm = action.searchTerm) } - recalculateViewState() + VaultDropdownMenuAction.EDIT -> { + sendEvent(ItemSearchEvent.NavigateToEditItem(action.item.id)) } - is ItemSearchAction.ItemClick -> { - clipboardManager.setText(action.authCode) - sendEvent( - event = ItemSearchEvent.ShowToast( - message = BitwardenString.value_has_been_copied.asText(action.authCode), - ), - ) + VaultDropdownMenuAction.DELETE -> { + mutableStateFlow.update { + it.copy( + dialog = ItemSearchState.DialogState.DeleteConfirmationPrompt( + message = BitwardenString + .do_you_really_want_to_permanently_delete_this_cannot_be_undone + .asText(), + itemId = action.item.id, + ), + ) + } } + } + } + private fun handleInternalAction(action: ItemSearchAction.Internal) { + when (action) { is ItemSearchAction.Internal.AuthenticatorDataReceive -> { handleAuthenticatorDataReceive(action) } + + is ItemSearchAction.Internal.AddTotpLoginItemFlowResult -> { + handleAddTotpLoginItemFlowResult(action) + } + + is ItemSearchAction.Internal.DeleteItemReceive -> handleDeleteItemReceive(action) } } @@ -95,6 +168,40 @@ class ItemSearchViewModel @Inject constructor( } } + private fun handleAddTotpLoginItemFlowResult( + action: ItemSearchAction.Internal.AddTotpLoginItemFlowResult, + ) { + if (action.isSuccess) return + mutableStateFlow.update { + it.copy( + dialog = ItemSearchState.DialogState.Error( + title = BitwardenString.something_went_wrong.asText(), + message = BitwardenString.please_try_again.asText(), + ), + ) + } + } + + private fun handleDeleteItemReceive(action: ItemSearchAction.Internal.DeleteItemReceive) { + when (action.result) { + DeleteItemResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = ItemSearchState.DialogState.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + ), + ) + } + } + + DeleteItemResult.Success -> { + mutableStateFlow.update { it.copy(dialog = null) } + sendEvent(ItemSearchEvent.ShowSnackbar(BitwardenString.item_deleted.asText())) + } + } + } + //region Utility Functions private fun recalculateViewState() { authenticatorRepository.getLocalVerificationCodesFlow() @@ -194,7 +301,7 @@ class ItemSearchViewModel @Inject constructor( sharedVerificationCodesState = authenticatorRepository .sharedCodesStateFlow .value, - showOverflow = false, + showOverflow = true, ) } .toImmutableList(), @@ -213,6 +320,7 @@ class ItemSearchViewModel @Inject constructor( data class ItemSearchState( val searchTerm: String, val viewState: ViewState, + val dialog: DialogState?, ) : Parcelable { /** * Represents the specific view state for the search screen. @@ -244,6 +352,36 @@ data class ItemSearchState( @Parcelize data class Empty(val message: Text?) : ViewState() } + + /** + * Display a dialog on the [ItemSearchScreen]. + */ + sealed class DialogState : Parcelable { + /** + * Displays a prompt to confirm item deletion. + */ + @Parcelize + data class DeleteConfirmationPrompt( + val message: Text, + val itemId: String, + ) : DialogState() + + /** + * Displays the loading dialog to the user. + */ + @Parcelize + data object Loading : DialogState() + + /** + * Displays a generic error dialog to the user. + */ + @Parcelize + data class Error( + val title: Text, + val message: Text, + val throwable: Throwable? = null, + ) : DialogState() + } } /** @@ -255,11 +393,29 @@ sealed class ItemSearchAction { */ data object BackClick : ItemSearchAction() + /** + * User has dismissed a dialog. + */ + data object DismissDialog : ItemSearchAction() + /** * User updated the search term. */ data class SearchTermChange(val searchTerm: String) : ItemSearchAction() + /** + * The user clicked confirm when prompted to delete an item. + */ + data class ConfirmDeleteClick(val itemId: String) : ItemSearchAction() + + /** + * Represents an action triggered when the user clicks an item in the dropdown menu. + */ + data class DropdownMenuClick( + val menuAction: VaultDropdownMenuAction, + val item: VerificationCodeDisplayItem, + ) : ItemSearchAction() + /** * User clicked a row item. */ @@ -277,6 +433,20 @@ sealed class ItemSearchAction { val localData: DataState>, val sharedData: SharedVerificationCodesState, ) : Internal() + + /** + * Indicates the result of the add totp login item flow. + */ + data class AddTotpLoginItemFlowResult( + val isSuccess: Boolean, + ) : Internal() + + /** + * Indicates a result for deleting an item has been received. + */ + data class DeleteItemReceive( + val result: DeleteItemResult, + ) : Internal() } } @@ -291,9 +461,30 @@ sealed class ItemSearchEvent { data object NavigateBack : ItemSearchEvent() /** - * Show a toast with the given [message]. + * Navigate to the edit item screen. */ - data class ShowToast(val message: Text) : ItemSearchEvent() + data class NavigateToEditItem(val itemId: String) : ItemSearchEvent() + + /** + * Show a Snackbar with the given [data]. + */ + data class ShowSnackbar( + val data: BitwardenSnackbarData, + ) : ItemSearchEvent() { + constructor( + message: Text, + messageHeader: Text? = null, + actionLabel: Text? = null, + withDismissAction: Boolean = false, + ) : this( + data = BitwardenSnackbarData( + message = message, + messageHeader = messageHeader, + actionLabel = actionLabel, + withDismissAction = withDismissAction, + ), + ) + } } private enum class SortPriority { diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/handlers/SearchHandlers.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/handlers/SearchHandlers.kt index b2c8a827e6..f36d22b0a1 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/handlers/SearchHandlers.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/handlers/SearchHandlers.kt @@ -2,6 +2,8 @@ package com.bitwarden.authenticator.ui.authenticator.feature.search.handlers import com.bitwarden.authenticator.ui.authenticator.feature.search.ItemSearchAction import com.bitwarden.authenticator.ui.authenticator.feature.search.ItemSearchViewModel +import com.bitwarden.authenticator.ui.platform.components.listitem.model.VaultDropdownMenuAction +import com.bitwarden.authenticator.ui.platform.components.listitem.model.VerificationCodeDisplayItem /** * A collection of delegate functions for managing actions within the context of the search screen. @@ -10,6 +12,9 @@ class SearchHandlers( val onBackClick: () -> Unit, val onItemClick: (String) -> Unit, val onSearchTermChange: (String) -> Unit, + val onDropdownMenuClick: (VaultDropdownMenuAction, VerificationCodeDisplayItem) -> Unit, + val onDismissDialog: () -> Unit, + val onConfirmDeleteClick: (String) -> Unit, ) { /** * Creates an instance of [SearchHandlers] by binding actions to the provided @@ -28,6 +33,13 @@ class SearchHandlers( onSearchTermChange = { viewModel.trySendAction(ItemSearchAction.SearchTermChange(it)) }, + onDropdownMenuClick = { action, item -> + viewModel.trySendAction(ItemSearchAction.DropdownMenuClick(action, item)) + }, + onDismissDialog = { viewModel.trySendAction(ItemSearchAction.DismissDialog) }, + onConfirmDeleteClick = { + viewModel.trySendAction(ItemSearchAction.ConfirmDeleteClick(it)) + }, ) } } diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt index baf7b0c954..c288b0193f 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt @@ -1,25 +1,36 @@ package com.bitwarden.authenticator.ui.authenticator.feature.search import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem import com.bitwarden.authenticator.data.authenticator.manager.util.createMockSharedAuthenticatorItemSource import com.bitwarden.authenticator.data.authenticator.manager.util.createMockVerificationCodeItem import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager import com.bitwarden.authenticator.ui.platform.components.listitem.model.SharedCodesDisplayState +import com.bitwarden.authenticator.ui.platform.components.listitem.model.VaultDropdownMenuAction import com.bitwarden.authenticator.ui.platform.components.listitem.model.VerificationCodeDisplayItem +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.ui.platform.base.BaseViewModelTest import com.bitwarden.ui.platform.components.icon.model.IconData import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.asText +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -30,21 +41,107 @@ class ItemSearchViewModelTest : BaseViewModelTest() { private val mutableSharedCodesFlow = MutableStateFlow( SharedVerificationCodesState.Success(items = SHARED_ITEMS), ) + private val mutableItemStateFlow = + MutableStateFlow>(DataState.Loading) private val mockAuthenticatorRepository = mockk { every { getLocalVerificationCodesFlow() } returns mutableAuthCodesStateFlow every { sharedCodesStateFlow } returns mutableSharedCodesFlow + every { getItemStateFlow(itemId = any()) } returns mutableItemStateFlow } + private val authenticatorBridgeManager = mockk() private val mockClipboardManager = mockk() @Test fun `initial state is correct`() { val viewModel = createViewModel() assertEquals( - ItemSearchState.ViewState.Empty(message = null), - viewModel.stateFlow.value.viewState, + DEFAULT_STATE, + viewModel.stateFlow.value, ) } + @Test + fun `on BackClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(ItemSearchAction.BackClick) + assertEquals(ItemSearchEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `on ConfirmDeleteClick should emit update the dialog state correctly on error`() = runTest { + val itemId = "mockId" + coEvery { + mockAuthenticatorRepository.hardDeleteItem(itemId = itemId) + } returns DeleteItemResult.Error + val viewModel = createViewModel() + + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + viewModel.trySendAction(ItemSearchAction.ConfirmDeleteClick(itemId = itemId)) + assertEquals( + DEFAULT_STATE.copy(dialog = ItemSearchState.DialogState.Loading), + awaitItem(), + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = ItemSearchState.DialogState.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + } + coVerify(exactly = 1) { + mockAuthenticatorRepository.hardDeleteItem(itemId = itemId) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on ConfirmDeleteClick should emit update the dialog state correctly and show Snackbar on success`() = + runTest { + val itemId = "mockId" + coEvery { + mockAuthenticatorRepository.hardDeleteItem(itemId = itemId) + } returns DeleteItemResult.Success + val viewModel = createViewModel() + + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + assertEquals(DEFAULT_STATE, stateFlow.awaitItem()) + viewModel.trySendAction(ItemSearchAction.ConfirmDeleteClick(itemId = itemId)) + assertEquals( + DEFAULT_STATE.copy(dialog = ItemSearchState.DialogState.Loading), + stateFlow.awaitItem(), + ) + assertEquals( + DEFAULT_STATE.copy(dialog = null), + stateFlow.awaitItem(), + ) + assertEquals( + ItemSearchEvent.ShowSnackbar(message = BitwardenString.item_deleted.asText()), + eventFlow.awaitItem(), + ) + } + coVerify(exactly = 1) { + mockAuthenticatorRepository.hardDeleteItem(itemId = itemId) + } + } + + @Test + fun `on DismissDialog should clear the dialogState`() = runTest { + val initialState = DEFAULT_STATE.copy(dialog = ItemSearchState.DialogState.Loading) + val viewModel = createViewModel(initialState = initialState) + assertEquals(initialState, viewModel.stateFlow.value) + + viewModel.trySendAction(ItemSearchAction.DismissDialog) + + assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value) + } + @Test fun `state contains both shared items and local items when available`() { val viewModel = createViewModel() @@ -102,6 +199,142 @@ class ItemSearchViewModelTest : BaseViewModelTest() { ) } + @Test + fun `ItemClick should call clipboardManager`() { + val code = "authCode" + every { mockClipboardManager.setText(text = code) } just runs + val viewModel = createViewModel() + + viewModel.trySendAction(ItemSearchAction.ItemClick(authCode = code)) + + verify(exactly = 1) { + mockClipboardManager.setText(text = code) + } + } + + @Test + fun `DropdownMenuClick COPY_CODE should call clipboardManager`() { + val code = "authCode" + every { mockClipboardManager.setText(text = code) } just runs + val viewModel = createViewModel() + + viewModel.trySendAction( + ItemSearchAction.DropdownMenuClick( + menuAction = VaultDropdownMenuAction.COPY_CODE, + item = mockk { every { authCode } returns code }, + ), + ) + + verify(exactly = 1) { + mockClipboardManager.setText(text = code) + } + } + + @Test + fun `DropdownMenuClick COPY_TO_BITWARDEN should startAddTotpLoginItemFlow on success`() { + val itemId = "itemId" + val uriString = "uriString" + val entity = mockk { + every { toOtpAuthUriString() } returns uriString + } + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(totpUri = uriString) + } returns true + val viewModel = createViewModel() + + mutableItemStateFlow.value = DataState.Loaded(data = entity) + viewModel.trySendAction( + ItemSearchAction.DropdownMenuClick( + menuAction = VaultDropdownMenuAction.COPY_TO_BITWARDEN, + item = mockk { every { id } returns itemId }, + ), + ) + + verify(exactly = 1) { + mockAuthenticatorRepository.getItemStateFlow(itemId = itemId) + authenticatorBridgeManager.startAddTotpLoginItemFlow(totpUri = uriString) + } + } + + @Suppress("MaxLineLength") + @Test + fun `DropdownMenuClick COPY_TO_BITWARDEN should startAddTotpLoginItemFlow and display error dialog on failure`() { + val itemId = "itemId" + val uriString = "uriString" + val entity = mockk { + every { toOtpAuthUriString() } returns uriString + } + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(totpUri = uriString) + } returns false + val viewModel = createViewModel() + + mutableItemStateFlow.value = DataState.Loaded(data = entity) + viewModel.trySendAction( + ItemSearchAction.DropdownMenuClick( + menuAction = VaultDropdownMenuAction.COPY_TO_BITWARDEN, + item = mockk { every { id } returns itemId }, + ), + ) + + verify(exactly = 1) { + mockAuthenticatorRepository.getItemStateFlow(itemId = itemId) + authenticatorBridgeManager.startAddTotpLoginItemFlow(totpUri = uriString) + } + assertEquals( + DEFAULT_STATE.copy( + dialog = ItemSearchState.DialogState.Error( + title = BitwardenString.something_went_wrong.asText(), + message = BitwardenString.please_try_again.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `DropdownMenuClick EDIT should emit NavigateToEditItem`() = runTest { + val itemId = "itemId" + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction( + ItemSearchAction.DropdownMenuClick( + menuAction = VaultDropdownMenuAction.EDIT, + item = mockk { every { id } returns itemId }, + ), + ) + assertEquals( + ItemSearchEvent.NavigateToEditItem(itemId = itemId), + awaitItem(), + ) + } + } + + @Test + fun `DropdownMenuClick DELETE should update the dialog state`() = runTest { + val itemId = "itemId" + val viewModel = createViewModel() + + viewModel.trySendAction( + ItemSearchAction.DropdownMenuClick( + menuAction = VaultDropdownMenuAction.DELETE, + item = mockk { every { id } returns itemId }, + ), + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = ItemSearchState.DialogState.DeleteConfirmationPrompt( + message = BitwardenString + .do_you_really_want_to_permanently_delete_this_cannot_be_undone + .asText(), + itemId = itemId, + ), + ), + viewModel.stateFlow.value, + ) + } + private fun createViewModel( initialState: ItemSearchState? = null, ): ItemSearchViewModel = @@ -111,9 +344,16 @@ class ItemSearchViewModelTest : BaseViewModelTest() { }, clipboardManager = mockClipboardManager, authenticatorRepository = mockAuthenticatorRepository, + authenticatorBridgeManager = authenticatorBridgeManager, ) } +private val DEFAULT_STATE: ItemSearchState = ItemSearchState( + searchTerm = "", + viewState = ItemSearchState.ViewState.Empty(message = null), + dialog = null, +) + private val LOCAL_ITEMS = listOf( createMockVerificationCodeItem(number = 1), ) @@ -167,7 +407,7 @@ private val LOCAL_DISPLAY_ITEMS = persistentListOf( ), subtitle = LOCAL_ITEMS[0].label, favorite = false, - showOverflow = false, + showOverflow = true, showMoveToBitwarden = true, ), )