diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt index 0693d899fa..80f6e607d5 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState 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.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel 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.annotatedStringResource +import com.bitwarden.ui.platform.base.util.spanStyleOf import com.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar 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.resource.BitwardenDrawable 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.ui.platform.components.dialog.BitwardenBasicDialog 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.model.MultiSelectOption 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.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.handlers.AutoFillHandlers 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 kotlinx.collections.immutable.toImmutableList @@ -109,6 +117,10 @@ fun AutoFillScreen( AutoFillEvent.NavigateToPrivilegedAppsListScreen -> { onNavigateToPrivilegedAppsList() } + + AutoFillEvent.NavigateToLearnMore -> { + intentManager.launchUri("https://bitwarden.com/help/uri-match-detection/".toUri()) + } } } @@ -303,6 +315,7 @@ private fun AutoFillScreenContent( DefaultUriMatchTypeRow( selectedUriMatchType = state.defaultUriMatchType, onUriMatchTypeSelect = autoFillHandlers.onDefaultUriMatchTypeSelect, + onNavigateToLearnMore = autoFillHandlers.onLearnMoreClick, modifier = Modifier .testTag("DefaultUriMatchDetectionChooser") .standardHorizontalMargin() @@ -387,24 +400,205 @@ private fun AccessibilityAutofillSwitch( private fun DefaultUriMatchTypeRow( selectedUriMatchType: UriMatchType, onUriMatchTypeSelect: (UriMatchType) -> Unit, + onNavigateToLearnMore: () -> Unit, + modifier: Modifier = Modifier, +) { + var showAdvancedDialog by rememberSaveable { mutableStateOf(false) } + var optionPendingConfirmation by rememberSaveable { mutableStateOf(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, 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( label = stringResource(id = BitwardenString.default_uri_match_detection), - options = UriMatchType.entries.map { it.displayLabel() }.toImmutableList(), - selectedOption = selectedUriMatchType.displayLabel(), - onOptionSelected = { selectedOption -> - onUriMatchTypeSelect( - UriMatchType - .entries - .first { it.displayLabel.toString(resources) == selectedOption }, - ) + options = options, + selectedOption = MultiSelectOption.Row(selectedUriMatchType.displayLabel()), + onOptionSelected = { row -> + val newSelectedType = UriMatchType + .entries + .first { it.displayLabel(resources) == row.title } + onOptionSelected(newSelectedType) }, - supportingText = stringResource( - id = BitwardenString.default_uri_match_detection_description, - ), cardStyle = CardStyle.Full, + supportingContent = { SupportingTextForMatchDetection(selectedUriMatchType) }, 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(), + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt index 7df00fcab1..267d58730e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt @@ -131,12 +131,17 @@ class AutoFillViewModel @Inject constructor( is AutoFillAction.BrowserAutofillSelected -> handleBrowserAutofillSelected(action) AutoFillAction.AboutPrivilegedAppsClick -> handleAboutPrivilegedAppsClick() AutoFillAction.PrivilegedAppsClick -> handlePrivilegedAppsClick() + AutoFillAction.LearnMoreClick -> handleLearnMoreClick() } private fun handlePrivilegedAppsClick() { sendEvent(AutoFillEvent.NavigateToPrivilegedAppsListScreen) } + private fun handleLearnMoreClick() { + sendEvent(AutoFillEvent.NavigateToLearnMore) + } + private fun handleInternalAction(action: AutoFillAction.Internal) { when (action) { is AutoFillAction.Internal.AccessibilityEnabledUpdateReceive -> { @@ -390,6 +395,11 @@ sealed class AutoFillEvent { * Navigate to the privileged apps list screen. */ data object NavigateToPrivilegedAppsListScreen : AutoFillEvent() + + /** + * Navigate to the learn more. + */ + data object NavigateToLearnMore : AutoFillEvent() } /** @@ -476,6 +486,11 @@ sealed class AutoFillAction { */ data object PrivilegedAppsClick : AutoFillAction() + /** + * User has clicked the learn more help link. + */ + data object LearnMoreClick : AutoFillAction() + /** * Internal actions. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt index 6aa7c119f7..8bce9df11f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt @@ -25,6 +25,7 @@ class AutoFillHandlers( val onAskToAddLoginClick: (isEnabled: Boolean) -> Unit, val onDefaultUriMatchTypeSelect: (defaultUriMatchType: UriMatchType) -> Unit, val onBlockAutoFillClick: () -> Unit, + val onLearnMoreClick: () -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -86,6 +87,7 @@ class AutoFillHandlers( ) }, onBlockAutoFillClick = { viewModel.trySendAction(AutoFillAction.BlockAutoFillClick) }, + onLearnMoreClick = { viewModel.trySendAction(AutoFillAction.LearnMoreClick) }, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/util/UriMatchTypeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/util/UriMatchTypeExtensions.kt index 0ea84af57f..1ace97764e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/util/UriMatchTypeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/util/UriMatchTypeExtensions.kt @@ -31,3 +31,15 @@ fun UriMatchType.toSdkUriMatchType(): com.bitwarden.vault.UriMatchType = UriMatchType.REGULAR_EXPRESSION -> com.bitwarden.vault.UriMatchType.REGULAR_EXPRESSION 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 + } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt index 5e6b4815d9..17911dcac2 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt @@ -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.resource.BitwardenDrawable 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.field.BitwardenTextField 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.VaultAddEditContent( state: VaultAddEditState.ViewState.Content, isAddItemMode: Boolean, + defaultUriMatchType: UriMatchType, commonTypeHandlers: VaultAddEditCommonHandlers, loginItemTypeHandlers: VaultAddEditLoginTypeHandlers, identityItemTypeHandlers: VaultAddEditIdentityTypeHandlers, @@ -212,6 +214,7 @@ fun CoachMarkScope.VaultAddEditContent( onNextCoachMark = onNextCoachMark, onCoachMarkTourComplete = onCoachMarkTourComplete, onCoachMarkDismissed = onCoachMarkDismissed, + defaultUriMatchType = defaultUriMatchType, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt index 5eb2953984..c8f2ff86c5 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt @@ -29,6 +29,7 @@ import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme import com.bitwarden.ui.util.Text 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.CoachMarkScope import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape @@ -54,6 +55,7 @@ fun LazyListScope.vaultAddEditLoginItems( onPreviousCoachMark: () -> Unit, onCoachMarkTourComplete: () -> Unit, onCoachMarkDismissed: () -> Unit, + defaultUriMatchType: UriMatchType, ) = coachMarkScope.run { item { Spacer(modifier = Modifier.height(height = 16.dp)) @@ -192,7 +194,9 @@ fun LazyListScope.vaultAddEditLoginItems( uriItem = uriItem, onUriValueChange = loginItemTypeHandlers.onUriValueChange, onUriItemRemoved = loginItemTypeHandlers.onRemoveUriClick, + onLearnMoreClick = loginItemTypeHandlers.onLearnMoreClick, cardStyle = cardStyle, + defaultUriMatchType = defaultUriMatchType, modifier = Modifier .fillMaxWidth(), ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index f003c0f322..88608ddc53 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -194,6 +194,10 @@ fun VaultAddEditScreen( } 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( state = viewState, isAddItemMode = state.isAddItemMode, + defaultUriMatchType = state.defaultUriMatchType, loginItemTypeHandlers = loginItemTypeHandlers, commonTypeHandlers = commonTypeHandlers, permissionsManager = permissionsManager, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditUriItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditUriItem.kt index eaea58056e..c872682cd8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditUriItem.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditUriItem.kt @@ -1,25 +1,38 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit +import android.content.res.Resources import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag 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.model.CardStyle import com.bitwarden.ui.platform.resource.BitwardenDrawable 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.BitwardenTwoButtonDialog 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.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.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.toUriMatchType +import kotlinx.collections.immutable.ImmutableList /** * The URI item displayed to the user. @@ -31,10 +44,20 @@ fun VaultAddEditUriItem( onUriItemRemoved: (UriItem) -> Unit, onUriValueChange: (UriItem) -> Unit, cardStyle: CardStyle, + defaultUriMatchType: UriMatchType, + onLearnMoreClick: () -> Unit, modifier: Modifier = Modifier, + resources: Resources = LocalContext.current.resources, + intentManager: IntentManager = LocalIntentManager.current, ) { var shouldShowOptionsDialog by rememberSaveable { mutableStateOf(false) } var shouldShowMatchDialog by rememberSaveable { mutableStateOf(false) } + var shouldShowAdvancedMatchDialog by rememberSaveable { mutableStateOf(false) } + var optionPendingConfirmation by rememberSaveable { mutableStateOf(null) } + var shouldShowLearnMoreMatchDetectionDialog by rememberSaveable { mutableStateOf(false) } + val defaultUriOption = remember(defaultUriMatchType) { + defaultUriMatchType.displayLabel.toString(resources) + } BitwardenTextField( label = stringResource(id = BitwardenString.website_uri), @@ -76,26 +99,173 @@ fun VaultAddEditUriItem( } if (shouldShowMatchDialog) { - val selectedString = uriItem.match.toDisplayMatchType().text.invoke() - BitwardenSelectionDialog( title = stringResource(id = BitwardenString.uri_match_detection), onDismissRequest = { shouldShowMatchDialog = false }, ) { - UriMatchDisplayType - .entries - .forEach { matchType -> - BitwardenSelectionRow( - text = matchType.text, - isSelected = matchType.text.invoke() == selectedString, - onClick = { - shouldShowMatchDialog = false - onUriValueChange( - uriItem.copy(match = matchType.toUriMatchType()), - ) - }, - ) - } + BitwardenMultiSelectDialogContent( + options = uriMatchingOptions(defaultUriOption = defaultUriOption), + selectedOption = MultiSelectOption.Row( + uriItem + .match + .toDisplayMatchType() + .displayLabel(defaultUriOption = defaultUriOption) + .invoke(), + ), + 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 { + 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(), + ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index d5f22aea88..7dee8d5a11 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -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.toTotpDataOrNull 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.model.GeneratorResult import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult @@ -202,6 +203,7 @@ class VaultAddEditViewModel @Inject constructor( shouldExitOnSave = shouldExitOnSave, shouldShowCoachMarkTour = false, shouldClearSpecialCircumstance = autofillSelectionData == null, + defaultUriMatchType = settingsRepository.defaultUriMatchType, ) }, ) { @@ -1056,9 +1058,17 @@ class VaultAddEditViewModel @Inject constructor( VaultAddEditAction.ItemType.LoginType.AuthenticatorHelpToolTipClick -> { handleAuthenticatorHelpToolTipClick() } + + VaultAddEditAction.ItemType.LoginType.LearnMoreClick -> { + handleLearnMoreClick() + } } } + private fun handleLearnMoreClick() { + sendEvent(VaultAddEditEvent.NavigateToLearnMore) + } + private fun handleStartLearnAboutLogins() { coachMarkTourCompleted() sendEvent(VaultAddEditEvent.StartAddLoginItemCoachMarkTour) @@ -2230,6 +2240,7 @@ data class VaultAddEditState( val shouldClearSpecialCircumstance: Boolean = true, val totpData: TotpData? = null, val createCredentialRequest: CreateCredentialRequest? = null, + val defaultUriMatchType: UriMatchType, private val shouldShowCoachMarkTour: Boolean, ) : Parcelable { @@ -2880,6 +2891,11 @@ sealed class VaultAddEditEvent { * Navigate the user to the tooltip URI for Authenticator key help. */ 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. */ data object AuthenticatorHelpToolTipClick : LoginType() + + /** + * User has clicked the call to action on the learn more help link. + */ + data object LearnMoreClick : LoginType() } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt index ef2ccfd26e..84099035d8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt @@ -47,6 +47,7 @@ data class VaultAddEditLoginTypeHandlers( val onStartLoginCoachMarkTour: () -> Unit, val onDismissLearnAboutLoginsCard: () -> Unit, val onAuthenticatorHelpToolTipClick: () -> Unit, + val onLearnMoreClick: () -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -143,6 +144,11 @@ data class VaultAddEditLoginTypeHandlers( VaultAddEditAction.ItemType.LoginType.LearnAboutLoginsDismissed, ) }, + onLearnMoreClick = { + viewModel.trySendAction( + VaultAddEditAction.ItemType.LoginType.LearnMoreClick, + ) + }, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/model/UriMatchDisplayType.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/model/UriMatchDisplayType.kt index dc3bcc54f3..34f6f3b48b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/model/UriMatchDisplayType.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/model/UriMatchDisplayType.kt @@ -1,50 +1,44 @@ 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 * for their URI. */ @Suppress("MagicNumber") -enum class UriMatchDisplayType( - val text: Text, -) { +enum class UriMatchDisplayType { /** * 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. */ - BASE_DOMAIN(BitwardenString.base_domain.asText()), + BASE_DOMAIN, /** * 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. */ - STARTS_WITH(BitwardenString.starts_with.asText()), + STARTS_WITH, /** * The URIs match if the "test" URI matches the known URI according to a specified regular * expression for the item. */ - REGULAR_EXPRESSION(BitwardenString.reg_ex.asText()), + REGULAR_EXPRESSION, /** * The URIs match if they are exactly the same. */ - EXACT(BitwardenString.exact.asText()), + EXACT, /** * The URIs should never match. */ - NEVER(BitwardenString.never.asText()), + NEVER, } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/UriMatchDisplayTypeUtil.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/UriMatchDisplayTypeUtil.kt index 178aa025b9..6779f57cbe 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/UriMatchDisplayTypeUtil.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/UriMatchDisplayTypeUtil.kt @@ -1,5 +1,8 @@ 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.x8bit.bitwarden.ui.vault.feature.addedit.model.UriMatchDisplayType @@ -30,3 +33,30 @@ fun UriMatchDisplayType.toUriMatchType(): UriMatchType? = UriMatchDisplayType.EXACT -> UriMatchType.EXACT 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() + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt index 7792bfcf78..0dba3fa8eb 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.core.net.toUri import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.ui.util.assertNoDialogExists 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.Test +@Suppress("LargeClass") class AutoFillScreenTest : BitwardenComposeTest() { private var isSystemSettingsRequestSuccess = false @@ -50,6 +52,7 @@ class AutoFillScreenTest : BitwardenComposeTest() { every { startSystemAutofillSettingsActivity() } answers { isSystemSettingsRequestSuccess } every { startCredentialManagerSettings(any()) } just runs every { startSystemAccessibilitySettingsActivity() } just runs + every { launchUri(any()) } just runs every { startBrowserAutofillSettingsActivity(any()) } returns true } @@ -672,6 +675,114 @@ class AutoFillScreenTest : BitwardenComposeTest() { 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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt index 8dcf59c75e..4714bff469 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt @@ -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( state: AutoFillState? = DEFAULT_STATE, ): AutoFillViewModel = AutoFillViewModel( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/util/UriMatchTypeExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/util/UriMatchTypeExtensionsTest.kt index adb38d6b64..77910db1b2 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/util/UriMatchTypeExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/util/UriMatchTypeExtensionsTest.kt @@ -52,4 +52,32 @@ class UriMatchTypeExtensionsTest { 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(), + ) + } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index 43a2b34f73..cd268081e0 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -83,6 +83,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType as UriMatchTypeModel @Suppress("LargeClass") class VaultAddEditScreenTest : BitwardenComposeTest() { @@ -1365,7 +1366,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { .assertIsDisplayed() composeTestRule - .onNodeWithText("Default") + .onNodeWithText("Default (Exact)") .assert(hasAnyAncestor(isDialog())) .assertIsDisplayed() @@ -1386,6 +1387,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText("Regular expression") + .performScrollTo() .assert(hasAnyAncestor(isDialog())) .assertIsDisplayed() @@ -1477,6 +1479,186 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { 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 fun `in ItemType_Login state clicking the New URI button should trigger AddNewUriClick`() { 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 private fun updateLoginType( @@ -4086,6 +4274,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { bottomSheetState = null, vaultAddEditType = VaultAddEditType.AddItem, shouldShowCoachMarkTour = false, + defaultUriMatchType = UriMatchTypeModel.EXACT, ) private val DEFAULT_STATE_LOGIN = VaultAddEditState( @@ -4099,6 +4288,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { dialog = null, bottomSheetState = null, shouldShowCoachMarkTour = false, + defaultUriMatchType = UriMatchTypeModel.EXACT, ) private val DEFAULT_STATE_IDENTITY = VaultAddEditState( @@ -4112,6 +4302,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { dialog = null, bottomSheetState = null, shouldShowCoachMarkTour = false, + defaultUriMatchType = UriMatchTypeModel.EXACT, ) private val DEFAULT_STATE_CARD = VaultAddEditState( @@ -4125,6 +4316,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { dialog = null, bottomSheetState = null, shouldShowCoachMarkTour = false, + defaultUriMatchType = UriMatchTypeModel.EXACT, ) private val DEFAULT_STATE_SECURE_NOTES_CUSTOM_FIELDS = VaultAddEditState( @@ -4148,6 +4340,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { vaultAddEditType = VaultAddEditType.AddItem, cipherType = VaultItemCipherType.SECURE_NOTE, shouldShowCoachMarkTour = false, + defaultUriMatchType = UriMatchTypeModel.EXACT, ) private val DEFAULT_STATE_SECURE_NOTES = VaultAddEditState( @@ -4161,6 +4354,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { dialog = null, bottomSheetState = null, shouldShowCoachMarkTour = false, + defaultUriMatchType = UriMatchTypeModel.EXACT, ) private val DEFAULT_STATE_SSH_KEYS = VaultAddEditState( @@ -4174,6 +4368,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { dialog = null, bottomSheetState = null, shouldShowCoachMarkTour = false, + defaultUriMatchType = UriMatchTypeModel.EXACT, ) private val ALTERED_COLLECTIONS = listOf( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 0feacae232..fa31b7e2b3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -119,6 +119,7 @@ import java.time.Clock import java.time.Instant import java.time.ZoneOffset import java.util.UUID +import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType as UriMatchTypeModel @Suppress("LargeClass") class VaultAddEditViewModelTest : BaseViewModelTest() { @@ -131,6 +132,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { every { initialAutofillDialogShown = any() } just runs every { initialAutofillDialogShown } returns true every { isUnlockWithPinEnabled } returns false + every { defaultUriMatchType } returns UriMatchTypeModel.EXACT } private val mutableUserStateFlow = MutableStateFlow(createUserState()) private val authRepository: AuthRepository = mockk { @@ -247,6 +249,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { shouldShowCloseButton = true, shouldExitOnSave = false, shouldShowCoachMarkTour = false, + defaultUriMatchType = UriMatchTypeModel.EXACT, ) val viewModel = createAddVaultItemViewModel( savedStateHandle = createSavedStateHandleWithState( @@ -333,6 +336,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { dialog = null, bottomSheetState = null, shouldShowCoachMarkTour = false, + defaultUriMatchType = UriMatchTypeModel.EXACT, ), 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 @Suppress("LongParameterList") @@ -4601,6 +4618,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { shouldShowCoachMarkTour = false, shouldClearSpecialCircumstance = shouldClearSpecialCircumstance, createCredentialRequest = createCredentialRequest, + defaultUriMatchType = UriMatchTypeModel.EXACT, ) @Suppress("LongParameterList") diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 3fcbfbb47f..6300734ec5 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -286,7 +286,7 @@ Scanning will happen automatically. New URI Add website Base domain - Default + Default (%1$s) Exact Host Regular expression @@ -356,7 +356,7 @@ Scanning will happen automatically. Clear clipboard Automatically clear copied values from your clipboard. Default URI match detection - Choose the default way that URI match detection is handled for logins when performing actions such as autofill. + URI match detection controls how Bitwarden identifies autofill suggestions. Theme Change the application\'s color theme Copy note @@ -1069,4 +1069,13 @@ Do you want to switch to this account? Items are expanded, click to collapse. Items are collapsed, click to expand. Select a card for %s + Advanced options + More about match detection + Keep your credentials secure + Learn more about how to keep credentials secure when using “%1$s”. + Are you sure you want to use “%1$s”? + URI match detection controls how Bitwarden identifies autofill suggestions.\nWarning: “Regular expression” is an advanced option with increased risk of exposing credentials if used incorrectly. + URI match detection controls how Bitwarden identifies autofill suggestions.\nWarning: “Starts with” is an advanced option with increased risk of exposing credentials. + “Starts with” is an advanced option with increased risk of exposing credentials. + “Regular expression” is an advanced option with increased risk of exposing credentials if used incorrectly.