From 79bc483491455e86073e1b0a1c7a501387b627f2 Mon Sep 17 00:00:00 2001 From: Joshua Queen <139182194+joshua-livefront@users.noreply.github.com> Date: Mon, 22 Jan 2024 14:33:51 -0500 Subject: [PATCH] BIT-1147, BIT-1487: Implementing blocking auto-fill for specific URIs (#710) --- .../platform/components/BitwardenTextField.kt | 2 + .../blockautofill/AddEditBlockedUriDialog.kt | 133 ++++++++++++++ .../blockautofill/BlockAutoFillScreen.kt | 64 ++++++- .../blockautofill/BlockAutoFillViewModel.kt | 138 ++++++++++++++- .../blockautofill/util/StringExtensions.kt | 50 ++++++ .../blockautofill/BlockAutoFillScreenTest.kt | 163 +++++++++++++++++- .../BlockAutoFillViewModelTest.kt | 94 +++++++++- .../util/StringExtensionsTest.kt | 75 ++++++++ 8 files changed, 711 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/AddEditBlockedUriDialog.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/util/StringExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/util/StringExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt index b3b57399b7..1ea77bcdf2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt @@ -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, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/AddEditBlockedUriDialog.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/AddEditBlockedUriDialog.kt new file mode 100644 index 0000000000..e9da187387 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/AddEditBlockedUriDialog.kt @@ -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) }, + ) + } + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreen.kt index 3f5c27690f..81ba481f72 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreen.kt @@ -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(), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModel.kt index 3c2d602bca..52a385cc2f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModel.kt @@ -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( 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() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/util/StringExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/util/StringExtensions.kt new file mode 100644 index 0000000000..7ac40c798e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/util/StringExtensions.kt @@ -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): 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) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreenTest.kt index ec48d0b379..fc6797caba 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreenTest.kt @@ -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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModelTest.kt index 6bb3065931..ee3f810605 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModelTest.kt @@ -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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/util/StringExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/util/StringExtensionsTest.kt new file mode 100644 index 0000000000..3d692dc5e9 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/util/StringExtensionsTest.kt @@ -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() + + 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() + + 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") + } + } +}