diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt new file mode 100644 index 0000000000..01b2bc96d6 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt @@ -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 + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt index 03d2855c63..7c23bd6b62 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt @@ -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(null) + } + var masterPasswordRepromptData by rememberSaveable { + mutableStateOf(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) + }, + ) + } + }, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt index b8ec964184..b79f0d5855 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt @@ -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( // 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, val overflowOptions: List, + val autofillSelectionOptions: List, + 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, + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/handlers/SearchHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/handlers/SearchHandlers.kt index 0b399f19b8..e2e227081c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/handlers/SearchHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/handlers/SearchHandlers.kt @@ -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)) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/model/AutofillSelectionOption.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/model/AutofillSelectionOption.kt new file mode 100644 index 0000000000..9a1b1c3986 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/model/AutofillSelectionOption.kt @@ -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, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt index 37217af534..5629900259 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt @@ -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.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.toViewState( displayItems = toDisplayItemList( baseIconUrl = baseIconUrl, isIconLoadingDisabled = isIconLoadingDisabled, + isAutofill = isAutofill, ), ) } @@ -152,17 +156,20 @@ fun List.toViewState( private fun List.toDisplayItemList( baseIconUrl: String, isIconLoadingDisabled: Boolean, + isAutofill: Boolean, ): List = 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 { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt new file mode 100644 index 0000000000..2658853820 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt @@ -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(), + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt index b862df8a7c..4d9cf8ed9b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt @@ -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() 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, + ), + ), + ), + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt index e73444c8d2..5f65b8e9ed 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt @@ -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() @@ -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, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt index e951d9b506..6f5f0030d4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt @@ -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( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt index c176dcd941..129877005a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt @@ -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, ) } }