BIT-1147, BIT-1487: Implementing blocking auto-fill for specific URIs (#710)

This commit is contained in:
Joshua Queen 2024-01-22 14:33:51 -05:00 committed by Álison Fernandes
parent 83b77730f5
commit 79bc483491
8 changed files with 711 additions and 8 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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