mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-1538, BIT-1539, BIT-1660: Implement Search in autofill flow (#889)
This commit is contained in:
parent
5ceec9b2f7
commit
ab84a4b9d3
11 changed files with 1195 additions and 4 deletions
|
@ -0,0 +1,13 @@
|
|||
package com.x8bit.bitwarden.data.platform.manager.util
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
|
||||
/**
|
||||
* Returns [AutofillSelectionData] when contained in the given [SpecialCircumstance].
|
||||
*/
|
||||
fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData? =
|
||||
when (this) {
|
||||
is SpecialCircumstance.AutofillSelection -> this.autofillSelectionData
|
||||
is SpecialCircumstance.ShareNewSend -> null
|
||||
}
|
|
@ -15,11 +15,15 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialogRow
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenListItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenMasterPasswordDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.SelectionItemData
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.toIconResources
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.handlers.SearchHandlers
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOption
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
|
@ -69,6 +73,34 @@ fun SearchContent(
|
|||
-> Unit
|
||||
}
|
||||
|
||||
var autofillSelectionOptionsItem by rememberSaveable {
|
||||
mutableStateOf<SearchState.DisplayItem?>(null)
|
||||
}
|
||||
var masterPasswordRepromptData by rememberSaveable {
|
||||
mutableStateOf<MasterPasswordRepromptData?>(null)
|
||||
}
|
||||
autofillSelectionOptionsItem?.let { item ->
|
||||
AutofillSelectionDialog(
|
||||
displayItem = item,
|
||||
onAutofillItemClick = searchHandlers.onAutofillItemClick,
|
||||
onAutofillAndSaveItemClick = searchHandlers.onAutofillAndSaveItemClick,
|
||||
onViewItemClick = searchHandlers.onItemClick,
|
||||
onMasterPasswordRepromptRequest = { masterPasswordRepromptData = it },
|
||||
onDismissRequest = { autofillSelectionOptionsItem = null },
|
||||
)
|
||||
}
|
||||
masterPasswordRepromptData?.let { data ->
|
||||
BitwardenMasterPasswordDialog(
|
||||
onConfirmClick = { password ->
|
||||
searchHandlers.onMasterPasswordRepromptSubmit(password, data)
|
||||
masterPasswordRepromptData = null
|
||||
},
|
||||
onDismissRequest = {
|
||||
masterPasswordRepromptData = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
) {
|
||||
|
@ -77,7 +109,13 @@ fun SearchContent(
|
|||
startIcon = it.iconData,
|
||||
label = it.title,
|
||||
supportingLabel = it.subtitle,
|
||||
onClick = { searchHandlers.onItemClick(it.id) },
|
||||
onClick = {
|
||||
if (it.autofillSelectionOptions.isNotEmpty()) {
|
||||
autofillSelectionOptionsItem = it
|
||||
} else {
|
||||
searchHandlers.onItemClick(it.id)
|
||||
}
|
||||
},
|
||||
trailingLabelIcons = it
|
||||
.extraIconList
|
||||
.toIconResources()
|
||||
|
@ -115,3 +153,74 @@ fun SearchContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun AutofillSelectionDialog(
|
||||
displayItem: SearchState.DisplayItem,
|
||||
onAutofillItemClick: (cipherId: String) -> Unit,
|
||||
onAutofillAndSaveItemClick: (cipherId: String) -> Unit,
|
||||
onViewItemClick: (cipherId: String) -> Unit,
|
||||
onMasterPasswordRepromptRequest: (MasterPasswordRepromptData) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
val selectionCallback: (SearchState.DisplayItem, MasterPasswordRepromptData.Type) -> Unit =
|
||||
{ item, type ->
|
||||
onDismissRequest()
|
||||
if (item.shouldDisplayMasterPasswordReprompt) {
|
||||
onMasterPasswordRepromptRequest(
|
||||
MasterPasswordRepromptData(
|
||||
cipherId = item.id,
|
||||
type = type,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
when (type) {
|
||||
MasterPasswordRepromptData.Type.AUTOFILL -> {
|
||||
onAutofillItemClick(item.id)
|
||||
}
|
||||
|
||||
MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE -> {
|
||||
onAutofillAndSaveItemClick(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BitwardenSelectionDialog(
|
||||
title = stringResource(id = R.string.autofill_or_view),
|
||||
onDismissRequest = onDismissRequest,
|
||||
selectionItems = {
|
||||
if (AutofillSelectionOption.AUTOFILL in displayItem.autofillSelectionOptions) {
|
||||
BitwardenBasicDialogRow(
|
||||
text = stringResource(id = R.string.autofill),
|
||||
onClick = {
|
||||
selectionCallback(
|
||||
displayItem,
|
||||
MasterPasswordRepromptData.Type.AUTOFILL,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (AutofillSelectionOption.AUTOFILL_AND_SAVE in displayItem.autofillSelectionOptions) {
|
||||
BitwardenBasicDialogRow(
|
||||
text = stringResource(id = R.string.autofill_and_save),
|
||||
onClick = {
|
||||
selectionCallback(
|
||||
displayItem,
|
||||
MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (AutofillSelectionOption.VIEW in displayItem.autofillSelectionOptions) {
|
||||
BitwardenBasicDialogRow(
|
||||
text = stringResource(id = R.string.view),
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onViewItemClick(displayItem.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,9 +3,15 @@ package com.x8bit.bitwarden.ui.platform.feature.search
|
|||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.LoginUriView
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrNull
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
|
@ -15,6 +21,7 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
|||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
|
@ -22,6 +29,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
|
|||
import com.x8bit.bitwarden.ui.platform.base.util.concat
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconRes
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOption
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.util.filterAndOrganize
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.util.toSearchTypeData
|
||||
|
@ -53,16 +61,22 @@ class SearchViewModel @Inject constructor(
|
|||
savedStateHandle: SavedStateHandle,
|
||||
private val clock: Clock,
|
||||
private val clipboardManager: BitwardenClipboardManager,
|
||||
private val autofillSelectionManager: AutofillSelectionManager,
|
||||
private val vaultRepo: VaultRepository,
|
||||
authRepo: AuthRepository,
|
||||
private val authRepo: AuthRepository,
|
||||
environmentRepo: EnvironmentRepository,
|
||||
settingsRepo: SettingsRepository,
|
||||
specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
) : BaseViewModel<SearchState, SearchEvent, SearchAction>(
|
||||
// We load the state from the savedStateHandle for testing purposes.
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: run {
|
||||
val searchType = SearchArgs(savedStateHandle).type
|
||||
val userState = requireNotNull(authRepo.userStateFlow.value)
|
||||
val autofillSelectionData = specialCircumstanceManager
|
||||
.specialCircumstance
|
||||
?.toAutofillSelectionDataOrNull()
|
||||
|
||||
SearchState(
|
||||
searchTerm = "",
|
||||
searchType = searchType.toSearchTypeData(),
|
||||
|
@ -75,6 +89,7 @@ class SearchViewModel @Inject constructor(
|
|||
baseWebSendUrl = environmentRepo.environment.environmentUrlData.baseWebSendUrl,
|
||||
baseIconUrl = environmentRepo.environment.environmentUrlData.baseIconUrl,
|
||||
isIconLoadingDisabled = settingsRepo.isIconLoadingDisabled,
|
||||
autofillSelectionData = autofillSelectionData,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
@ -97,6 +112,12 @@ class SearchViewModel @Inject constructor(
|
|||
SearchAction.BackClick -> handleBackClick()
|
||||
SearchAction.DismissDialogClick -> handleDismissClick()
|
||||
is SearchAction.ItemClick -> handleItemClick(action)
|
||||
is SearchAction.AutofillItemClick -> handleAutofillItemClick(action)
|
||||
is SearchAction.AutofillAndSaveItemClick -> handleAutofillAndSaveItemClick(action)
|
||||
is SearchAction.MasterPasswordRepromptSubmit -> {
|
||||
handleMasterPasswordRepromptSubmit(action)
|
||||
}
|
||||
|
||||
is SearchAction.SearchTermChange -> handleSearchTermChange(action)
|
||||
is SearchAction.VaultFilterSelect -> handleVaultFilterSelect(action)
|
||||
is SearchAction.OverflowOptionClick -> handleOverflowItemClick(action)
|
||||
|
@ -125,6 +146,60 @@ class SearchViewModel @Inject constructor(
|
|||
sendEvent(event)
|
||||
}
|
||||
|
||||
private fun handleAutofillItemClick(action: SearchAction.AutofillItemClick) {
|
||||
val cipherView = getCipherViewOrNull(cipherId = action.itemId) ?: return
|
||||
autofillSelectionManager.emitAutofillSelection(cipherView = cipherView)
|
||||
}
|
||||
|
||||
private fun handleAutofillAndSaveItemClick(action: SearchAction.AutofillAndSaveItemClick) {
|
||||
val cipherView = getCipherViewOrNull(cipherId = action.itemId) ?: return
|
||||
val uris = cipherView.login?.uris.orEmpty()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = SearchState.DialogState.Loading(
|
||||
message = R.string.loading.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = vaultRepo.updateCipher(
|
||||
cipherId = action.itemId,
|
||||
cipherView = cipherView.copy(
|
||||
login = cipherView
|
||||
.login
|
||||
?.copy(
|
||||
uris = uris + LoginUriView(
|
||||
uri = state.autofillSelectionData?.uri,
|
||||
match = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
sendAction(
|
||||
SearchAction.Internal.UpdateCipherResultReceive(
|
||||
cipherId = action.itemId,
|
||||
result = result,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMasterPasswordRepromptSubmit(
|
||||
action: SearchAction.MasterPasswordRepromptSubmit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val result = authRepo.validatePassword(password = action.password)
|
||||
sendAction(
|
||||
SearchAction.Internal.ValidatePasswordResultReceive(
|
||||
masterPasswordRepromptData = action.masterPasswordRepromptData,
|
||||
result = result,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSearchTermChange(action: SearchAction.SearchTermChange) {
|
||||
mutableStateFlow.update { it.copy(searchTerm = action.searchTerm) }
|
||||
recalculateViewState()
|
||||
|
@ -305,6 +380,14 @@ class SearchViewModel @Inject constructor(
|
|||
handleRemovePasswordSendResultReceive(action)
|
||||
}
|
||||
|
||||
is SearchAction.Internal.UpdateCipherResultReceive -> {
|
||||
handleUpdateCipherResultReceive(action)
|
||||
}
|
||||
|
||||
is SearchAction.Internal.ValidatePasswordResultReceive -> {
|
||||
handleValidatePasswordResultReceive(action)
|
||||
}
|
||||
|
||||
is SearchAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
|
||||
}
|
||||
}
|
||||
|
@ -374,6 +457,82 @@ class SearchViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleUpdateCipherResultReceive(
|
||||
action: SearchAction.Internal.UpdateCipherResultReceive,
|
||||
) {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
|
||||
when (val result = action.result) {
|
||||
is UpdateCipherResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = SearchState.DialogState.Error(
|
||||
title = null,
|
||||
message = result.errorMessage?.asText()
|
||||
?: R.string.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
UpdateCipherResult.Success -> {
|
||||
// Complete the autofill selection flow
|
||||
val cipherView = getCipherViewOrNull(cipherId = action.cipherId) ?: return
|
||||
autofillSelectionManager.emitAutofillSelection(cipherView = cipherView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleValidatePasswordResultReceive(
|
||||
action: SearchAction.Internal.ValidatePasswordResultReceive,
|
||||
) {
|
||||
when (action.result) {
|
||||
ValidatePasswordResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = SearchState.DialogState.Error(
|
||||
title = null,
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ValidatePasswordResult.Success -> {
|
||||
if (!action.result.isValid) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = SearchState.DialogState.Error(
|
||||
title = null,
|
||||
message = R.string.invalid_master_password.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Complete the deferred actions
|
||||
when (action.masterPasswordRepromptData.type) {
|
||||
MasterPasswordRepromptData.Type.AUTOFILL -> {
|
||||
trySendAction(
|
||||
SearchAction.AutofillItemClick(
|
||||
itemId = action.masterPasswordRepromptData.cipherId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE -> {
|
||||
trySendAction(
|
||||
SearchAction.AutofillAndSaveItemClick(
|
||||
itemId = action.masterPasswordRepromptData.cipherId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVaultDataReceive(
|
||||
action: SearchAction.Internal.VaultDataReceive,
|
||||
) {
|
||||
|
@ -462,6 +621,7 @@ class SearchViewModel @Inject constructor(
|
|||
searchTerm = state.searchTerm,
|
||||
baseIconUrl = state.baseIconUrl,
|
||||
isIconLoadingDisabled = state.isIconLoadingDisabled,
|
||||
isAutofill = state.isAutofill,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -480,6 +640,14 @@ class SearchViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCipherViewOrNull(cipherId: String) =
|
||||
vaultRepo
|
||||
.vaultDataStateFlow
|
||||
.value
|
||||
.data
|
||||
?.cipherViewList
|
||||
?.firstOrNull { it.id == cipherId }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -495,7 +663,16 @@ data class SearchState(
|
|||
val baseWebSendUrl: String,
|
||||
val baseIconUrl: String,
|
||||
val isIconLoadingDisabled: Boolean,
|
||||
// Internal
|
||||
val autofillSelectionData: AutofillSelectionData? = null,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Whether or not this represents an autofill selection flow.
|
||||
*/
|
||||
val isAutofill: Boolean
|
||||
get() = autofillSelectionData != null
|
||||
|
||||
/**
|
||||
* Represents the specific view states for the search screen.
|
||||
*/
|
||||
|
@ -578,6 +755,8 @@ data class SearchState(
|
|||
val iconData: IconData,
|
||||
val extraIconList: List<IconRes>,
|
||||
val overflowOptions: List<ListingItemOverflowAction>,
|
||||
val autofillSelectionOptions: List<AutofillSelectionOption>,
|
||||
val shouldDisplayMasterPasswordReprompt: Boolean,
|
||||
) : Parcelable
|
||||
}
|
||||
|
||||
|
@ -748,6 +927,28 @@ sealed class SearchAction {
|
|||
val itemId: String,
|
||||
) : SearchAction()
|
||||
|
||||
/**
|
||||
* User clicked a row item as an autofill selection.
|
||||
*/
|
||||
data class AutofillItemClick(
|
||||
val itemId: String,
|
||||
) : SearchAction()
|
||||
|
||||
/**
|
||||
* User clicked a row item as an autofill-and-save selection.
|
||||
*/
|
||||
data class AutofillAndSaveItemClick(
|
||||
val itemId: String,
|
||||
) : SearchAction()
|
||||
|
||||
/**
|
||||
* User clicked a row item for autofill but must satisfy the master password reprompt.
|
||||
*/
|
||||
data class MasterPasswordRepromptSubmit(
|
||||
val password: String,
|
||||
val masterPasswordRepromptData: MasterPasswordRepromptData,
|
||||
) : SearchAction()
|
||||
|
||||
/**
|
||||
* User updated the search term.
|
||||
*/
|
||||
|
@ -801,6 +1002,23 @@ sealed class SearchAction {
|
|||
val result: RemovePasswordSendResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates a result for updating a cipher during the autofill-and-save process.
|
||||
*/
|
||||
data class UpdateCipherResultReceive(
|
||||
val cipherId: String,
|
||||
val result: UpdateCipherResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates a result for validating the user's master password during an autofill selection
|
||||
* process.
|
||||
*/
|
||||
data class ValidatePasswordResultReceive(
|
||||
val masterPasswordRepromptData: MasterPasswordRepromptData,
|
||||
val result: ValidatePasswordResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates vault data was received.
|
||||
*/
|
||||
|
@ -861,3 +1079,22 @@ sealed class SearchEvent {
|
|||
val message: Text,
|
||||
) : SearchEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Data tracking the type of request that triggered a master password reprompt during an autofill
|
||||
* selection process.
|
||||
*/
|
||||
@Parcelize
|
||||
data class MasterPasswordRepromptData(
|
||||
val cipherId: String,
|
||||
val type: Type,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* The type of action that requires the prompt.
|
||||
*/
|
||||
enum class Type {
|
||||
AUTOFILL,
|
||||
AUTOFILL_AND_SAVE,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.search.handlers
|
||||
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.MasterPasswordRepromptData
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.SearchAction
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.SearchViewModel
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
|
||||
|
@ -12,6 +13,9 @@ data class SearchHandlers(
|
|||
val onBackClick: () -> Unit,
|
||||
val onDismissRequest: () -> Unit,
|
||||
val onItemClick: (String) -> Unit,
|
||||
val onAutofillItemClick: (String) -> Unit,
|
||||
val onAutofillAndSaveItemClick: (String) -> Unit,
|
||||
val onMasterPasswordRepromptSubmit: (password: String, MasterPasswordRepromptData) -> Unit,
|
||||
val onSearchTermChange: (String) -> Unit,
|
||||
val onVaultFilterSelect: (VaultFilterType) -> Unit,
|
||||
val onOverflowItemClick: (ListingItemOverflowAction) -> Unit,
|
||||
|
@ -26,6 +30,20 @@ data class SearchHandlers(
|
|||
onBackClick = { viewModel.trySendAction(SearchAction.BackClick) },
|
||||
onDismissRequest = { viewModel.trySendAction(SearchAction.DismissDialogClick) },
|
||||
onItemClick = { viewModel.trySendAction(SearchAction.ItemClick(it)) },
|
||||
onAutofillItemClick = {
|
||||
viewModel.trySendAction(SearchAction.AutofillItemClick(it))
|
||||
},
|
||||
onAutofillAndSaveItemClick = {
|
||||
viewModel.trySendAction(SearchAction.AutofillAndSaveItemClick(it))
|
||||
},
|
||||
onMasterPasswordRepromptSubmit = { password, data ->
|
||||
viewModel.trySendAction(
|
||||
SearchAction.MasterPasswordRepromptSubmit(
|
||||
password = password,
|
||||
masterPasswordRepromptData = data,
|
||||
),
|
||||
)
|
||||
},
|
||||
onSearchTermChange = { viewModel.trySendAction(SearchAction.SearchTermChange(it)) },
|
||||
onVaultFilterSelect = {
|
||||
viewModel.trySendAction(SearchAction.VaultFilterSelect(it))
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.search.model
|
||||
|
||||
/**
|
||||
* Possible options available during the autofill process on the Search screen.
|
||||
*/
|
||||
enum class AutofillSelectionOption {
|
||||
/**
|
||||
* The item should be selected for autofill.
|
||||
*/
|
||||
AUTOFILL,
|
||||
|
||||
/**
|
||||
* The item should be selected for autofill and updated to be linked to the given URI.
|
||||
*/
|
||||
AUTOFILL_AND_SAVE,
|
||||
|
||||
/**
|
||||
* The item should be viewed.
|
||||
*/
|
||||
VIEW,
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.search.util
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.bitwarden.core.CipherRepromptType
|
||||
import com.bitwarden.core.CipherType
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.bitwarden.core.CollectionView
|
||||
|
@ -16,6 +17,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.removeDiacritics
|
|||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.SearchState
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.SearchTypeData
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOption
|
||||
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.util.toLabelIcons
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.util.toOverflowActions
|
||||
|
@ -130,6 +132,7 @@ fun List<CipherView>.toViewState(
|
|||
searchTerm: String,
|
||||
baseIconUrl: String,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
isAutofill: Boolean,
|
||||
): SearchState.ViewState =
|
||||
when {
|
||||
searchTerm.isEmpty() -> SearchState.ViewState.Empty(message = null)
|
||||
|
@ -138,6 +141,7 @@ fun List<CipherView>.toViewState(
|
|||
displayItems = toDisplayItemList(
|
||||
baseIconUrl = baseIconUrl,
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
isAutofill = isAutofill,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@ -152,17 +156,20 @@ fun List<CipherView>.toViewState(
|
|||
private fun List<CipherView>.toDisplayItemList(
|
||||
baseIconUrl: String,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
isAutofill: Boolean,
|
||||
): List<SearchState.DisplayItem> =
|
||||
this.map {
|
||||
it.toDisplayItem(
|
||||
baseIconUrl = baseIconUrl,
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
isAutofill = isAutofill,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CipherView.toDisplayItem(
|
||||
baseIconUrl: String,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
isAutofill: Boolean,
|
||||
): SearchState.DisplayItem =
|
||||
SearchState.DisplayItem(
|
||||
id = id.orEmpty(),
|
||||
|
@ -175,6 +182,15 @@ private fun CipherView.toDisplayItem(
|
|||
extraIconList = toLabelIcons(),
|
||||
overflowOptions = toOverflowActions(),
|
||||
totpCode = login?.totp,
|
||||
autofillSelectionOptions = AutofillSelectionOption
|
||||
.entries
|
||||
// Only valid for autofill
|
||||
.filter { isAutofill }
|
||||
// Only Login types get the save option
|
||||
.filter {
|
||||
this.login != null || (it != AutofillSelectionOption.AUTOFILL_AND_SAVE)
|
||||
},
|
||||
shouldDisplayMasterPasswordReprompt = isAutofill && reprompt == CipherRepromptType.PASSWORD,
|
||||
)
|
||||
|
||||
private fun CipherView.toIconData(
|
||||
|
@ -307,6 +323,8 @@ private fun SendView.toDisplayItem(
|
|||
extraIconList = toLabelIcons(clock = clock),
|
||||
overflowOptions = toOverflowActions(baseWebSendUrl = baseWebSendUrl),
|
||||
totpCode = null,
|
||||
autofillSelectionOptions = emptyList(),
|
||||
shouldDisplayMasterPasswordReprompt = false,
|
||||
)
|
||||
|
||||
private enum class SortPriority {
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
package com.x8bit.bitwarden.data.platform.manager.util
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SpecialCircumstanceExtensionsTest {
|
||||
|
||||
@Test
|
||||
fun `toAutofillSelectionDataOrNull should a non-null value for AutofillSelection`() {
|
||||
val autofillSelectionData = AutofillSelectionData(
|
||||
type = AutofillSelectionData.Type.LOGIN,
|
||||
uri = "uri",
|
||||
)
|
||||
assertEquals(
|
||||
autofillSelectionData,
|
||||
SpecialCircumstance
|
||||
.AutofillSelection(
|
||||
autofillSelectionData = autofillSelectionData,
|
||||
shouldFinishWhenComplete = true,
|
||||
)
|
||||
.toAutofillSelectionDataOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toAutofillSelectionDataOrNull should a non-null value for other types`() {
|
||||
assertNull(
|
||||
SpecialCircumstance
|
||||
.ShareNewSend(
|
||||
data = mockk(),
|
||||
shouldFinishWhenComplete = true,
|
||||
)
|
||||
.toAutofillSelectionDataOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -8,15 +8,18 @@ import androidx.compose.ui.test.hasAnyAncestor
|
|||
import androidx.compose.ui.test.hasScrollToNodeAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onChildren
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.core.net.toUri
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOption
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.util.createMockDisplayItemForCipher
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.util.createMockDisplayItemForSend
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
|
@ -35,6 +38,7 @@ import org.junit.Assert.assertTrue
|
|||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class SearchScreenTest : BaseComposeTest() {
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<SearchEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
|
@ -197,6 +201,282 @@ class SearchScreenTest : BaseComposeTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clicking on a display item with autofill options should open the autofill option selection dialog`() {
|
||||
mutableStateFlow.value = createStateForAutofill()
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "mockName-1")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Do you want to auto-fill or view this item?")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Auto-fill")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Auto-fill and save")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("View")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Cancel")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
verify(exactly = 0) { viewModel.trySendAction(any()) }
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clicking on cancel in selection dialog should close dialog`() {
|
||||
mutableStateFlow.value = createStateForAutofill()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "mockName-1")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Cancel")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clicking on autofill option in selection dialog when no reprompt required should send AutofillItemClick and close dialog`() {
|
||||
mutableStateFlow.value = createStateForAutofill()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "mockName-1")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Auto-fill")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(SearchAction.AutofillItemClick(itemId = "mockId-1")) }
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clicking on autofill option in selection dialog when reprompt is required should show master password dialog`() {
|
||||
mutableStateFlow.value = createStateForAutofill(isRepromptRequired = true)
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "mockName-1")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Auto-fill")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Master password confirmation")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText(
|
||||
text = "This action is protected, to continue please re-enter your master " +
|
||||
"password to verify your identity.",
|
||||
)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Master password")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Submit")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clicking on autofill-and-save option in selection dialog when no reprompt required should send AutofillAndSaveItemClick and close dialog`() {
|
||||
mutableStateFlow.value = createStateForAutofill()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "mockName-1")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Auto-fill and save")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(SearchAction.AutofillAndSaveItemClick(itemId = "mockId-1")) }
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clicking on autofill-and-save option in selection dialog when reprompt is required should show master password dialog`() {
|
||||
mutableStateFlow.value = createStateForAutofill(isRepromptRequired = true)
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "mockName-1")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Auto-fill and save")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Master password confirmation")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText(
|
||||
text = "This action is protected, to continue please re-enter your master " +
|
||||
"password to verify your identity.",
|
||||
)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Master password")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Submit")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clicking on view option in selection dialog when no reprompt required should send ItemClick and close dialog`() {
|
||||
mutableStateFlow.value = createStateForAutofill()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "mockName-1")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("View")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(SearchAction.ItemClick(itemId = "mockId-1")) }
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking cancel on the master password dialog should close the dialog`() {
|
||||
mutableStateFlow.value = createStateForAutofill(isRepromptRequired = true)
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "mockName-1")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
composeTestRule
|
||||
.onNodeWithText("Auto-fill")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Cancel")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clicking submit on the master password dialog for autofill should close the dialog and send MasterPasswordRepromptSubmit`() {
|
||||
mutableStateFlow.value = createStateForAutofill(isRepromptRequired = true)
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "mockName-1")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
composeTestRule
|
||||
.onNodeWithText("Auto-fill")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Master password")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performTextInput("password")
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Submit")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
SearchAction.MasterPasswordRepromptSubmit(
|
||||
password = "password",
|
||||
masterPasswordRepromptData = MasterPasswordRepromptData(
|
||||
cipherId = "mockId-1",
|
||||
type = MasterPasswordRepromptData.Type.AUTOFILL,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clicking submit on the master password dialog for autofill-and-save should close the dialog and send MasterPasswordRepromptSubmit`() {
|
||||
mutableStateFlow.value = createStateForAutofill(isRepromptRequired = true)
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "mockName-1")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
composeTestRule
|
||||
.onNodeWithText("Auto-fill and save")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Master password")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performTextInput("password")
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Submit")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
SearchAction.MasterPasswordRepromptSubmit(
|
||||
password = "password",
|
||||
masterPasswordRepromptData = MasterPasswordRepromptData(
|
||||
cipherId = "mockId-1",
|
||||
type = MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `topBar search placeholder should be displayed according to state`() {
|
||||
mutableStateFlow.update { DEFAULT_STATE }
|
||||
|
@ -439,3 +719,18 @@ private val DEFAULT_STATE: SearchState = SearchState(
|
|||
baseIconUrl = "www.test.com",
|
||||
isIconLoadingDisabled = false,
|
||||
)
|
||||
|
||||
private fun createStateForAutofill(
|
||||
isRepromptRequired: Boolean = false,
|
||||
): SearchState = DEFAULT_STATE
|
||||
.copy(
|
||||
viewState = SearchState.ViewState.Content(
|
||||
displayItems = listOf(
|
||||
createMockDisplayItemForCipher(number = 1)
|
||||
.copy(
|
||||
autofillSelectionOptions = AutofillSelectionOption.entries,
|
||||
shouldDisplayMasterPasswordReprompt = isRepromptRequired,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -3,11 +3,20 @@ package com.x8bit.bitwarden.ui.platform.feature.search
|
|||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import app.cash.turbine.turbineScope
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.bitwarden.core.LoginUriView
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
|
@ -15,11 +24,14 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
|||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockLoginView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockUriView
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
|
@ -30,7 +42,9 @@ import com.x8bit.bitwarden.ui.platform.feature.search.util.toViewState
|
|||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList
|
||||
import io.mockk.awaits
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
|
@ -53,6 +67,9 @@ import java.time.ZoneOffset
|
|||
@Suppress("LargeClass")
|
||||
class SearchViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val autofillSelectionManager: AutofillSelectionManager =
|
||||
AutofillSelectionManagerImpl()
|
||||
|
||||
private val clock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
|
@ -79,6 +96,8 @@ class SearchViewModelTest : BaseViewModelTest() {
|
|||
every { isIconLoadingDisabled } returns false
|
||||
every { isIconLoadingDisabledFlow } returns mutableIsIconLoadingDisabledFlow
|
||||
}
|
||||
private val specialCircumstanceManager: SpecialCircumstanceManager =
|
||||
SpecialCircumstanceManagerImpl()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
|
@ -145,6 +164,274 @@ class SearchViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AutofillItemClick should emit NavigateToViewCipher`() = runTest {
|
||||
val cipherView = setupForAutofill()
|
||||
val cipherId = CIPHER_ID
|
||||
val viewModel = createViewModel()
|
||||
|
||||
autofillSelectionManager.autofillSelectionFlow.test {
|
||||
viewModel.trySendAction(SearchAction.AutofillItemClick(itemId = cipherId))
|
||||
assertEquals(cipherView, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AutofillAndSaveItemClick with request error should show error dialog`() = runTest {
|
||||
val cipherView = setupForAutofill()
|
||||
val cipherId = CIPHER_ID
|
||||
val errorMessage = "Server error"
|
||||
val updatedCipherView = cipherView.copy(
|
||||
login = createMockLoginView(1).copy(
|
||||
uris = listOf(createMockUriView(number = 1)) +
|
||||
LoginUriView(
|
||||
uri = AUTOFILL_URI,
|
||||
match = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = createViewModel()
|
||||
coEvery {
|
||||
vaultRepository.updateCipher(
|
||||
cipherId = cipherId,
|
||||
cipherView = updatedCipherView,
|
||||
)
|
||||
} returns UpdateCipherResult.Error(errorMessage)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(INITIAL_STATE_FOR_AUTOFILL, awaitItem())
|
||||
|
||||
viewModel.trySendAction(SearchAction.AutofillAndSaveItemClick(itemId = cipherId))
|
||||
|
||||
assertEquals(
|
||||
INITIAL_STATE_FOR_AUTOFILL
|
||||
.copy(
|
||||
dialogState = SearchState.DialogState.Loading(
|
||||
message = R.string.loading.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
INITIAL_STATE_FOR_AUTOFILL
|
||||
.copy(
|
||||
dialogState = SearchState.DialogState.Error(
|
||||
title = null,
|
||||
message = errorMessage.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `AutofillAndSaveItemClick with request success should post to the AutofillSelectionManager`() =
|
||||
runTest {
|
||||
val cipherView = setupForAutofill()
|
||||
val cipherId = CIPHER_ID
|
||||
val updatedCipherView = cipherView.copy(
|
||||
login = createMockLoginView(1).copy(
|
||||
uris = listOf(createMockUriView(number = 1)) +
|
||||
LoginUriView(
|
||||
uri = AUTOFILL_URI,
|
||||
match = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = createViewModel()
|
||||
coEvery {
|
||||
vaultRepository.updateCipher(
|
||||
cipherId = cipherId,
|
||||
cipherView = updatedCipherView,
|
||||
)
|
||||
} returns UpdateCipherResult.Success
|
||||
|
||||
turbineScope {
|
||||
val stateTurbine = viewModel
|
||||
.stateFlow
|
||||
.testIn(backgroundScope)
|
||||
val selectionTurbine = autofillSelectionManager
|
||||
.autofillSelectionFlow
|
||||
.testIn(backgroundScope)
|
||||
|
||||
assertEquals(INITIAL_STATE_FOR_AUTOFILL, stateTurbine.awaitItem())
|
||||
|
||||
viewModel.trySendAction(SearchAction.AutofillAndSaveItemClick(itemId = cipherId))
|
||||
|
||||
assertEquals(
|
||||
INITIAL_STATE_FOR_AUTOFILL
|
||||
.copy(
|
||||
dialogState = SearchState.DialogState.Loading(
|
||||
message = R.string.loading.asText(),
|
||||
),
|
||||
),
|
||||
stateTurbine.awaitItem(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
INITIAL_STATE_FOR_AUTOFILL,
|
||||
stateTurbine.awaitItem(),
|
||||
)
|
||||
|
||||
// Autofill flow is completed
|
||||
assertEquals(cipherView, selectionTurbine.awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MasterPasswordRepromptSubmit for a request Error should show a generic error dialog`() =
|
||||
runTest {
|
||||
setupMockUri()
|
||||
setupForAutofill()
|
||||
val cipherId = CIPHER_ID
|
||||
val password = "password"
|
||||
coEvery {
|
||||
authRepository.validatePassword(password = password)
|
||||
} returns ValidatePasswordResult.Error
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(
|
||||
INITIAL_STATE_FOR_AUTOFILL,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
viewModel.trySendAction(
|
||||
SearchAction.MasterPasswordRepromptSubmit(
|
||||
password = password,
|
||||
masterPasswordRepromptData = MasterPasswordRepromptData(
|
||||
cipherId = cipherId,
|
||||
type = MasterPasswordRepromptData.Type.AUTOFILL,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
INITIAL_STATE_FOR_AUTOFILL.copy(
|
||||
dialogState = SearchState.DialogState.Error(
|
||||
title = null,
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `MasterPasswordRepromptSubmit for a request Success with an invalid password should show an invalid password dialog`() =
|
||||
runTest {
|
||||
setupMockUri()
|
||||
setupForAutofill()
|
||||
val cipherId = CIPHER_ID
|
||||
val password = "password"
|
||||
coEvery {
|
||||
authRepository.validatePassword(password = password)
|
||||
} returns ValidatePasswordResult.Success(isValid = false)
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(
|
||||
INITIAL_STATE_FOR_AUTOFILL,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
viewModel.trySendAction(
|
||||
SearchAction.MasterPasswordRepromptSubmit(
|
||||
password = password,
|
||||
masterPasswordRepromptData = MasterPasswordRepromptData(
|
||||
cipherId = cipherId,
|
||||
type = MasterPasswordRepromptData.Type.AUTOFILL,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
INITIAL_STATE_FOR_AUTOFILL.copy(
|
||||
dialogState = SearchState.DialogState.Error(
|
||||
title = null,
|
||||
message = R.string.invalid_master_password.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `MasterPasswordRepromptSubmit for a request Success with a valid password for autofill should post to the AutofillSelectionManager`() =
|
||||
runTest {
|
||||
setupMockUri()
|
||||
val cipherView = setupForAutofill()
|
||||
val cipherId = CIPHER_ID
|
||||
val password = "password"
|
||||
coEvery {
|
||||
authRepository.validatePassword(password = password)
|
||||
} returns ValidatePasswordResult.Success(isValid = true)
|
||||
val viewModel = createViewModel()
|
||||
|
||||
autofillSelectionManager.autofillSelectionFlow.test {
|
||||
viewModel.trySendAction(
|
||||
SearchAction.MasterPasswordRepromptSubmit(
|
||||
password = password,
|
||||
masterPasswordRepromptData = MasterPasswordRepromptData(
|
||||
cipherId = cipherId,
|
||||
type = MasterPasswordRepromptData.Type.AUTOFILL,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
cipherView,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `MasterPasswordRepromptSubmit for a request Success with a valid password for autofill-and-save should make a the cipher update request`() =
|
||||
runTest {
|
||||
setupMockUri()
|
||||
val cipherView = setupForAutofill()
|
||||
val cipherId = CIPHER_ID
|
||||
val password = "password"
|
||||
val updatedCipherView = cipherView.copy(
|
||||
login = createMockLoginView(1).copy(
|
||||
uris = listOf(createMockUriView(number = 1)) +
|
||||
LoginUriView(
|
||||
uri = AUTOFILL_URI,
|
||||
match = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
coEvery {
|
||||
authRepository.validatePassword(password = password)
|
||||
} returns ValidatePasswordResult.Success(isValid = true)
|
||||
coEvery {
|
||||
vaultRepository.updateCipher(
|
||||
cipherId = cipherId,
|
||||
cipherView = updatedCipherView,
|
||||
)
|
||||
} just awaits
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(
|
||||
SearchAction.MasterPasswordRepromptSubmit(
|
||||
password = password,
|
||||
masterPasswordRepromptData = MasterPasswordRepromptData(
|
||||
cipherId = cipherId,
|
||||
type = MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
coVerify {
|
||||
vaultRepository.updateCipher(
|
||||
cipherId = cipherId,
|
||||
cipherView = updatedCipherView,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OverflowOptionClick Send EditClick should emit NavigateToEditSend`() = runTest {
|
||||
val sendId = "sendId"
|
||||
|
@ -492,6 +779,7 @@ class SearchViewModelTest : BaseViewModelTest() {
|
|||
searchTerm = "",
|
||||
baseIconUrl = "https://vault.bitwarden.com/icons",
|
||||
isIconLoadingDisabled = false,
|
||||
isAutofill = false,
|
||||
)
|
||||
} returns expectedViewState
|
||||
val dataState = DataState.Loaded(
|
||||
|
@ -591,6 +879,7 @@ class SearchViewModelTest : BaseViewModelTest() {
|
|||
searchTerm = "",
|
||||
baseIconUrl = "https://vault.bitwarden.com/icons",
|
||||
isIconLoadingDisabled = false,
|
||||
isAutofill = false,
|
||||
)
|
||||
} returns expectedViewState
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
|
@ -700,6 +989,7 @@ class SearchViewModelTest : BaseViewModelTest() {
|
|||
searchTerm = "",
|
||||
baseIconUrl = "https://vault.bitwarden.com/icons",
|
||||
isIconLoadingDisabled = false,
|
||||
isAutofill = false,
|
||||
)
|
||||
} returns expectedViewState
|
||||
val dataState = DataState.Error(
|
||||
|
@ -809,6 +1099,7 @@ class SearchViewModelTest : BaseViewModelTest() {
|
|||
searchTerm = "",
|
||||
baseIconUrl = "https://vault.bitwarden.com/icons",
|
||||
isIconLoadingDisabled = false,
|
||||
isAutofill = false,
|
||||
)
|
||||
} returns expectedViewState
|
||||
val dataState = DataState.NoNetwork(
|
||||
|
@ -941,8 +1232,53 @@ class SearchViewModelTest : BaseViewModelTest() {
|
|||
environmentRepo = environmentRepository,
|
||||
settingsRepo = settingsRepository,
|
||||
clipboardManager = clipboardManager,
|
||||
specialCircumstanceManager = specialCircumstanceManager,
|
||||
autofillSelectionManager = autofillSelectionManager,
|
||||
)
|
||||
|
||||
/**
|
||||
* Generates and returns [CipherView] to be populated for autofill testing and sets up the
|
||||
* state to return that item.
|
||||
*/
|
||||
private fun setupForAutofill(): CipherView {
|
||||
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.AutofillSelection(
|
||||
autofillSelectionData = AUTOFILL_SELECTION_DATA,
|
||||
shouldFinishWhenComplete = true,
|
||||
)
|
||||
val cipherView = createMockCipherView(number = 1)
|
||||
val ciphers = listOf(cipherView)
|
||||
val expectedViewState = SearchState.ViewState.Content(
|
||||
displayItems = listOf(createMockDisplayItemForCipher(number = 1)),
|
||||
)
|
||||
every {
|
||||
ciphers.filterAndOrganize(
|
||||
searchTypeData = SearchTypeData.Vault.All,
|
||||
searchTerm = "",
|
||||
)
|
||||
} returns ciphers
|
||||
every {
|
||||
ciphers.toFilteredList(vaultFilterType = VaultFilterType.AllVaults)
|
||||
} returns ciphers
|
||||
every {
|
||||
ciphers.toViewState(
|
||||
searchTerm = "",
|
||||
baseIconUrl = "https://vault.bitwarden.com/icons",
|
||||
isIconLoadingDisabled = false,
|
||||
isAutofill = true,
|
||||
)
|
||||
} returns expectedViewState
|
||||
val dataState = DataState.Loaded(
|
||||
data = VaultData(
|
||||
cipherViewList = ciphers,
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
collectionViewList = listOf(createMockCollectionView(number = 1)),
|
||||
sendViewList = listOf(createMockSendView(number = 1)),
|
||||
),
|
||||
)
|
||||
mutableVaultDataStateFlow.value = dataState
|
||||
return cipherView
|
||||
}
|
||||
|
||||
private fun setupMockUri() {
|
||||
mockkStatic(Uri::class)
|
||||
val uriMock = mockk<Uri>()
|
||||
|
@ -980,3 +1316,21 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
),
|
||||
),
|
||||
)
|
||||
|
||||
private const val AUTOFILL_URI = "autofill-uri"
|
||||
|
||||
private const val CIPHER_ID = "mockId-1"
|
||||
|
||||
private val AUTOFILL_SELECTION_DATA =
|
||||
AutofillSelectionData(
|
||||
type = AutofillSelectionData.Type.LOGIN,
|
||||
uri = AUTOFILL_URI,
|
||||
)
|
||||
|
||||
private val INITIAL_STATE_FOR_AUTOFILL =
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = SearchState.ViewState.Content(
|
||||
displayItems = listOf(createMockDisplayItemForCipher(number = 1)),
|
||||
),
|
||||
autofillSelectionData = AUTOFILL_SELECTION_DATA,
|
||||
)
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.search.util
|
||||
|
||||
import android.net.Uri
|
||||
import com.bitwarden.core.CipherRepromptType
|
||||
import com.bitwarden.core.CipherType
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.bitwarden.core.CollectionView
|
||||
import com.bitwarden.core.FolderView
|
||||
|
@ -11,6 +13,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
|
|||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.SearchState
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.SearchTypeData
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOption
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
|
@ -28,6 +31,11 @@ class SearchTypeDataExtensionsTest {
|
|||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun tearDown() {
|
||||
unmockkStatic(Uri::parse)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `updateWithAdditionalDataIfNecessary should update the collection name when searchTypeData is a vault collection`() {
|
||||
|
@ -262,6 +270,7 @@ class SearchTypeDataExtensionsTest {
|
|||
searchTerm = "",
|
||||
baseIconUrl = "www.test.com",
|
||||
isIconLoadingDisabled = false,
|
||||
isAutofill = false,
|
||||
)
|
||||
|
||||
assertEquals(SearchState.ViewState.Empty(message = null), result)
|
||||
|
@ -284,6 +293,7 @@ class SearchTypeDataExtensionsTest {
|
|||
searchTerm = "mock",
|
||||
baseIconUrl = "https://vault.bitwarden.com/icons",
|
||||
isIconLoadingDisabled = false,
|
||||
isAutofill = false,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
|
@ -296,7 +306,70 @@ class SearchTypeDataExtensionsTest {
|
|||
),
|
||||
result,
|
||||
)
|
||||
unmockkStatic(Uri::parse)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `CipherViews toViewState should return content state for autofill when search term is not blank and ciphers is not empty`() {
|
||||
mockkStatic(Uri::parse)
|
||||
every { Uri.parse(any()) } returns mockk {
|
||||
every { host } returns "www.mockuri.com"
|
||||
}
|
||||
val sends = listOf(
|
||||
createMockCipherView(
|
||||
number = 0,
|
||||
cipherType = CipherType.CARD,
|
||||
)
|
||||
.copy(
|
||||
reprompt = CipherRepromptType.PASSWORD,
|
||||
),
|
||||
createMockCipherView(number = 1),
|
||||
createMockCipherView(number = 2),
|
||||
)
|
||||
|
||||
val result = sends.toViewState(
|
||||
searchTerm = "mock",
|
||||
baseIconUrl = "https://vault.bitwarden.com/icons",
|
||||
isIconLoadingDisabled = false,
|
||||
isAutofill = true,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
SearchState.ViewState.Content(
|
||||
displayItems = listOf(
|
||||
createMockDisplayItemForCipher(
|
||||
number = 0,
|
||||
cipherType = CipherType.CARD,
|
||||
)
|
||||
.copy(
|
||||
autofillSelectionOptions = listOf(
|
||||
AutofillSelectionOption.AUTOFILL,
|
||||
AutofillSelectionOption.VIEW,
|
||||
),
|
||||
shouldDisplayMasterPasswordReprompt = true,
|
||||
),
|
||||
createMockDisplayItemForCipher(number = 1)
|
||||
.copy(
|
||||
autofillSelectionOptions = listOf(
|
||||
AutofillSelectionOption.AUTOFILL,
|
||||
AutofillSelectionOption.AUTOFILL_AND_SAVE,
|
||||
AutofillSelectionOption.VIEW,
|
||||
),
|
||||
shouldDisplayMasterPasswordReprompt = false,
|
||||
),
|
||||
createMockDisplayItemForCipher(number = 2)
|
||||
.copy(
|
||||
autofillSelectionOptions = listOf(
|
||||
AutofillSelectionOption.AUTOFILL,
|
||||
AutofillSelectionOption.AUTOFILL_AND_SAVE,
|
||||
AutofillSelectionOption.VIEW,
|
||||
),
|
||||
shouldDisplayMasterPasswordReprompt = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
|
@ -306,6 +379,7 @@ class SearchTypeDataExtensionsTest {
|
|||
searchTerm = "a",
|
||||
baseIconUrl = "www.test.com",
|
||||
isIconLoadingDisabled = false,
|
||||
isAutofill = false,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
|
|
|
@ -53,6 +53,8 @@ fun createMockDisplayItemForCipher(
|
|||
),
|
||||
),
|
||||
totpCode = "mockTotp-$number",
|
||||
autofillSelectionOptions = emptyList(),
|
||||
shouldDisplayMasterPasswordReprompt = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -80,6 +82,8 @@ fun createMockDisplayItemForCipher(
|
|||
),
|
||||
),
|
||||
totpCode = null,
|
||||
autofillSelectionOptions = emptyList(),
|
||||
shouldDisplayMasterPasswordReprompt = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -87,7 +91,7 @@ fun createMockDisplayItemForCipher(
|
|||
SearchState.DisplayItem(
|
||||
id = "mockId-$number",
|
||||
title = "mockName-$number",
|
||||
subtitle = "er-$number",
|
||||
subtitle = "mockBrand-$number, *er-$number",
|
||||
iconData = IconData.Local(R.drawable.ic_card_item),
|
||||
extraIconList = listOf(
|
||||
IconRes(
|
||||
|
@ -110,6 +114,8 @@ fun createMockDisplayItemForCipher(
|
|||
),
|
||||
),
|
||||
totpCode = null,
|
||||
autofillSelectionOptions = emptyList(),
|
||||
shouldDisplayMasterPasswordReprompt = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -134,6 +140,8 @@ fun createMockDisplayItemForCipher(
|
|||
ListingItemOverflowAction.VaultAction.EditClick(cipherId = "mockId-$number"),
|
||||
),
|
||||
totpCode = null,
|
||||
autofillSelectionOptions = emptyList(),
|
||||
shouldDisplayMasterPasswordReprompt = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -175,6 +183,8 @@ fun createMockDisplayItemForSend(
|
|||
ListingItemOverflowAction.SendAction.DeleteClick(sendId = "mockId-$number"),
|
||||
),
|
||||
totpCode = null,
|
||||
autofillSelectionOptions = emptyList(),
|
||||
shouldDisplayMasterPasswordReprompt = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -206,6 +216,8 @@ fun createMockDisplayItemForSend(
|
|||
ListingItemOverflowAction.SendAction.DeleteClick(sendId = "mockId-$number"),
|
||||
),
|
||||
totpCode = null,
|
||||
autofillSelectionOptions = emptyList(),
|
||||
shouldDisplayMasterPasswordReprompt = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue