mirror of
https://github.com/bitwarden/android.git
synced 2026-05-06 15:58:22 -05:00
PM-27071: Add overflow menu to authenticator search (#6044)
This commit is contained in:
@@ -49,6 +49,7 @@ fun NavGraphBuilder.itemListingGraph(
|
||||
)
|
||||
itemSearchDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = navigateToEditItem,
|
||||
)
|
||||
qrCodeScanDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -16,10 +16,12 @@ data object ItemSearchRoute
|
||||
*/
|
||||
fun NavGraphBuilder.itemSearchDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToEdit: (String) -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<ItemSearchRoute> {
|
||||
ItemSearchScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToEdit = onNavigateToEdit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user