BIT-1538, BIT-1539, BIT-1660: Implement Search in autofill flow (#889)

This commit is contained in:
Brian Yencho
2024-01-31 08:52:06 -06:00
committed by Álison Fernandes
parent 5ceec9b2f7
commit ab84a4b9d3
11 changed files with 1195 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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