mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-1147, BIT-1487: Implementing blocking auto-fill for specific URIs (#710)
This commit is contained in:
parent
83b77730f5
commit
79bc483491
8 changed files with 711 additions and 8 deletions
|
@ -60,6 +60,7 @@ fun BitwardenTextField(
|
||||||
textStyle: TextStyle? = null,
|
textStyle: TextStyle? = null,
|
||||||
shouldAddCustomLineBreaks: Boolean = false,
|
shouldAddCustomLineBreaks: Boolean = false,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
isError: Boolean = false,
|
||||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||||
) {
|
) {
|
||||||
var widthPx by remember { mutableStateOf(0) }
|
var widthPx by remember { mutableStateOf(0) }
|
||||||
|
@ -109,6 +110,7 @@ fun BitwardenTextField(
|
||||||
readOnly = readOnly,
|
readOnly = readOnly,
|
||||||
textStyle = currentTextStyle,
|
textStyle = currentTextStyle,
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType),
|
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType),
|
||||||
|
isError = isError,
|
||||||
visualTransformation = visualTransformation,
|
visualTransformation = visualTransformation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredHeightIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import com.x8bit.bitwarden.R
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.util.maxDialogHeight
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog for adding a blocked URI.
|
||||||
|
*/
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
@Composable
|
||||||
|
fun AddEditBlockedUriDialog(
|
||||||
|
uri: String,
|
||||||
|
isEdit: Boolean,
|
||||||
|
errorMessage: String?,
|
||||||
|
onUriChange: (String) -> Unit,
|
||||||
|
onCancelClick: () -> Unit,
|
||||||
|
onSaveClick: (String) -> Unit,
|
||||||
|
onDeleteClick: (() -> Unit)? = null,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
) {
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.requiredHeightIn(
|
||||||
|
max = configuration.maxDialogHeight,
|
||||||
|
)
|
||||||
|
// This background is necessary for the dialog to not be transparent.
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
),
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 24.dp, start = 24.dp, end = 24.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
text = stringResource(id = R.string.new_uri),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
)
|
||||||
|
if (scrollState.canScrollBackward) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(1.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f, fill = false)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
BitwardenTextField(
|
||||||
|
label = stringResource(id = R.string.enter_uri),
|
||||||
|
isError = errorMessage != null,
|
||||||
|
hint = errorMessage ?: stringResource(
|
||||||
|
id = R.string.format_x_separate_multiple_ur_is_with_a_comma,
|
||||||
|
"http://domain.com",
|
||||||
|
),
|
||||||
|
value = uri,
|
||||||
|
onValueChange = onUriChange,
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 16.dp, end = 16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (scrollState.canScrollForward) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(1.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 8.dp, top = 24.dp, bottom = 24.dp, end = 24.dp),
|
||||||
|
) {
|
||||||
|
if (isEdit && onDeleteClick != null) {
|
||||||
|
BitwardenTextButton(
|
||||||
|
label = stringResource(id = R.string.remove),
|
||||||
|
onClick = onDeleteClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
BitwardenTextButton(
|
||||||
|
label = stringResource(id = R.string.cancel),
|
||||||
|
onClick = onCancelClick,
|
||||||
|
)
|
||||||
|
|
||||||
|
BitwardenFilledButton(
|
||||||
|
label = stringResource(id = R.string.save),
|
||||||
|
onClick = { onSaveClick(uri) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,7 +42,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider
|
import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
||||||
|
@ -65,6 +64,51 @@ fun BlockAutoFillScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
when (val dialogState = state.dialog) {
|
||||||
|
is BlockAutoFillState.DialogState.AddEdit -> {
|
||||||
|
AddEditBlockedUriDialog(
|
||||||
|
uri = dialogState.uri,
|
||||||
|
isEdit = dialogState.originalUri != null,
|
||||||
|
errorMessage = dialogState.errorMessage?.invoke(),
|
||||||
|
onUriChange = remember(viewModel) {
|
||||||
|
{
|
||||||
|
viewModel.trySendAction(BlockAutoFillAction.UriTextChange(uri = it))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(BlockAutoFillAction.DismissDialog) }
|
||||||
|
},
|
||||||
|
onDeleteClick = if (dialogState.isEdit) {
|
||||||
|
remember(viewModel, dialogState) {
|
||||||
|
{
|
||||||
|
dialogState.originalUri?.let { originalUri ->
|
||||||
|
viewModel.trySendAction(
|
||||||
|
BlockAutoFillAction.RemoveUriClick(originalUri),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onCancelClick = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(BlockAutoFillAction.DismissDialog) }
|
||||||
|
},
|
||||||
|
onSaveClick = remember(viewModel) {
|
||||||
|
{
|
||||||
|
viewModel.trySendAction(
|
||||||
|
BlockAutoFillAction.SaveUri(
|
||||||
|
newUri = it,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
BitwardenScaffold(
|
BitwardenScaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
@ -89,7 +133,9 @@ fun BlockAutoFillScreen(
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
onClick = {},
|
onClick = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(BlockAutoFillAction.AddUriClick) }
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_plus),
|
painter = painterResource(id = R.drawable.ic_plus),
|
||||||
|
@ -126,10 +172,16 @@ fun BlockAutoFillScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items(viewState.blockedUris) { uri ->
|
items(viewState.blockedUris, key = { it }) { uri ->
|
||||||
BlockAutoFillListItem(
|
BlockAutoFillListItem(
|
||||||
label = uri,
|
label = uri,
|
||||||
onClick = {},
|
onClick = remember(viewModel) {
|
||||||
|
{
|
||||||
|
viewModel.trySendAction(
|
||||||
|
BlockAutoFillAction.EditUriClick(uri),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
|
@ -140,7 +192,9 @@ fun BlockAutoFillScreen(
|
||||||
is BlockAutoFillState.ViewState.Empty -> {
|
is BlockAutoFillState.ViewState.Empty -> {
|
||||||
item {
|
item {
|
||||||
BlockAutoFillNoItems(
|
BlockAutoFillNoItems(
|
||||||
addItemClickAction = {},
|
addItemClickAction = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(BlockAutoFillAction.AddUriClick) }
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
|
@file:Suppress("TooManyFunctions")
|
||||||
|
|
||||||
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill
|
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||||
|
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill.util.validateUri
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
@ -20,7 +24,10 @@ class BlockAutoFillViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : BaseViewModel<BlockAutoFillState, BlockAutoFillEvent, BlockAutoFillAction>(
|
) : BaseViewModel<BlockAutoFillState, BlockAutoFillEvent, BlockAutoFillAction>(
|
||||||
initialState = savedStateHandle[KEY_STATE]
|
initialState = savedStateHandle[KEY_STATE]
|
||||||
?: BlockAutoFillState(viewState = BlockAutoFillState.ViewState.Empty),
|
?: BlockAutoFillState(
|
||||||
|
dialog = null,
|
||||||
|
viewState = BlockAutoFillState.ViewState.Empty,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
updateContentWithUris(
|
updateContentWithUris(
|
||||||
|
@ -45,9 +52,89 @@ class BlockAutoFillViewModel @Inject constructor(
|
||||||
override fun handleAction(action: BlockAutoFillAction) {
|
override fun handleAction(action: BlockAutoFillAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
BlockAutoFillAction.BackClick -> handleCloseClick()
|
BlockAutoFillAction.BackClick -> handleCloseClick()
|
||||||
|
BlockAutoFillAction.AddUriClick -> handleAddUriClick()
|
||||||
|
is BlockAutoFillAction.UriTextChange -> handleUriTextChange(action)
|
||||||
|
BlockAutoFillAction.DismissDialog -> handleDismissDialog()
|
||||||
|
is BlockAutoFillAction.EditUriClick -> handleEditUriClick(action)
|
||||||
|
is BlockAutoFillAction.RemoveUriClick -> handleRemoveUriClick(action)
|
||||||
|
is BlockAutoFillAction.SaveUri -> handleSaveUri(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleAddUriClick() {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(uri = ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUriTextChange(action: BlockAutoFillAction.UriTextChange) {
|
||||||
|
mutableStateFlow.update { currentState ->
|
||||||
|
val currentDialog =
|
||||||
|
currentState.dialog as? BlockAutoFillState.DialogState.AddEdit
|
||||||
|
currentState.copy(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(
|
||||||
|
uri = action.uri,
|
||||||
|
originalUri = currentDialog?.originalUri,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleEditUriClick(action: BlockAutoFillAction.EditUriClick) {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(
|
||||||
|
uri = action.uri,
|
||||||
|
originalUri = action.uri,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDismissDialog() {
|
||||||
|
mutableStateFlow.update { it.copy(dialog = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSaveUri(action: BlockAutoFillAction.SaveUri) {
|
||||||
|
val errorText = action.newUri.validateUri(settingsRepository.blockedAutofillUris)
|
||||||
|
|
||||||
|
if (errorText != null) {
|
||||||
|
mutableStateFlow.update { currentState ->
|
||||||
|
currentState.copy(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(
|
||||||
|
uri = action.newUri,
|
||||||
|
errorMessage = errorText,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentUris = settingsRepository.blockedAutofillUris.toMutableList()
|
||||||
|
|
||||||
|
val uriIndex = currentUris.indexOf(action.newUri)
|
||||||
|
if (uriIndex != -1) {
|
||||||
|
currentUris[uriIndex] = action.newUri
|
||||||
|
} else {
|
||||||
|
currentUris.add(action.newUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsRepository.blockedAutofillUris = currentUris
|
||||||
|
updateContentWithUris(currentUris)
|
||||||
|
mutableStateFlow.update { it.copy(dialog = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRemoveUriClick(action: BlockAutoFillAction.RemoveUriClick) {
|
||||||
|
val currentUris = settingsRepository.blockedAutofillUris.toMutableList()
|
||||||
|
currentUris.remove(action.uri)
|
||||||
|
|
||||||
|
settingsRepository.blockedAutofillUris = currentUris
|
||||||
|
updateContentWithUris(currentUris)
|
||||||
|
mutableStateFlow.update { it.copy(dialog = null) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleCloseClick() {
|
private fun handleCloseClick() {
|
||||||
sendEvent(
|
sendEvent(
|
||||||
event = BlockAutoFillEvent.NavigateBack,
|
event = BlockAutoFillEvent.NavigateBack,
|
||||||
|
@ -62,9 +149,28 @@ class BlockAutoFillViewModel @Inject constructor(
|
||||||
*/
|
*/
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class BlockAutoFillState(
|
data class BlockAutoFillState(
|
||||||
|
val dialog: DialogState? = null,
|
||||||
val viewState: ViewState,
|
val viewState: ViewState,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Representation of the dialog to display on BlockAutoFillScreen.
|
||||||
|
*/
|
||||||
|
sealed class DialogState : Parcelable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows the user to confirm adding or editing URI.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class AddEdit(
|
||||||
|
val uri: String,
|
||||||
|
val originalUri: String? = null,
|
||||||
|
val errorMessage: Text? = null,
|
||||||
|
) : DialogState() {
|
||||||
|
val isEdit: Boolean get() = originalUri != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the specific view states for the [BlockAutoFillScreen].
|
* Represents the specific view states for the [BlockAutoFillScreen].
|
||||||
*/
|
*/
|
||||||
|
@ -106,8 +212,38 @@ sealed class BlockAutoFillEvent {
|
||||||
*/
|
*/
|
||||||
sealed class BlockAutoFillAction {
|
sealed class BlockAutoFillAction {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User clicked BlockAutoFillListItem.
|
||||||
|
*/
|
||||||
|
data class EditUriClick(val uri: String) : BlockAutoFillAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User clicked Add uri.
|
||||||
|
*/
|
||||||
|
data object AddUriClick : BlockAutoFillAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User updated uri text.
|
||||||
|
*/
|
||||||
|
data class UriTextChange(val uri: String) : BlockAutoFillAction()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User clicked close.
|
* User clicked close.
|
||||||
*/
|
*/
|
||||||
data object BackClick : BlockAutoFillAction()
|
data object BackClick : BlockAutoFillAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User click to save or edit a URI.
|
||||||
|
*/
|
||||||
|
data class SaveUri(val newUri: String) : BlockAutoFillAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User click to remove URI.
|
||||||
|
*/
|
||||||
|
data class RemoveUriClick(val uri: String) : BlockAutoFillAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User dismissed the currently displayed dialog.
|
||||||
|
*/
|
||||||
|
data object DismissDialog : BlockAutoFillAction()
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill.util
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.R
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the URI based on specific criteria.
|
||||||
|
*
|
||||||
|
* The validation checks include:
|
||||||
|
* - The URI should start with "https://", "http://", or "androidapp://".
|
||||||
|
* - The URI should not end immediately after the scheme part.
|
||||||
|
* - The URI should not be a duplicate of any existing URIs in the provided list.
|
||||||
|
* - The URI should match a specific valid pattern
|
||||||
|
*
|
||||||
|
* This function will return the error message or null if there is no error.
|
||||||
|
*/
|
||||||
|
@Suppress("ReturnCount")
|
||||||
|
fun String.validateUri(existingUris: List<String>): Text? {
|
||||||
|
|
||||||
|
// Check if URI starts with allowed schemes.
|
||||||
|
if (
|
||||||
|
!startsWith("https://") &&
|
||||||
|
!startsWith("http://") &&
|
||||||
|
!startsWith("androidapp://")
|
||||||
|
) {
|
||||||
|
return R.string.invalid_format_use_https_http_or_android_app.asText()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for specific invalid patterns.
|
||||||
|
if (!isValidPattern()) {
|
||||||
|
return R.string.invalid_uri.asText()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicates.
|
||||||
|
if (this in existingUris) {
|
||||||
|
return R.string.the_urix_is_already_blocked.asText(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return null to indicate no errors.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the string matches a specific URI pattern.
|
||||||
|
*/
|
||||||
|
fun String.isValidPattern(): Boolean {
|
||||||
|
val pattern = "^(https?|androidapp)://([A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*)(/.*)?$".toRegex()
|
||||||
|
return matches(pattern)
|
||||||
|
}
|
|
@ -1,11 +1,16 @@
|
||||||
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill
|
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assert
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.hasAnyAncestor
|
||||||
|
import androidx.compose.ui.test.isDialog
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
|
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
|
@ -70,8 +75,164 @@ class BlockAutoFillScreenTest : BaseComposeTest() {
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty state should display 'New blocked URI' button`() {
|
||||||
|
mutableStateFlow.value = BlockAutoFillState(
|
||||||
|
viewState = BlockAutoFillState.ViewState.Empty,
|
||||||
|
dialog = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("New blocked URI")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on add URI button click should send AddUriClick`() {
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("New blocked URI")
|
||||||
|
.performClick()
|
||||||
|
verify { viewModel.trySendAction(BlockAutoFillAction.AddUriClick) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on FAB button click should send AddUriClick`() {
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithContentDescription("Add item")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
verify { viewModel.trySendAction(BlockAutoFillAction.AddUriClick) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on URI item click should send EditUriClick`() {
|
||||||
|
mutableStateFlow.value = BlockAutoFillState(
|
||||||
|
viewState = BlockAutoFillState.ViewState.Content(listOf("uri1")),
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("uri1").performClick()
|
||||||
|
|
||||||
|
verify { viewModel.trySendAction(BlockAutoFillAction.EditUriClick(uri = "uri1")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should show add URI dialog according to state`() {
|
||||||
|
composeTestRule.assertNoDialogExists()
|
||||||
|
|
||||||
|
mutableStateFlow.value = BlockAutoFillState(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(
|
||||||
|
uri = "",
|
||||||
|
originalUri = null,
|
||||||
|
errorMessage = null,
|
||||||
|
),
|
||||||
|
viewState = BlockAutoFillState.ViewState.Content(listOf("uri1")),
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("New URI")
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking a uri from the list should send EditUriClick action`() {
|
||||||
|
val testUri = "http://test.com"
|
||||||
|
|
||||||
|
composeTestRule.assertNoDialogExists()
|
||||||
|
|
||||||
|
mutableStateFlow.value = BlockAutoFillState(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(
|
||||||
|
uri = testUri,
|
||||||
|
originalUri = testUri,
|
||||||
|
errorMessage = null,
|
||||||
|
),
|
||||||
|
viewState = BlockAutoFillState.ViewState.Content(listOf("uri1")),
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("New URI")
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should display error message in dialog when there is a error in the dialog state`() {
|
||||||
|
val errorMessage = "Invalid URI"
|
||||||
|
|
||||||
|
composeTestRule.assertNoDialogExists()
|
||||||
|
|
||||||
|
mutableStateFlow.value = BlockAutoFillState(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(
|
||||||
|
uri = "invalid-uri",
|
||||||
|
originalUri = null,
|
||||||
|
errorMessage = errorMessage.asText(),
|
||||||
|
),
|
||||||
|
viewState = BlockAutoFillState.ViewState.Content(listOf("uri1")),
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(errorMessage)
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking save in dialog should send SaveUri action`() {
|
||||||
|
composeTestRule.assertNoDialogExists()
|
||||||
|
|
||||||
|
mutableStateFlow.value = BlockAutoFillState(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(
|
||||||
|
uri = "http://newuri.com",
|
||||||
|
originalUri = null,
|
||||||
|
errorMessage = null,
|
||||||
|
),
|
||||||
|
viewState = BlockAutoFillState.ViewState.Content(listOf("existingUri")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val newUri = "http://newuri.com"
|
||||||
|
composeTestRule.onNodeWithText("Save").performClick()
|
||||||
|
|
||||||
|
verify { viewModel.trySendAction(BlockAutoFillAction.SaveUri(newUri = newUri)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking cancel in dialog should send DismissDialog action`() {
|
||||||
|
composeTestRule.assertNoDialogExists()
|
||||||
|
|
||||||
|
mutableStateFlow.value = BlockAutoFillState(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(
|
||||||
|
uri = "http://uri.com",
|
||||||
|
originalUri = null,
|
||||||
|
errorMessage = null,
|
||||||
|
),
|
||||||
|
viewState = BlockAutoFillState.ViewState.Content(emptyList()),
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Cancel").performClick()
|
||||||
|
|
||||||
|
verify { viewModel.trySendAction(BlockAutoFillAction.DismissDialog) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking remove in dialog should send RemoveUriClick action`() {
|
||||||
|
composeTestRule.assertNoDialogExists()
|
||||||
|
|
||||||
|
val uriToRemove = "http://uriToRemove.com"
|
||||||
|
mutableStateFlow.value = BlockAutoFillState(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(
|
||||||
|
uri = uriToRemove,
|
||||||
|
originalUri = uriToRemove,
|
||||||
|
errorMessage = null,
|
||||||
|
),
|
||||||
|
viewState = BlockAutoFillState.ViewState.Content(listOf(uriToRemove, "otherUri")),
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Remove").performClick()
|
||||||
|
|
||||||
|
verify { viewModel.trySendAction(BlockAutoFillAction.RemoveUriClick(uri = uriToRemove)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val DEFAULT_STATE: BlockAutoFillState = BlockAutoFillState(
|
private val DEFAULT_STATE: BlockAutoFillState = BlockAutoFillState(
|
||||||
BlockAutoFillState.ViewState.Empty,
|
viewState = BlockAutoFillState.ViewState.Empty,
|
||||||
)
|
)
|
||||||
|
|
|
@ -43,6 +43,98 @@ class BlockAutoFillViewModelTest : BaseViewModelTest() {
|
||||||
assertEquals(expectedState, viewModel.stateFlow.value)
|
assertEquals(expectedState, viewModel.stateFlow.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on AddUriClick should open AddEdit dialog with empty URI`() = runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.trySendAction(BlockAutoFillAction.AddUriClick)
|
||||||
|
|
||||||
|
val expectedDialogState = BlockAutoFillState.DialogState.AddEdit(uri = "")
|
||||||
|
assertEquals(expectedDialogState, viewModel.stateFlow.value.dialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on UriTextChange should update dialog URI`() = runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
val testUri = "http://test.com"
|
||||||
|
viewModel.trySendAction(BlockAutoFillAction.UriTextChange(uri = testUri))
|
||||||
|
|
||||||
|
val expectedState = BlockAutoFillState(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(
|
||||||
|
uri = testUri,
|
||||||
|
originalUri = null,
|
||||||
|
errorMessage = null,
|
||||||
|
),
|
||||||
|
viewState = BlockAutoFillState.ViewState.Content(blockedUris = listOf("blockedUri")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedState, viewModel.stateFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on EditUriClick should open AddEdit dialog with specified URI`() = runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
val testUri = "http://edit.com"
|
||||||
|
viewModel.trySendAction(BlockAutoFillAction.EditUriClick(uri = testUri))
|
||||||
|
|
||||||
|
val expectedState = BlockAutoFillState(
|
||||||
|
dialog = BlockAutoFillState.DialogState.AddEdit(
|
||||||
|
uri = testUri,
|
||||||
|
originalUri = testUri,
|
||||||
|
errorMessage = null,
|
||||||
|
),
|
||||||
|
viewState = BlockAutoFillState.ViewState.Content(listOf("blockedUri")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedState, viewModel.stateFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on RemoveUriClick action should remove specified URI from list`() = runTest {
|
||||||
|
val blockedUris = mutableListOf("http://a.com", "http://b.com")
|
||||||
|
|
||||||
|
every { settingsRepository.blockedAutofillUris } answers { blockedUris.toList() }
|
||||||
|
every { settingsRepository.blockedAutofillUris = any() } answers {
|
||||||
|
blockedUris.clear()
|
||||||
|
blockedUris.addAll(firstArg())
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.trySendAction(BlockAutoFillAction.RemoveUriClick(uri = "http://a.com"))
|
||||||
|
|
||||||
|
val expectedState = BlockAutoFillState(
|
||||||
|
dialog = null,
|
||||||
|
viewState = BlockAutoFillState.ViewState.Content(
|
||||||
|
blockedUris = listOf("http://b.com"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedState, viewModel.stateFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on SaveUri action with valid URI should add URI to list`() = runTest {
|
||||||
|
val blockedUris = mutableListOf("http://existing.com")
|
||||||
|
|
||||||
|
every { settingsRepository.blockedAutofillUris } answers { blockedUris.toList() }
|
||||||
|
every { settingsRepository.blockedAutofillUris = any() } answers {
|
||||||
|
blockedUris.clear()
|
||||||
|
blockedUris.addAll(firstArg())
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
val testUri = "http://new.com"
|
||||||
|
viewModel.trySendAction(BlockAutoFillAction.SaveUri(newUri = testUri))
|
||||||
|
|
||||||
|
val expectedState = BlockAutoFillState(
|
||||||
|
dialog = null,
|
||||||
|
viewState = BlockAutoFillState.ViewState.Content(
|
||||||
|
blockedUris = blockedUris,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedState, viewModel.stateFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on BackClick should emit NavigateBack`() = runTest {
|
fun `on BackClick should emit NavigateBack`() = runTest {
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
|
@ -61,5 +153,5 @@ class BlockAutoFillViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private val DEFAULT_STATE: BlockAutoFillState = BlockAutoFillState(
|
private val DEFAULT_STATE: BlockAutoFillState = BlockAutoFillState(
|
||||||
BlockAutoFillState.ViewState.Empty,
|
viewState = BlockAutoFillState.ViewState.Empty,
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill.util
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class StringExtensionsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validateUri should return null for valid URIs`() {
|
||||||
|
val validUri = "https://example.com"
|
||||||
|
val existingUris = listOf("http://another.com")
|
||||||
|
|
||||||
|
val result = validUri.validateUri(existingUris)
|
||||||
|
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validateUri should return non-null for URIs with invalid scheme`() {
|
||||||
|
val invalidSchemeUri = "ftp://example.com"
|
||||||
|
val existingUris = listOf<String>()
|
||||||
|
|
||||||
|
val result = invalidSchemeUri.validateUri(existingUris)
|
||||||
|
|
||||||
|
assertNotNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validateUri should return non-null for URIs with invalid pattern`() {
|
||||||
|
val invalidPatternUri = "https://example..com"
|
||||||
|
val existingUris = listOf<String>()
|
||||||
|
|
||||||
|
val result = invalidPatternUri.validateUri(existingUris)
|
||||||
|
|
||||||
|
assertNotNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validateUri should return non-null for duplicate URIs`() {
|
||||||
|
val duplicateUri = "https://example.com"
|
||||||
|
val existingUris = listOf("https://example.com")
|
||||||
|
|
||||||
|
val result = duplicateUri.validateUri(existingUris)
|
||||||
|
|
||||||
|
assertNotNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isValidPattern should correctly validate URIs`() {
|
||||||
|
val validUris = listOf(
|
||||||
|
"https://a",
|
||||||
|
"http://a.com",
|
||||||
|
"https://subdomain.example.com",
|
||||||
|
"androidapp://com.example.app",
|
||||||
|
)
|
||||||
|
|
||||||
|
val invalidUris = listOf(
|
||||||
|
"https://a.....",
|
||||||
|
"https://a....com",
|
||||||
|
"https://.com",
|
||||||
|
"ftp://example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
validUris.forEach { uri ->
|
||||||
|
assertTrue(uri.isValidPattern(), "Expected valid URI: $uri")
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidUris.forEach { uri ->
|
||||||
|
assertFalse(uri.isValidPattern(), "Expected invalid URI: $uri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue