[PM-23723] URI Matching detection layout updates on advanced options (#5574)

This commit is contained in:
aj-rosado 2025-08-13 17:09:29 +01:00 committed by GitHub
parent 3ed63ef5eb
commit a688693f43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 875 additions and 45 deletions

View File

@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -26,9 +27,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.core.util.persistentListOfNotNull
import com.bitwarden.ui.platform.base.util.EventsEffect import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.annotatedStringResource
import com.bitwarden.ui.platform.base.util.spanStyleOf
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.badge.NotificationBadge import com.bitwarden.ui.platform.components.badge.NotificationBadge
@ -40,10 +45,12 @@ import com.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.bitwarden.ui.platform.components.util.rememberVectorPainter import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.dropdown.model.MultiSelectOption
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow import com.x8bit.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow
import com.x8bit.bitwarden.ui.platform.components.row.BitwardenTextRow import com.x8bit.bitwarden.ui.platform.components.row.BitwardenTextRow
@ -52,6 +59,7 @@ import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.browser.BrowserAutofillSettingsCard import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.browser.BrowserAutofillSettingsCard
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.handlers.AutoFillHandlers import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.handlers.AutoFillHandlers
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.util.displayLabel import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.util.displayLabel
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.util.isAdvancedMatching
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -109,6 +117,10 @@ fun AutoFillScreen(
AutoFillEvent.NavigateToPrivilegedAppsListScreen -> { AutoFillEvent.NavigateToPrivilegedAppsListScreen -> {
onNavigateToPrivilegedAppsList() onNavigateToPrivilegedAppsList()
} }
AutoFillEvent.NavigateToLearnMore -> {
intentManager.launchUri("https://bitwarden.com/help/uri-match-detection/".toUri())
}
} }
} }
@ -303,6 +315,7 @@ private fun AutoFillScreenContent(
DefaultUriMatchTypeRow( DefaultUriMatchTypeRow(
selectedUriMatchType = state.defaultUriMatchType, selectedUriMatchType = state.defaultUriMatchType,
onUriMatchTypeSelect = autoFillHandlers.onDefaultUriMatchTypeSelect, onUriMatchTypeSelect = autoFillHandlers.onDefaultUriMatchTypeSelect,
onNavigateToLearnMore = autoFillHandlers.onLearnMoreClick,
modifier = Modifier modifier = Modifier
.testTag("DefaultUriMatchDetectionChooser") .testTag("DefaultUriMatchDetectionChooser")
.standardHorizontalMargin() .standardHorizontalMargin()
@ -387,24 +400,205 @@ private fun AccessibilityAutofillSwitch(
private fun DefaultUriMatchTypeRow( private fun DefaultUriMatchTypeRow(
selectedUriMatchType: UriMatchType, selectedUriMatchType: UriMatchType,
onUriMatchTypeSelect: (UriMatchType) -> Unit, onUriMatchTypeSelect: (UriMatchType) -> Unit,
onNavigateToLearnMore: () -> Unit,
modifier: Modifier = Modifier,
) {
var showAdvancedDialog by rememberSaveable { mutableStateOf(false) }
var optionPendingConfirmation by rememberSaveable { mutableStateOf<UriMatchType?>(null) }
var shouldShowLearnMoreMatchDetectionDialog by rememberSaveable { mutableStateOf(false) }
UriMatchSelectionButton(
selectedUriMatchType = selectedUriMatchType,
onOptionSelected = { selectedOption ->
if (selectedOption.isAdvancedMatching()) {
optionPendingConfirmation = selectedOption
showAdvancedDialog = true
} else {
onUriMatchTypeSelect(selectedOption)
optionPendingConfirmation = null
showAdvancedDialog = false
}
},
modifier = modifier,
)
val currentOptionToConfirm = optionPendingConfirmation
if (showAdvancedDialog && currentOptionToConfirm != null) {
AdvancedMatchDetectionWarningDialog(
pendingOption = currentOptionToConfirm,
onDialogConfirm = {
onUriMatchTypeSelect(currentOptionToConfirm)
showAdvancedDialog = false
optionPendingConfirmation = null
shouldShowLearnMoreMatchDetectionDialog = true
},
onDialogDismiss = {
showAdvancedDialog = false
optionPendingConfirmation = null
},
)
}
if (shouldShowLearnMoreMatchDetectionDialog) {
MatchDetectionLearnMoreDialog(
uriMatchType = selectedUriMatchType,
onDialogConfirm = {
onNavigateToLearnMore()
shouldShowLearnMoreMatchDetectionDialog = false
},
onDialogDismiss = {
shouldShowLearnMoreMatchDetectionDialog = false
},
)
}
}
@Composable
private fun AdvancedMatchDetectionWarningDialog(
pendingOption: UriMatchType,
onDialogConfirm: () -> Unit,
onDialogDismiss: () -> Unit,
) {
val descriptionStringResId =
when (pendingOption) {
UriMatchType.STARTS_WITH -> {
BitwardenString.advanced_option_with_increased_risk_of_exposing_credentials
}
UriMatchType.REGULAR_EXPRESSION -> {
BitwardenString.advanced_option_increased_risk_exposing_credentials_used_incorrectly
}
UriMatchType.HOST,
UriMatchType.DOMAIN,
UriMatchType.EXACT,
UriMatchType.NEVER,
-> {
error("Unexpected value $pendingOption on AdvancedMatchDetectionWarningDialog")
}
}
BitwardenTwoButtonDialog(
title = stringResource(
id = BitwardenString.are_you_sure_you_want_to_use,
formatArgs = arrayOf(
pendingOption.displayLabel(),
),
),
message = stringResource(
id = descriptionStringResId,
),
confirmButtonText = stringResource(id = BitwardenString.yes),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = onDialogConfirm,
onDismissClick = onDialogDismiss,
onDismissRequest = onDialogDismiss,
)
}
@Composable
private fun UriMatchSelectionButton(
selectedUriMatchType: UriMatchType,
onOptionSelected: (UriMatchType) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
resources: Resources = LocalContext.current.resources, resources: Resources = LocalContext.current.resources,
) { ) {
val advancedOptions = UriMatchType.entries.filter { it.isAdvancedMatching() }
val options = persistentListOfNotNull(
*UriMatchType
.entries
.filter { !it.isAdvancedMatching() }
.map { MultiSelectOption.Row(it.displayLabel()) }
.toTypedArray(),
if (advancedOptions.isNotEmpty()) {
MultiSelectOption.Header(
title = stringResource(id = BitwardenString.advanced_options),
testTag = "AdvancedOptionsSection",
)
} else {
null
},
*advancedOptions
.map { MultiSelectOption.Row(it.displayLabel()) }
.toTypedArray(),
)
BitwardenMultiSelectButton( BitwardenMultiSelectButton(
label = stringResource(id = BitwardenString.default_uri_match_detection), label = stringResource(id = BitwardenString.default_uri_match_detection),
options = UriMatchType.entries.map { it.displayLabel() }.toImmutableList(), options = options,
selectedOption = selectedUriMatchType.displayLabel(), selectedOption = MultiSelectOption.Row(selectedUriMatchType.displayLabel()),
onOptionSelected = { selectedOption -> onOptionSelected = { row ->
onUriMatchTypeSelect( val newSelectedType = UriMatchType
UriMatchType .entries
.entries .first { it.displayLabel(resources) == row.title }
.first { it.displayLabel.toString(resources) == selectedOption }, onOptionSelected(newSelectedType)
)
}, },
supportingText = stringResource(
id = BitwardenString.default_uri_match_detection_description,
),
cardStyle = CardStyle.Full, cardStyle = CardStyle.Full,
supportingContent = { SupportingTextForMatchDetection(selectedUriMatchType) },
modifier = modifier, modifier = modifier,
) )
} }
@Composable
private fun MatchDetectionLearnMoreDialog(
uriMatchType: UriMatchType,
onDialogConfirm: () -> Unit,
onDialogDismiss: () -> Unit,
) {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.keep_your_credential_secure),
message = stringResource(
id = BitwardenString.learn_more_about_how_to_keep_credentirals_secure,
formatArgs = arrayOf(uriMatchType.displayLabel()),
),
confirmButtonText = stringResource(id = BitwardenString.learn_more),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = onDialogConfirm,
onDismissClick = onDialogDismiss,
onDismissRequest = onDialogDismiss,
)
}
@Composable
private fun SupportingTextForMatchDetection(
uriMatchType: UriMatchType,
) {
val stringResId =
when (uriMatchType) {
UriMatchType.STARTS_WITH -> {
BitwardenString.default_uri_match_detection_description_advanced_options
}
UriMatchType.REGULAR_EXPRESSION -> {
BitwardenString.default_uri_match_detection_description_advanced_options_incorrectly
}
UriMatchType.HOST,
UriMatchType.DOMAIN,
UriMatchType.EXACT,
UriMatchType.NEVER,
-> {
BitwardenString.default_uri_match_detection_description
}
}
val supportingAnnotatedString =
annotatedStringResource(
id = stringResId,
emphasisHighlightStyle = spanStyleOf(
textStyle = BitwardenTheme.typography.bodyMediumEmphasis,
color = BitwardenTheme.colorScheme.text.secondary,
),
style = spanStyleOf(
textStyle = BitwardenTheme.typography.bodySmall,
color = BitwardenTheme.colorScheme.text.secondary,
),
)
Text(
text = supportingAnnotatedString,
style = BitwardenTheme.typography.bodySmall,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.fillMaxWidth(),
)
}

View File

@ -131,12 +131,17 @@ class AutoFillViewModel @Inject constructor(
is AutoFillAction.BrowserAutofillSelected -> handleBrowserAutofillSelected(action) is AutoFillAction.BrowserAutofillSelected -> handleBrowserAutofillSelected(action)
AutoFillAction.AboutPrivilegedAppsClick -> handleAboutPrivilegedAppsClick() AutoFillAction.AboutPrivilegedAppsClick -> handleAboutPrivilegedAppsClick()
AutoFillAction.PrivilegedAppsClick -> handlePrivilegedAppsClick() AutoFillAction.PrivilegedAppsClick -> handlePrivilegedAppsClick()
AutoFillAction.LearnMoreClick -> handleLearnMoreClick()
} }
private fun handlePrivilegedAppsClick() { private fun handlePrivilegedAppsClick() {
sendEvent(AutoFillEvent.NavigateToPrivilegedAppsListScreen) sendEvent(AutoFillEvent.NavigateToPrivilegedAppsListScreen)
} }
private fun handleLearnMoreClick() {
sendEvent(AutoFillEvent.NavigateToLearnMore)
}
private fun handleInternalAction(action: AutoFillAction.Internal) { private fun handleInternalAction(action: AutoFillAction.Internal) {
when (action) { when (action) {
is AutoFillAction.Internal.AccessibilityEnabledUpdateReceive -> { is AutoFillAction.Internal.AccessibilityEnabledUpdateReceive -> {
@ -390,6 +395,11 @@ sealed class AutoFillEvent {
* Navigate to the privileged apps list screen. * Navigate to the privileged apps list screen.
*/ */
data object NavigateToPrivilegedAppsListScreen : AutoFillEvent() data object NavigateToPrivilegedAppsListScreen : AutoFillEvent()
/**
* Navigate to the learn more.
*/
data object NavigateToLearnMore : AutoFillEvent()
} }
/** /**
@ -476,6 +486,11 @@ sealed class AutoFillAction {
*/ */
data object PrivilegedAppsClick : AutoFillAction() data object PrivilegedAppsClick : AutoFillAction()
/**
* User has clicked the learn more help link.
*/
data object LearnMoreClick : AutoFillAction()
/** /**
* Internal actions. * Internal actions.
*/ */

View File

@ -25,6 +25,7 @@ class AutoFillHandlers(
val onAskToAddLoginClick: (isEnabled: Boolean) -> Unit, val onAskToAddLoginClick: (isEnabled: Boolean) -> Unit,
val onDefaultUriMatchTypeSelect: (defaultUriMatchType: UriMatchType) -> Unit, val onDefaultUriMatchTypeSelect: (defaultUriMatchType: UriMatchType) -> Unit,
val onBlockAutoFillClick: () -> Unit, val onBlockAutoFillClick: () -> Unit,
val onLearnMoreClick: () -> Unit,
) { ) {
@Suppress("UndocumentedPublicClass") @Suppress("UndocumentedPublicClass")
companion object { companion object {
@ -86,6 +87,7 @@ class AutoFillHandlers(
) )
}, },
onBlockAutoFillClick = { viewModel.trySendAction(AutoFillAction.BlockAutoFillClick) }, onBlockAutoFillClick = { viewModel.trySendAction(AutoFillAction.BlockAutoFillClick) },
onLearnMoreClick = { viewModel.trySendAction(AutoFillAction.LearnMoreClick) },
) )
} }
} }

View File

@ -31,3 +31,15 @@ fun UriMatchType.toSdkUriMatchType(): com.bitwarden.vault.UriMatchType =
UriMatchType.REGULAR_EXPRESSION -> com.bitwarden.vault.UriMatchType.REGULAR_EXPRESSION UriMatchType.REGULAR_EXPRESSION -> com.bitwarden.vault.UriMatchType.REGULAR_EXPRESSION
UriMatchType.STARTS_WITH -> com.bitwarden.vault.UriMatchType.STARTS_WITH UriMatchType.STARTS_WITH -> com.bitwarden.vault.UriMatchType.STARTS_WITH
} }
/**
* Checks if the [UriMatchType] is considered an advanced matching strategy.
*/
fun UriMatchType.isAdvancedMatching(): Boolean =
when (this) {
UriMatchType.REGULAR_EXPRESSION,
UriMatchType.STARTS_WITH,
-> true
else -> false
}

View File

@ -24,6 +24,7 @@ import com.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkScope import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkScope
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
@ -43,6 +44,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditSshKeyT
fun CoachMarkScope<AddEditItemCoachMark>.VaultAddEditContent( fun CoachMarkScope<AddEditItemCoachMark>.VaultAddEditContent(
state: VaultAddEditState.ViewState.Content, state: VaultAddEditState.ViewState.Content,
isAddItemMode: Boolean, isAddItemMode: Boolean,
defaultUriMatchType: UriMatchType,
commonTypeHandlers: VaultAddEditCommonHandlers, commonTypeHandlers: VaultAddEditCommonHandlers,
loginItemTypeHandlers: VaultAddEditLoginTypeHandlers, loginItemTypeHandlers: VaultAddEditLoginTypeHandlers,
identityItemTypeHandlers: VaultAddEditIdentityTypeHandlers, identityItemTypeHandlers: VaultAddEditIdentityTypeHandlers,
@ -212,6 +214,7 @@ fun CoachMarkScope<AddEditItemCoachMark>.VaultAddEditContent(
onNextCoachMark = onNextCoachMark, onNextCoachMark = onNextCoachMark,
onCoachMarkTourComplete = onCoachMarkTourComplete, onCoachMarkTourComplete = onCoachMarkTourComplete,
onCoachMarkDismissed = onCoachMarkDismissed, onCoachMarkDismissed = onCoachMarkDismissed,
defaultUriMatchType = defaultUriMatchType,
) )
} }

View File

@ -29,6 +29,7 @@ import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkActionText import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkActionText
import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkScope import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkScope
import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
@ -54,6 +55,7 @@ fun LazyListScope.vaultAddEditLoginItems(
onPreviousCoachMark: () -> Unit, onPreviousCoachMark: () -> Unit,
onCoachMarkTourComplete: () -> Unit, onCoachMarkTourComplete: () -> Unit,
onCoachMarkDismissed: () -> Unit, onCoachMarkDismissed: () -> Unit,
defaultUriMatchType: UriMatchType,
) = coachMarkScope.run { ) = coachMarkScope.run {
item { item {
Spacer(modifier = Modifier.height(height = 16.dp)) Spacer(modifier = Modifier.height(height = 16.dp))
@ -192,7 +194,9 @@ fun LazyListScope.vaultAddEditLoginItems(
uriItem = uriItem, uriItem = uriItem,
onUriValueChange = loginItemTypeHandlers.onUriValueChange, onUriValueChange = loginItemTypeHandlers.onUriValueChange,
onUriItemRemoved = loginItemTypeHandlers.onRemoveUriClick, onUriItemRemoved = loginItemTypeHandlers.onRemoveUriClick,
onLearnMoreClick = loginItemTypeHandlers.onLearnMoreClick,
cardStyle = cardStyle, cardStyle = cardStyle,
defaultUriMatchType = defaultUriMatchType,
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
) )

View File

@ -194,6 +194,10 @@ fun VaultAddEditScreen(
} }
is VaultAddEditEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) is VaultAddEditEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data)
VaultAddEditEvent.NavigateToLearnMore -> {
intentManager.launchUri("https://bitwarden.com/help/uri-match-detection/".toUri())
}
} }
} }
@ -412,6 +416,7 @@ fun VaultAddEditScreen(
VaultAddEditContent( VaultAddEditContent(
state = viewState, state = viewState,
isAddItemMode = state.isAddItemMode, isAddItemMode = state.isAddItemMode,
defaultUriMatchType = state.defaultUriMatchType,
loginItemTypeHandlers = loginItemTypeHandlers, loginItemTypeHandlers = loginItemTypeHandlers,
commonTypeHandlers = commonTypeHandlers, commonTypeHandlers = commonTypeHandlers,
permissionsManager = permissionsManager, permissionsManager = permissionsManager,

View File

@ -1,25 +1,38 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit package com.x8bit.bitwarden.ui.vault.feature.addedit
import android.content.res.Resources
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.bitwarden.core.util.persistentListOfNotNull
import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenBasicDialogRow import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenBasicDialogRow
import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenSelectionRow import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectDialogContent
import com.x8bit.bitwarden.ui.platform.components.dropdown.model.MultiSelectOption
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.util.displayLabel
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriMatchDisplayType import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriMatchDisplayType
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.displayLabel
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.isAdvancedMatching
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toDisplayMatchType import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toDisplayMatchType
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toUriMatchType import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toUriMatchType
import kotlinx.collections.immutable.ImmutableList
/** /**
* The URI item displayed to the user. * The URI item displayed to the user.
@ -31,10 +44,20 @@ fun VaultAddEditUriItem(
onUriItemRemoved: (UriItem) -> Unit, onUriItemRemoved: (UriItem) -> Unit,
onUriValueChange: (UriItem) -> Unit, onUriValueChange: (UriItem) -> Unit,
cardStyle: CardStyle, cardStyle: CardStyle,
defaultUriMatchType: UriMatchType,
onLearnMoreClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
resources: Resources = LocalContext.current.resources,
intentManager: IntentManager = LocalIntentManager.current,
) { ) {
var shouldShowOptionsDialog by rememberSaveable { mutableStateOf(false) } var shouldShowOptionsDialog by rememberSaveable { mutableStateOf(false) }
var shouldShowMatchDialog by rememberSaveable { mutableStateOf(false) } var shouldShowMatchDialog by rememberSaveable { mutableStateOf(false) }
var shouldShowAdvancedMatchDialog by rememberSaveable { mutableStateOf(false) }
var optionPendingConfirmation by rememberSaveable { mutableStateOf<UriMatchDisplayType?>(null) }
var shouldShowLearnMoreMatchDetectionDialog by rememberSaveable { mutableStateOf(false) }
val defaultUriOption = remember(defaultUriMatchType) {
defaultUriMatchType.displayLabel.toString(resources)
}
BitwardenTextField( BitwardenTextField(
label = stringResource(id = BitwardenString.website_uri), label = stringResource(id = BitwardenString.website_uri),
@ -76,26 +99,173 @@ fun VaultAddEditUriItem(
} }
if (shouldShowMatchDialog) { if (shouldShowMatchDialog) {
val selectedString = uriItem.match.toDisplayMatchType().text.invoke()
BitwardenSelectionDialog( BitwardenSelectionDialog(
title = stringResource(id = BitwardenString.uri_match_detection), title = stringResource(id = BitwardenString.uri_match_detection),
onDismissRequest = { shouldShowMatchDialog = false }, onDismissRequest = { shouldShowMatchDialog = false },
) { ) {
UriMatchDisplayType BitwardenMultiSelectDialogContent(
.entries options = uriMatchingOptions(defaultUriOption = defaultUriOption),
.forEach { matchType -> selectedOption = MultiSelectOption.Row(
BitwardenSelectionRow( uriItem
text = matchType.text, .match
isSelected = matchType.text.invoke() == selectedString, .toDisplayMatchType()
onClick = { .displayLabel(defaultUriOption = defaultUriOption)
shouldShowMatchDialog = false .invoke(),
onUriValueChange( ),
uriItem.copy(match = matchType.toUriMatchType()), onOptionSelected = { selectedOption ->
) shouldShowMatchDialog = false
},
) val newSelectedType =
} UriMatchDisplayType
.entries
.first {
it.displayLabel(defaultUriOption)
.invoke(resources) == selectedOption.title
}
if (newSelectedType.isAdvancedMatching()) {
optionPendingConfirmation = newSelectedType
shouldShowAdvancedMatchDialog = true
} else {
onUriValueChange(
uriItem.copy(match = newSelectedType.toUriMatchType()),
)
optionPendingConfirmation = null
}
},
)
} }
} }
val currentOptionToConfirm = optionPendingConfirmation
if (shouldShowAdvancedMatchDialog && currentOptionToConfirm != null) {
AdvancedMatchDetectionWarning(
pendingOption = currentOptionToConfirm,
defaultUriMatchType = defaultUriMatchType,
onDialogConfirm = {
onUriValueChange(
uriItem.copy(match = currentOptionToConfirm.toUriMatchType()),
)
shouldShowAdvancedMatchDialog = false
optionPendingConfirmation = null
shouldShowLearnMoreMatchDetectionDialog = true
},
onDialogDismiss = {
shouldShowAdvancedMatchDialog = false
optionPendingConfirmation = null
},
)
}
if (shouldShowLearnMoreMatchDetectionDialog) {
LearnMoreAboutMatchDetectionDialog(
uriMatchDisplayType = uriItem.match.toDisplayMatchType(),
defaultUriOption = defaultUriOption,
onDialogConfirm = {
onLearnMoreClick()
shouldShowLearnMoreMatchDetectionDialog = false
},
onDialogDismiss = {
shouldShowLearnMoreMatchDetectionDialog = false
},
)
}
}
@Composable
private fun AdvancedMatchDetectionWarning(
pendingOption: UriMatchDisplayType,
defaultUriMatchType: UriMatchType,
onDialogConfirm: () -> Unit,
onDialogDismiss: () -> Unit,
) {
val descriptionStringResId = when (pendingOption) {
UriMatchDisplayType.STARTS_WITH -> {
BitwardenString.advanced_option_with_increased_risk_of_exposing_credentials
}
UriMatchDisplayType.REGULAR_EXPRESSION -> {
BitwardenString.advanced_option_increased_risk_exposing_credentials_used_incorrectly
}
UriMatchDisplayType.DEFAULT,
UriMatchDisplayType.HOST,
UriMatchDisplayType.BASE_DOMAIN,
UriMatchDisplayType.EXACT,
UriMatchDisplayType.NEVER,
->
error("Unexpected option on AdvancedMatchDetectionWarning")
}
val nameOfSelectedMatchDisplayType = pendingOption
.displayLabel(defaultUriOption = defaultUriMatchType.displayLabel())
.invoke()
BitwardenTwoButtonDialog(
title = stringResource(
id = BitwardenString.are_you_sure_you_want_to_use,
formatArgs = arrayOf(nameOfSelectedMatchDisplayType),
),
message = stringResource(
id = descriptionStringResId,
formatArgs = arrayOf(nameOfSelectedMatchDisplayType),
),
confirmButtonText = stringResource(id = BitwardenString.yes),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = onDialogConfirm,
onDismissClick = onDialogDismiss,
onDismissRequest = onDialogDismiss,
)
}
@Composable
private fun LearnMoreAboutMatchDetectionDialog(
uriMatchDisplayType: UriMatchDisplayType,
defaultUriOption: String,
onDialogConfirm: () -> Unit,
onDialogDismiss: () -> Unit,
) {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.keep_your_credential_secure),
message = stringResource(
id = BitwardenString.learn_more_about_how_to_keep_credentirals_secure,
formatArgs = arrayOf(
uriMatchDisplayType
.displayLabel(
defaultUriOption = defaultUriOption,
)
.invoke(),
),
),
confirmButtonText = stringResource(id = BitwardenString.learn_more),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = onDialogConfirm,
onDismissClick = onDialogDismiss,
onDismissRequest = onDialogDismiss,
)
}
@Composable
private fun uriMatchingOptions(defaultUriOption: String): ImmutableList<MultiSelectOption> {
val advancedOptions = UriMatchDisplayType.entries.filter { it.isAdvancedMatching() }
return persistentListOfNotNull(
*UriMatchDisplayType
.entries
.filter { !it.isAdvancedMatching() }
.map { MultiSelectOption.Row(it.displayLabel(defaultUriOption).invoke()) }
.toTypedArray(),
if (advancedOptions.isNotEmpty()) {
MultiSelectOption.Header(
title = stringResource(id = BitwardenString.advanced_options),
testTag = "AdvancedOptionsSection",
)
} else {
null
},
*advancedOptions
.map { MultiSelectOption.Row(it.displayLabel(defaultUriOption).invoke()) }
.toTypedArray(),
)
} }

View File

@ -42,6 +42,7 @@ import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrN
import com.x8bit.bitwarden.data.platform.manager.util.toCreateCredentialRequestOrNull import com.x8bit.bitwarden.data.platform.manager.util.toCreateCredentialRequestOrNull
import com.x8bit.bitwarden.data.platform.manager.util.toTotpDataOrNull import com.x8bit.bitwarden.data.platform.manager.util.toTotpDataOrNull
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
@ -202,6 +203,7 @@ class VaultAddEditViewModel @Inject constructor(
shouldExitOnSave = shouldExitOnSave, shouldExitOnSave = shouldExitOnSave,
shouldShowCoachMarkTour = false, shouldShowCoachMarkTour = false,
shouldClearSpecialCircumstance = autofillSelectionData == null, shouldClearSpecialCircumstance = autofillSelectionData == null,
defaultUriMatchType = settingsRepository.defaultUriMatchType,
) )
}, },
) { ) {
@ -1056,9 +1058,17 @@ class VaultAddEditViewModel @Inject constructor(
VaultAddEditAction.ItemType.LoginType.AuthenticatorHelpToolTipClick -> { VaultAddEditAction.ItemType.LoginType.AuthenticatorHelpToolTipClick -> {
handleAuthenticatorHelpToolTipClick() handleAuthenticatorHelpToolTipClick()
} }
VaultAddEditAction.ItemType.LoginType.LearnMoreClick -> {
handleLearnMoreClick()
}
} }
} }
private fun handleLearnMoreClick() {
sendEvent(VaultAddEditEvent.NavigateToLearnMore)
}
private fun handleStartLearnAboutLogins() { private fun handleStartLearnAboutLogins() {
coachMarkTourCompleted() coachMarkTourCompleted()
sendEvent(VaultAddEditEvent.StartAddLoginItemCoachMarkTour) sendEvent(VaultAddEditEvent.StartAddLoginItemCoachMarkTour)
@ -2230,6 +2240,7 @@ data class VaultAddEditState(
val shouldClearSpecialCircumstance: Boolean = true, val shouldClearSpecialCircumstance: Boolean = true,
val totpData: TotpData? = null, val totpData: TotpData? = null,
val createCredentialRequest: CreateCredentialRequest? = null, val createCredentialRequest: CreateCredentialRequest? = null,
val defaultUriMatchType: UriMatchType,
private val shouldShowCoachMarkTour: Boolean, private val shouldShowCoachMarkTour: Boolean,
) : Parcelable { ) : Parcelable {
@ -2880,6 +2891,11 @@ sealed class VaultAddEditEvent {
* Navigate the user to the tooltip URI for Authenticator key help. * Navigate the user to the tooltip URI for Authenticator key help.
*/ */
data object NavigateToAuthenticatorKeyTooltipUri : VaultAddEditEvent() data object NavigateToAuthenticatorKeyTooltipUri : VaultAddEditEvent()
/**
* Navigate the user to the learn more help page
*/
data object NavigateToLearnMore : VaultAddEditEvent()
} }
/** /**
@ -3216,6 +3232,11 @@ sealed class VaultAddEditAction {
* User has clicked the call to action on the authenticator help tooltip. * User has clicked the call to action on the authenticator help tooltip.
*/ */
data object AuthenticatorHelpToolTipClick : LoginType() data object AuthenticatorHelpToolTipClick : LoginType()
/**
* User has clicked the call to action on the learn more help link.
*/
data object LearnMoreClick : LoginType()
} }
/** /**

View File

@ -47,6 +47,7 @@ data class VaultAddEditLoginTypeHandlers(
val onStartLoginCoachMarkTour: () -> Unit, val onStartLoginCoachMarkTour: () -> Unit,
val onDismissLearnAboutLoginsCard: () -> Unit, val onDismissLearnAboutLoginsCard: () -> Unit,
val onAuthenticatorHelpToolTipClick: () -> Unit, val onAuthenticatorHelpToolTipClick: () -> Unit,
val onLearnMoreClick: () -> Unit,
) { ) {
@Suppress("UndocumentedPublicClass") @Suppress("UndocumentedPublicClass")
companion object { companion object {
@ -143,6 +144,11 @@ data class VaultAddEditLoginTypeHandlers(
VaultAddEditAction.ItemType.LoginType.LearnAboutLoginsDismissed, VaultAddEditAction.ItemType.LoginType.LearnAboutLoginsDismissed,
) )
}, },
onLearnMoreClick = {
viewModel.trySendAction(
VaultAddEditAction.ItemType.LoginType.LearnMoreClick,
)
},
) )
} }
} }

View File

@ -1,50 +1,44 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit.model package com.x8bit.bitwarden.ui.vault.feature.addedit.model
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
/** /**
* The options displayed to the user when choosing a match type * The options displayed to the user when choosing a match type
* for their URI. * for their URI.
*/ */
@Suppress("MagicNumber") @Suppress("MagicNumber")
enum class UriMatchDisplayType( enum class UriMatchDisplayType {
val text: Text,
) {
/** /**
* the default option for when the user has not chosen one. * the default option for when the user has not chosen one.
*/ */
DEFAULT(BitwardenString.default_text.asText()), DEFAULT,
/** /**
* The URIs match if their top-level and second-level domains match. * The URIs match if their top-level and second-level domains match.
*/ */
BASE_DOMAIN(BitwardenString.base_domain.asText()), BASE_DOMAIN,
/** /**
* The URIs match if their hostnames (and ports if specified) match. * The URIs match if their hostnames (and ports if specified) match.
*/ */
HOST(BitwardenString.host.asText()), HOST,
/** /**
* The URIs match if the "test" URI starts with the known URI. * The URIs match if the "test" URI starts with the known URI.
*/ */
STARTS_WITH(BitwardenString.starts_with.asText()), STARTS_WITH,
/** /**
* The URIs match if the "test" URI matches the known URI according to a specified regular * The URIs match if the "test" URI matches the known URI according to a specified regular
* expression for the item. * expression for the item.
*/ */
REGULAR_EXPRESSION(BitwardenString.reg_ex.asText()), REGULAR_EXPRESSION,
/** /**
* The URIs match if they are exactly the same. * The URIs match if they are exactly the same.
*/ */
EXACT(BitwardenString.exact.asText()), EXACT,
/** /**
* The URIs should never match. * The URIs should never match.
*/ */
NEVER(BitwardenString.never.asText()), NEVER,
} }

View File

@ -1,5 +1,8 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit.util package com.x8bit.bitwarden.ui.vault.feature.addedit.util
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.bitwarden.vault.UriMatchType import com.bitwarden.vault.UriMatchType
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriMatchDisplayType import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriMatchDisplayType
@ -30,3 +33,30 @@ fun UriMatchDisplayType.toUriMatchType(): UriMatchType? =
UriMatchDisplayType.EXACT -> UriMatchType.EXACT UriMatchDisplayType.EXACT -> UriMatchType.EXACT
UriMatchDisplayType.NEVER -> UriMatchType.NEVER UriMatchDisplayType.NEVER -> UriMatchType.NEVER
} }
/**
* Checks if the [UriMatchDisplayType] is considered an advanced matching strategy.
*/
fun UriMatchDisplayType.isAdvancedMatching(): Boolean =
when (this) {
UriMatchDisplayType.REGULAR_EXPRESSION,
UriMatchDisplayType.STARTS_WITH,
-> true
else -> false
}
/**
* Returns a human-readable display label for the given [UriMatchType].
*/
fun UriMatchDisplayType.displayLabel(defaultUriOption: String): Text {
return when (this) {
UriMatchDisplayType.DEFAULT -> BitwardenString.default_text.asText(defaultUriOption)
UriMatchDisplayType.BASE_DOMAIN -> BitwardenString.base_domain.asText()
UriMatchDisplayType.HOST -> BitwardenString.host.asText()
UriMatchDisplayType.STARTS_WITH -> BitwardenString.starts_with.asText()
UriMatchDisplayType.REGULAR_EXPRESSION -> BitwardenString.reg_ex.asText()
UriMatchDisplayType.EXACT -> BitwardenString.exact.asText()
UriMatchDisplayType.NEVER -> BitwardenString.never.asText()
}
}

View File

@ -12,6 +12,7 @@ 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 androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollTo
import androidx.core.net.toUri
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.assertNoDialogExists import com.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage
@ -31,6 +32,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@Suppress("LargeClass")
class AutoFillScreenTest : BitwardenComposeTest() { class AutoFillScreenTest : BitwardenComposeTest() {
private var isSystemSettingsRequestSuccess = false private var isSystemSettingsRequestSuccess = false
@ -50,6 +52,7 @@ class AutoFillScreenTest : BitwardenComposeTest() {
every { startSystemAutofillSettingsActivity() } answers { isSystemSettingsRequestSuccess } every { startSystemAutofillSettingsActivity() } answers { isSystemSettingsRequestSuccess }
every { startCredentialManagerSettings(any()) } just runs every { startCredentialManagerSettings(any()) } just runs
every { startSystemAccessibilitySettingsActivity() } just runs every { startSystemAccessibilitySettingsActivity() } just runs
every { launchUri(any()) } just runs
every { startBrowserAutofillSettingsActivity(any()) } returns true every { startBrowserAutofillSettingsActivity(any()) } returns true
} }
@ -672,6 +675,114 @@ class AutoFillScreenTest : BitwardenComposeTest() {
viewModel.trySendAction(AutoFillAction.PrivilegedAppsClick) viewModel.trySendAction(AutoFillAction.PrivilegedAppsClick)
} }
} }
@Suppress("MaxLineLength")
@Test
fun `on default URI match type dialog item click should send warning when is an Advanced Option`() {
composeTestRule
.onNodeWithText(text = "Default URI match detection")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText("Starts with")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText(
"“Starts with” is an advanced option with " +
"increased risk of exposing credentials.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Suppress("MaxLineLength")
@Test
fun `on advanced match detection warning dialog click on cancel should not change the default URI match type`() {
composeTestRule
.onNodeWithText(text = "Default URI match detection")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText("Starts with")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify(exactly = 0) {
viewModel.trySendAction(
AutoFillAction.DefaultUriMatchTypeSelect(
defaultUriMatchType = UriMatchType.STARTS_WITH,
),
)
}
}
@Test
fun `on Advanced matching warning dialog confirm should display learn more dialog`() {
composeTestRule
.onNodeWithText(text = "Default URI match detection")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText("Starts with")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Keep your credentials secure")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Suppress("MaxLineLength")
@Test
fun `on Advanced matching warning dialog click on more about match detection should call launchUri`() {
composeTestRule
.onNodeWithText(text = "Default URI match detection")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText("Starts with")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Learn more")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
AutoFillAction.LearnMoreClick,
)
}
}
@Test
fun `on NavigateToLearnMore should call launchUri`() {
mutableEventFlow.tryEmit(AutoFillEvent.NavigateToLearnMore)
intentManager.launchUri("https://bitwarden.com/help/uri-match-detection/".toUri())
}
} }
private val DEFAULT_STATE: AutoFillState = AutoFillState( private val DEFAULT_STATE: AutoFillState = AutoFillState(

View File

@ -450,6 +450,19 @@ class AutoFillViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `when LearnMoreClick action is handled NavigateToLearnMore event is sent`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(AutoFillAction.LearnMoreClick)
assertEquals(
AutoFillEvent.NavigateToLearnMore,
awaitItem(),
)
}
}
private fun createViewModel( private fun createViewModel(
state: AutoFillState? = DEFAULT_STATE, state: AutoFillState? = DEFAULT_STATE,
): AutoFillViewModel = AutoFillViewModel( ): AutoFillViewModel = AutoFillViewModel(

View File

@ -52,4 +52,32 @@ class UriMatchTypeExtensionsTest {
UriMatchType.STARTS_WITH.toSdkUriMatchType(), UriMatchType.STARTS_WITH.toSdkUriMatchType(),
) )
} }
@Test
fun `isAdvancedMatching should return the correct value for each type`() {
assertEquals(
false,
UriMatchType.DOMAIN.isAdvancedMatching(),
)
assertEquals(
false,
UriMatchType.EXACT.isAdvancedMatching(),
)
assertEquals(
false,
UriMatchType.HOST.isAdvancedMatching(),
)
assertEquals(
false,
UriMatchType.NEVER.isAdvancedMatching(),
)
assertEquals(
true,
UriMatchType.REGULAR_EXPRESSION.isAdvancedMatching(),
)
assertEquals(
true,
UriMatchType.STARTS_WITH.isAdvancedMatching(),
)
}
} }

View File

@ -83,6 +83,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType as UriMatchTypeModel
@Suppress("LargeClass") @Suppress("LargeClass")
class VaultAddEditScreenTest : BitwardenComposeTest() { class VaultAddEditScreenTest : BitwardenComposeTest() {
@ -1365,7 +1366,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
.assertIsDisplayed() .assertIsDisplayed()
composeTestRule composeTestRule
.onNodeWithText("Default") .onNodeWithText("Default (Exact)")
.assert(hasAnyAncestor(isDialog())) .assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed() .assertIsDisplayed()
@ -1386,6 +1387,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
composeTestRule composeTestRule
.onNodeWithText("Regular expression") .onNodeWithText("Regular expression")
.performScrollTo()
.assert(hasAnyAncestor(isDialog())) .assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed() .assertIsDisplayed()
@ -1477,6 +1479,186 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
composeTestRule.assertNoDialogExists() composeTestRule.assertNoDialogExists()
} }
@Suppress("MaxLineLength")
@Test
fun `on match detection when an Advanced option is selected should display warning dialog when Regular Expression`() {
composeTestRule
.onNodeWithTextAfterScroll(text = "Website (URI)")
.onChildren()
.filterToOne(hasContentDescription(value = "Options"))
.performClick()
composeTestRule
.onNodeWithText("Match detection")
.assert(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Regular expression")
.filterToOne(hasAnyAncestor(isDialog()))
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText(
"“Regular expression” is an advanced option with " +
"increased risk of exposing credentials if used incorrectly.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Suppress("MaxLineLength")
@Test
fun `on match detection when an Advanced option is selected should display warning dialog when Starts With`() {
composeTestRule
.onNodeWithTextAfterScroll(text = "Website (URI)")
.onChildren()
.filterToOne(hasContentDescription(value = "Options"))
.performClick()
composeTestRule
.onNodeWithText("Match detection")
.assert(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Starts with")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText(
"“Starts with” is an advanced option with " +
"increased risk of exposing credentials.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Suppress("MaxLineLength")
@Test
fun `on advanced match detection warning dialog click on cancel should not change the default URI match type`() {
composeTestRule
.onNodeWithTextAfterScroll(text = "Website (URI)")
.onChildren()
.filterToOne(hasContentDescription(value = "Options"))
.performClick()
composeTestRule
.onNodeWithText("Match detection")
.assert(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Starts with")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Close")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify(exactly = 0) {
viewModel.trySendAction(
VaultAddEditAction.ItemType.LoginType.UriValueChange(
UriItem(
id = "TestId",
uri = null,
match = UriMatchType.REGULAR_EXPRESSION,
checksum = null,
),
),
)
}
}
@Test
fun `on Advanced matching warning dialog confirm should display learn more dialog`() {
mutableStateFlow.update { currentState ->
updateLoginType(currentState) {
copy(
uriList = listOf(
UriItem(id = "TestId", uri = null, match = null, checksum = null),
),
)
}
}
composeTestRule
.onNodeWithTextAfterScroll(text = "Website (URI)")
.onChildren()
.filterToOne(hasContentDescription(value = "Options"))
.performClick()
composeTestRule
.onNodeWithText("Match detection")
.assert(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Starts with")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Keep your credentials secure")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Suppress("MaxLineLength")
@Test
fun `on Advanced matching warning dialog click on more about match detection should call launchUri`() {
mutableStateFlow.update { currentState ->
updateLoginType(currentState) {
copy(
uriList = listOf(
UriItem(id = "TestId", uri = null, match = null, checksum = null),
),
)
}
}
composeTestRule
.onNodeWithTextAfterScroll(text = "Website (URI)")
.onChildren()
.filterToOne(hasContentDescription(value = "Options"))
.performClick()
composeTestRule
.onNodeWithText("Match detection")
.assert(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Starts with")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Learn more")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultAddEditAction.ItemType.LoginType.LearnMoreClick,
)
}
}
@Test @Test
fun `in ItemType_Login state clicking the New URI button should trigger AddNewUriClick`() { fun `in ItemType_Login state clicking the New URI button should trigger AddNewUriClick`() {
composeTestRule composeTestRule
@ -3960,6 +4142,12 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
} }
} }
@Test
fun `on NavigateToLearnMore should call launchUri`() {
mutableEventFlow.tryEmit(VaultAddEditEvent.NavigateToLearnMore)
intentManager.launchUri("https://bitwarden.com/help/uri-match-detection/".toUri())
}
//region Helper functions //region Helper functions
private fun updateLoginType( private fun updateLoginType(
@ -4086,6 +4274,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
bottomSheetState = null, bottomSheetState = null,
vaultAddEditType = VaultAddEditType.AddItem, vaultAddEditType = VaultAddEditType.AddItem,
shouldShowCoachMarkTour = false, shouldShowCoachMarkTour = false,
defaultUriMatchType = UriMatchTypeModel.EXACT,
) )
private val DEFAULT_STATE_LOGIN = VaultAddEditState( private val DEFAULT_STATE_LOGIN = VaultAddEditState(
@ -4099,6 +4288,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
dialog = null, dialog = null,
bottomSheetState = null, bottomSheetState = null,
shouldShowCoachMarkTour = false, shouldShowCoachMarkTour = false,
defaultUriMatchType = UriMatchTypeModel.EXACT,
) )
private val DEFAULT_STATE_IDENTITY = VaultAddEditState( private val DEFAULT_STATE_IDENTITY = VaultAddEditState(
@ -4112,6 +4302,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
dialog = null, dialog = null,
bottomSheetState = null, bottomSheetState = null,
shouldShowCoachMarkTour = false, shouldShowCoachMarkTour = false,
defaultUriMatchType = UriMatchTypeModel.EXACT,
) )
private val DEFAULT_STATE_CARD = VaultAddEditState( private val DEFAULT_STATE_CARD = VaultAddEditState(
@ -4125,6 +4316,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
dialog = null, dialog = null,
bottomSheetState = null, bottomSheetState = null,
shouldShowCoachMarkTour = false, shouldShowCoachMarkTour = false,
defaultUriMatchType = UriMatchTypeModel.EXACT,
) )
private val DEFAULT_STATE_SECURE_NOTES_CUSTOM_FIELDS = VaultAddEditState( private val DEFAULT_STATE_SECURE_NOTES_CUSTOM_FIELDS = VaultAddEditState(
@ -4148,6 +4340,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
vaultAddEditType = VaultAddEditType.AddItem, vaultAddEditType = VaultAddEditType.AddItem,
cipherType = VaultItemCipherType.SECURE_NOTE, cipherType = VaultItemCipherType.SECURE_NOTE,
shouldShowCoachMarkTour = false, shouldShowCoachMarkTour = false,
defaultUriMatchType = UriMatchTypeModel.EXACT,
) )
private val DEFAULT_STATE_SECURE_NOTES = VaultAddEditState( private val DEFAULT_STATE_SECURE_NOTES = VaultAddEditState(
@ -4161,6 +4354,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
dialog = null, dialog = null,
bottomSheetState = null, bottomSheetState = null,
shouldShowCoachMarkTour = false, shouldShowCoachMarkTour = false,
defaultUriMatchType = UriMatchTypeModel.EXACT,
) )
private val DEFAULT_STATE_SSH_KEYS = VaultAddEditState( private val DEFAULT_STATE_SSH_KEYS = VaultAddEditState(
@ -4174,6 +4368,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
dialog = null, dialog = null,
bottomSheetState = null, bottomSheetState = null,
shouldShowCoachMarkTour = false, shouldShowCoachMarkTour = false,
defaultUriMatchType = UriMatchTypeModel.EXACT,
) )
private val ALTERED_COLLECTIONS = listOf( private val ALTERED_COLLECTIONS = listOf(

View File

@ -119,6 +119,7 @@ import java.time.Clock
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType as UriMatchTypeModel
@Suppress("LargeClass") @Suppress("LargeClass")
class VaultAddEditViewModelTest : BaseViewModelTest() { class VaultAddEditViewModelTest : BaseViewModelTest() {
@ -131,6 +132,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
every { initialAutofillDialogShown = any() } just runs every { initialAutofillDialogShown = any() } just runs
every { initialAutofillDialogShown } returns true every { initialAutofillDialogShown } returns true
every { isUnlockWithPinEnabled } returns false every { isUnlockWithPinEnabled } returns false
every { defaultUriMatchType } returns UriMatchTypeModel.EXACT
} }
private val mutableUserStateFlow = MutableStateFlow<UserState?>(createUserState()) private val mutableUserStateFlow = MutableStateFlow<UserState?>(createUserState())
private val authRepository: AuthRepository = mockk { private val authRepository: AuthRepository = mockk {
@ -247,6 +249,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
shouldShowCloseButton = true, shouldShowCloseButton = true,
shouldExitOnSave = false, shouldExitOnSave = false,
shouldShowCoachMarkTour = false, shouldShowCoachMarkTour = false,
defaultUriMatchType = UriMatchTypeModel.EXACT,
) )
val viewModel = createAddVaultItemViewModel( val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState( savedStateHandle = createSavedStateHandleWithState(
@ -333,6 +336,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
dialog = null, dialog = null,
bottomSheetState = null, bottomSheetState = null,
shouldShowCoachMarkTour = false, shouldShowCoachMarkTour = false,
defaultUriMatchType = UriMatchTypeModel.EXACT,
), ),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
@ -4568,6 +4572,19 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `when LearnMoreClick action is handled NavigateToLearnMore event is sent`() =
runTest {
val viewModel = createAddVaultItemViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAddEditAction.ItemType.LoginType.LearnMoreClick)
assertEquals(
VaultAddEditEvent.NavigateToLearnMore,
awaitItem(),
)
}
}
//region Helper functions //region Helper functions
@Suppress("LongParameterList") @Suppress("LongParameterList")
@ -4601,6 +4618,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
shouldShowCoachMarkTour = false, shouldShowCoachMarkTour = false,
shouldClearSpecialCircumstance = shouldClearSpecialCircumstance, shouldClearSpecialCircumstance = shouldClearSpecialCircumstance,
createCredentialRequest = createCredentialRequest, createCredentialRequest = createCredentialRequest,
defaultUriMatchType = UriMatchTypeModel.EXACT,
) )
@Suppress("LongParameterList") @Suppress("LongParameterList")

View File

@ -286,7 +286,7 @@ Scanning will happen automatically.</string>
<string name="new_uri">New URI</string> <string name="new_uri">New URI</string>
<string name="add_website">Add website</string> <string name="add_website">Add website</string>
<string name="base_domain">Base domain</string> <string name="base_domain">Base domain</string>
<string name="default_text">Default</string> <string name="default_text">Default (%1$s)</string>
<string name="exact">Exact</string> <string name="exact">Exact</string>
<string name="host">Host</string> <string name="host">Host</string>
<string name="reg_ex">Regular expression</string> <string name="reg_ex">Regular expression</string>
@ -356,7 +356,7 @@ Scanning will happen automatically.</string>
<string name="clear_clipboard">Clear clipboard</string> <string name="clear_clipboard">Clear clipboard</string>
<string name="clear_clipboard_description">Automatically clear copied values from your clipboard.</string> <string name="clear_clipboard_description">Automatically clear copied values from your clipboard.</string>
<string name="default_uri_match_detection">Default URI match detection</string> <string name="default_uri_match_detection">Default URI match detection</string>
<string name="default_uri_match_detection_description">Choose the default way that URI match detection is handled for logins when performing actions such as autofill.</string> <string name="default_uri_match_detection_description">URI match detection controls how Bitwarden identifies autofill suggestions.</string>
<string name="theme">Theme</string> <string name="theme">Theme</string>
<string name="theme_description">Change the application\'s color theme</string> <string name="theme_description">Change the application\'s color theme</string>
<string name="copy_notes">Copy note</string> <string name="copy_notes">Copy note</string>
@ -1069,4 +1069,13 @@ Do you want to switch to this account?</string>
<string name="items_expanded_click_to_collapse">Items are expanded, click to collapse.</string> <string name="items_expanded_click_to_collapse">Items are expanded, click to collapse.</string>
<string name="items_are_collapsed_click_to_expand">Items are collapsed, click to expand.</string> <string name="items_are_collapsed_click_to_expand">Items are collapsed, click to expand.</string>
<string name="select_a_card_for_x">Select a card for %s</string> <string name="select_a_card_for_x">Select a card for %s</string>
<string name="advanced_options">Advanced options</string>
<string name="more_about_match_detection"><annotation link="moreAboutMatchDetection">More about match detection</annotation></string>
<string name="keep_your_credential_secure">Keep your credentials secure</string>
<string name="learn_more_about_how_to_keep_credentirals_secure">Learn more about how to keep credentials secure when using “%1$s”.</string>
<string name="are_you_sure_you_want_to_use">Are you sure you want to use “%1$s”?</string>
<string name="default_uri_match_detection_description_advanced_options_incorrectly">URI match detection controls how Bitwarden identifies autofill suggestions.\n<annotation emphasis="bold">Warning:</annotation> “Regular expression” is an advanced option with increased risk of exposing credentials if used incorrectly.</string>
<string name="default_uri_match_detection_description_advanced_options">URI match detection controls how Bitwarden identifies autofill suggestions.\n<annotation emphasis="bold">Warning:</annotation> “Starts with” is an advanced option with increased risk of exposing credentials.</string>
<string name="advanced_option_with_increased_risk_of_exposing_credentials">“Starts with” is an advanced option with increased risk of exposing credentials.</string>
<string name="advanced_option_increased_risk_exposing_credentials_used_incorrectly">“Regular expression” is an advanced option with increased risk of exposing credentials if used incorrectly.</string>
</resources> </resources>