mirror of
https://github.com/bitwarden/android.git
synced 2024-11-27 12:00:19 +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,
|
||||
shouldAddCustomLineBreaks: Boolean = false,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
isError: Boolean = false,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
) {
|
||||
var widthPx by remember { mutableStateOf(0) }
|
||||
|
@ -109,6 +110,7 @@ fun BitwardenTextField(
|
|||
readOnly = readOnly,
|
||||
textStyle = currentTextStyle,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType),
|
||||
isError = isError,
|
||||
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 com.x8bit.bitwarden.R
|
||||
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.components.BitwardenScaffold
|
||||
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())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
|
@ -89,7 +133,9 @@ fun BlockAutoFillScreen(
|
|||
) {
|
||||
FloatingActionButton(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
onClick = {},
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(BlockAutoFillAction.AddUriClick) }
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_plus),
|
||||
|
@ -126,10 +172,16 @@ fun BlockAutoFillScreen(
|
|||
}
|
||||
}
|
||||
|
||||
items(viewState.blockedUris) { uri ->
|
||||
items(viewState.blockedUris, key = { it }) { uri ->
|
||||
BlockAutoFillListItem(
|
||||
label = uri,
|
||||
onClick = {},
|
||||
onClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
BlockAutoFillAction.EditUriClick(uri),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
|
@ -140,7 +192,9 @@ fun BlockAutoFillScreen(
|
|||
is BlockAutoFillState.ViewState.Empty -> {
|
||||
item {
|
||||
BlockAutoFillNoItems(
|
||||
addItemClickAction = {},
|
||||
addItemClickAction = remember(viewModel) {
|
||||
{ viewModel.trySendAction(BlockAutoFillAction.AddUriClick) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
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 kotlinx.coroutines.flow.update
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
@ -20,7 +24,10 @@ class BlockAutoFillViewModel @Inject constructor(
|
|||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<BlockAutoFillState, BlockAutoFillEvent, BlockAutoFillAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: BlockAutoFillState(viewState = BlockAutoFillState.ViewState.Empty),
|
||||
?: BlockAutoFillState(
|
||||
dialog = null,
|
||||
viewState = BlockAutoFillState.ViewState.Empty,
|
||||
),
|
||||
) {
|
||||
init {
|
||||
updateContentWithUris(
|
||||
|
@ -45,9 +52,89 @@ class BlockAutoFillViewModel @Inject constructor(
|
|||
override fun handleAction(action: BlockAutoFillAction) {
|
||||
when (action) {
|
||||
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() {
|
||||
sendEvent(
|
||||
event = BlockAutoFillEvent.NavigateBack,
|
||||
|
@ -62,9 +149,28 @@ class BlockAutoFillViewModel @Inject constructor(
|
|||
*/
|
||||
@Parcelize
|
||||
data class BlockAutoFillState(
|
||||
val dialog: DialogState? = null,
|
||||
val viewState: ViewState,
|
||||
) : 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].
|
||||
*/
|
||||
|
@ -106,8 +212,38 @@ sealed class BlockAutoFillEvent {
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
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
|
||||
|
||||
import androidx.compose.ui.test.assert
|
||||
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.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
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.util.assertNoDialogExists
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
|
@ -70,8 +75,164 @@ class BlockAutoFillScreenTest : BaseComposeTest() {
|
|||
.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(
|
||||
BlockAutoFillState.ViewState.Empty,
|
||||
viewState = BlockAutoFillState.ViewState.Empty,
|
||||
)
|
||||
|
|
|
@ -43,6 +43,98 @@ class BlockAutoFillViewModelTest : BaseViewModelTest() {
|
|||
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
|
||||
fun `on BackClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
@ -61,5 +153,5 @@ class BlockAutoFillViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
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