PM-27071: Add overflow menu to authenticator search (#6044)

This commit is contained in:
David Perez
2025-10-17 07:58:17 -05:00
committed by GitHub
parent 9bd35ccca5
commit 8f3f1fa3ba
7 changed files with 520 additions and 32 deletions

View File

@@ -49,6 +49,7 @@ fun NavGraphBuilder.itemListingGraph(
)
itemSearchDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = navigateToEditItem,
)
qrCodeScanDestination(
onNavigateBack = { navController.popBackStack() },

View File

@@ -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()

View File

@@ -16,10 +16,12 @@ data object ItemSearchRoute
*/
fun NavGraphBuilder.itemSearchDestination(
onNavigateBack: () -> Unit,
onNavigateToEdit: (String) -> Unit,
) {
composableWithSlideTransitions<ItemSearchRoute> {
ItemSearchScreen(
onNavigateBack = onNavigateBack,
onNavigateToEdit = onNavigateToEdit,
)
}
}

View File

@@ -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
}
}

View File

@@ -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<ItemSearchState, ItemSearchEvent, ItemSearchAction>(
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<List<VerificationCodeItem>>,
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 {

View File

@@ -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))
},
)
}
}

View File

@@ -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>(
SharedVerificationCodesState.Success(items = SHARED_ITEMS),
)
private val mutableItemStateFlow =
MutableStateFlow<DataState<AuthenticatorItemEntity?>>(DataState.Loading)
private val mockAuthenticatorRepository = mockk<AuthenticatorRepository> {
every { getLocalVerificationCodesFlow() } returns mutableAuthCodesStateFlow
every { sharedCodesStateFlow } returns mutableSharedCodesFlow
every { getItemStateFlow(itemId = any()) } returns mutableItemStateFlow
}
private val authenticatorBridgeManager = mockk<AuthenticatorBridgeManager>()
private val mockClipboardManager = mockk<BitwardenClipboardManager>()
@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<AuthenticatorItemEntity> {
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<AuthenticatorItemEntity> {
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,
),
)