diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/ViewNodeTraversalData.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/ViewNodeTraversalData.kt index da66146d92..d6b28b0c6b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/ViewNodeTraversalData.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/ViewNodeTraversalData.kt @@ -7,10 +7,12 @@ import android.view.autofill.AutofillId * * @param autofillViews The list of views we care about for autofilling. * @param idPackage The package id for this view, if there is one. + * @param urlBarWebsites The website associated with the URL bar view. * @param ignoreAutofillIds The list of [AutofillId]s that should be ignored in the fill response. */ data class ViewNodeTraversalData( val autofillViews: List, val idPackage: String?, + val urlBarWebsites: List, val ignoreAutofillIds: List, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt index a35b1b9959..8d087d7834 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt @@ -29,6 +29,20 @@ private val BLOCK_LISTED_URIS: List = listOf( "androidapp://com.oneplus.applocker", ) +/** + * A map of package ids and the known associated id entry for their url bar. + */ +private val URL_BARS: Map = mapOf( + "com.microsoft.emmx" to "url_bar", + "com.microsoft.emmx.beta" to "url_bar", + "com.microsoft.emmx.canary" to "url_bar", + "com.microsoft.emmx.dev" to "url_bar", + "com.sec.android.app.sbrowser" to "location_bar_edit_text", + "com.sec.android.app.sbrowser.beta" to "location_bar_edit_text", + "com.opera.browser" to "url_bar", + "com.opera.browser.beta" to "url_bar", +) + /** * The default [AutofillParser] implementation for the app. This is a tool for parsing autofill data * from the OS into domain models. @@ -76,18 +90,24 @@ class AutofillParserImpl( Timber.d("Parsing AssistStructure -- ${fillRequest?.id}") // Parse the `assistStructure` into internal models. val traversalDataList = assistStructure.traverse() + val urlBarWebsite = traversalDataList + .flatMap { it.urlBarWebsites } + .firstOrNull() + ?.takeIf { settingsRepository.isAutofillWebDomainCompatMode } + // Take only the autofill views from the node that currently has focus. // Then remove all the fields that cannot be filled with data. // We fallback to taking all the fillable views if nothing has focus. val autofillViewsList = traversalDataList.map { it.autofillViews } - val autofillViews = autofillViewsList + val autofillViews = (autofillViewsList .filter { views -> views.any { it.data.isFocused } } .flatten() .filter { it !is AutofillView.Unused } .takeUnless { it.isEmpty() } ?: autofillViewsList .flatten() - .filter { it !is AutofillView.Unused } + .filter { it !is AutofillView.Unused }) + .map { it.updateWebsiteIfNecessary(website = urlBarWebsite) } // Find the focused view, or fallback to the first fillable item on the screen (so // we at least have something to hook into) @@ -257,6 +277,12 @@ private fun AssistStructure.ViewNode.traverse( // OS sometimes defaults node.idPackage to "android", which is not a valid // package name so it is ignored to prevent auto-filling unknown applications. var storedIdPackage: String? = this.idPackage?.takeUnless { it.isBlank() || it == "android" } + val storedUrlBarId = storedIdPackage?.let { URL_BARS[it] } + val storedUrlBarWebsites: MutableList = this + .website + ?.takeIf { _ -> storedUrlBarId != null && storedUrlBarId == this.idEntry } + ?.let { mutableListOf(it) } + ?: mutableListOf() // Try converting this `ViewNode` into an `AutofillView`. If a valid instance is returned, add // it to the list. Otherwise, ignore the `AutofillId` associated with this `ViewNode`. @@ -277,6 +303,9 @@ private fun AssistStructure.ViewNode.traverse( if (storedIdPackage == null) { storedIdPackage = viewNodeTraversalData.idPackage } + // Add all url bar websites. We will deal with this later if + // there is somehow more than one. + storedUrlBarWebsites.addAll(viewNodeTraversalData.urlBarWebsites) } } @@ -285,6 +314,28 @@ private fun AssistStructure.ViewNode.traverse( return ViewNodeTraversalData( autofillViews = mutableAutofillViewList, idPackage = storedIdPackage, + urlBarWebsites = storedUrlBarWebsites, ignoreAutofillIds = mutableIgnoreAutofillIdList, ) } + +/** + * This updates the underlying [AutofillView.data] with the given [website] if it does not already + * have a website associated with it. + */ +private fun AutofillView.updateWebsiteIfNecessary(website: String?): AutofillView { + val site = website ?: return this + if (this.data.website != null) return this + return when (this) { + is AutofillView.Card.Brand -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Card.CardholderName -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Card.ExpirationDate -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Card.ExpirationMonth -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Card.ExpirationYear -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Card.Number -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Card.SecurityCode -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Login.Password -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Login.Username -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Unused -> this.copy(data = this.data.copy(website = site)) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt index ac96d5c458..45de48b6ca 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -110,6 +110,11 @@ interface SettingsDiskSource { */ var browserAutofillDialogReshowTime: Instant? + /** + * The current status of whether the web domain compatibility mode is enabled. + */ + var isAutofillWebDomainCompatMode: Boolean? + /** * Clears all the settings data for the given user. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 07b3f9c5ef..c1a430d774 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -50,6 +50,7 @@ private const val RESUME_SCREEN = "resumeScreen" private const val FLIGHT_RECORDER_KEY = "flightRecorderData" private const val IS_DYNAMIC_COLORS_ENABLED = "isDynamicColorsEnabled" private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogReshowTime" +private const val AUTOFILL_WEB_DOMAIN_COMPATIBILITY = "autofillWebDomainCompatibility" /** * Primary implementation of [SettingsDiskSource]. @@ -234,6 +235,12 @@ class SettingsDiskSourceImpl( putLong(key = BROWSER_AUTOFILL_DIALOG_RESHOW_TIME, value = value?.toEpochMilli()) } + override var isAutofillWebDomainCompatMode: Boolean? + get() = getBoolean(key = AUTOFILL_WEB_DOMAIN_COMPATIBILITY) + set(value) { + putBoolean(key = AUTOFILL_WEB_DOMAIN_COMPATIBILITY, value = value) + } + override fun clearData(userId: String) { storeVaultTimeoutInMinutes(userId = userId, vaultTimeoutInMinutes = null) storeVaultTimeoutAction(userId = userId, vaultTimeoutAction = null) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt index 046ab5e63e..02f0964230 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt @@ -155,6 +155,11 @@ interface SettingsRepository : FlightRecorderManager { */ var isAutofillSavePromptDisabled: Boolean + /** + * Whether or not the autofill web domain parsing is enabled. + */ + var isAutofillWebDomainCompatMode: Boolean + /** * A list of blocked autofill URI's for the current user. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index de2aa9625c..c09538add7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -338,6 +338,12 @@ class SettingsRepositoryImpl( ) } + override var isAutofillWebDomainCompatMode: Boolean + get() = settingsDiskSource.isAutofillWebDomainCompatMode ?: false + set(value) { + settingsDiskSource.isAutofillWebDomainCompatMode = value + } + override var blockedAutofillUris: List get() = activeUserId ?.let { settingsDiskSource.getBlockedAutofillUris(userId = it) } 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 58ce700670..64c0232a7d 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 @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package com.x8bit.bitwarden.ui.platform.feature.settings.autofill import android.content.res.Resources @@ -30,6 +32,9 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -76,7 +81,7 @@ import kotlinx.collections.immutable.toImmutableList /** * Displays the auto-fill screen. */ -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun AutoFillScreen( @@ -132,6 +137,13 @@ fun AutoFillScreen( intentManager.launchUri("https://bitwarden.com/help/uri-match-detection/".toUri()) } + AutoFillEvent.NavigateToCompatibilityModeLearnMore -> { + intentManager.launchUri( + uri = "https://bitwarden.com/help/auto-fill-android/#compatibility-mode" + .toUri(), + ) + } + AutoFillEvent.NavigateToAutofillHelp -> { intentManager.launchUri( uri = "https://bitwarden.com/help/auto-fill-android-troubleshooting/".toUri(), @@ -243,6 +255,20 @@ private fun AutoFillScreenContent( } } + AnimatedVisibility(visible = state.isAutoFillServicesEnabled) { + Column { + WebDomainCompatibilityModeRow( + isChecked = state.isWebDomainCompatModeEnabled, + onToggle = autoFillHandlers.onWebDomainCompatModeToggled, + onLearnMoreClick = autoFillHandlers.onWebDomainModeCompatLearnMoreClick, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(height = 8.dp)) + } + } + if (state.showPasskeyManagementRow) { BitwardenExternalLinkRow( text = stringResource(id = BitwardenString.passkey_management), @@ -344,6 +370,76 @@ private fun AutoFillScreenContent( } } +@Suppress("LongMethod") +@Composable +private fun WebDomainCompatibilityModeRow( + isChecked: Boolean, + onToggle: (isEnabled: Boolean) -> Unit, + onLearnMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var showConfirmationDialog by rememberSaveable { mutableStateOf(false) } + if (showConfirmationDialog) { + BitwardenTwoButtonDialog( + title = stringResource(id = BitwardenString.warning), + message = stringResource(id = BitwardenString.compatibility_mode_warning), + confirmButtonText = stringResource(id = BitwardenString.accept), + dismissButtonText = stringResource(id = BitwardenString.cancel), + onConfirmClick = { + onToggle(true) + showConfirmationDialog = false + }, + onDismissClick = { showConfirmationDialog = false }, + onDismissRequest = { showConfirmationDialog = false }, + ) + } + + BitwardenSwitch( + label = stringResource(id = BitwardenString.use_compatibility_mode_for_browser_autofill), + isChecked = isChecked, + onCheckedChange = { + if (isChecked) { + onToggle(false) + } else { + showConfirmationDialog = true + } + }, + cardStyle = CardStyle.Full, + modifier = modifier, + supportingContent = { + val learnMore = stringResource(id = BitwardenString.learn_more) + Text( + text = annotatedStringResource( + id = BitwardenString + .use_a_less_secure_autofill_method_compatible_with_more_browsers, + style = spanStyleOf( + textStyle = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.secondary, + ), + onAnnotationClick = { + when (it) { + "learnMore" -> onLearnMoreClick() + } + }, + ), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.secondary, + modifier = Modifier.semantics { + customActions = listOf( + CustomAccessibilityAction( + label = learnMore, + action = { + onLearnMoreClick() + true + }, + ), + ) + }, + ) + }, + ) +} + @Composable private fun AutofillCallToActionCard( state: AutoFillState, 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 2861dbd3be..4321e99366 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 @@ -69,6 +69,7 @@ class AutoFillViewModel @Inject constructor( browserAutofillSettingsOptions = browserThirdPartyAutofillEnabledManager .browserThirdPartyAutofillStatus .toBrowserAutoFillSettingsOptions(), + isWebDomainCompatModeEnabled = settingsRepository.isAutofillWebDomainCompatMode, ) }, ) { @@ -134,6 +135,10 @@ class AutoFillViewModel @Inject constructor( AutoFillAction.PrivilegedAppsClick -> handlePrivilegedAppsClick() AutoFillAction.LearnMoreClick -> handleLearnMoreClick() AutoFillAction.HelpCardClick -> handleHelpCardClick() + is AutoFillAction.WebDomainModeCompatToggle -> handleWebDomainModeCompatToggle(action) + AutoFillAction.WebDomainModeCompatLearnMoreClick -> { + handleNavigateToCompatibilityModeLearnMore() + } } private fun handlePrivilegedAppsClick() { @@ -148,6 +153,15 @@ class AutoFillViewModel @Inject constructor( sendEvent(AutoFillEvent.NavigateToAutofillHelp) } + private fun handleNavigateToCompatibilityModeLearnMore() { + sendEvent(AutoFillEvent.NavigateToCompatibilityModeLearnMore) + } + + private fun handleWebDomainModeCompatToggle(action: AutoFillAction.WebDomainModeCompatToggle) { + settingsRepository.isAutofillWebDomainCompatMode = action.isEnabled + mutableStateFlow.update { it.copy(isWebDomainCompatModeEnabled = action.isEnabled) } + } + private fun handleInternalAction(action: AutoFillAction.Internal) { when (action) { is AutoFillAction.Internal.AccessibilityEnabledUpdateReceive -> { @@ -305,6 +319,7 @@ data class AutoFillState( val showBrowserAutofillActionCard: Boolean, val activeUserId: String, val browserAutofillSettingsOptions: ImmutableList, + val isWebDomainCompatModeEnabled: Boolean, ) : Parcelable { /** * Indicates which call-to-action that should be displayed. @@ -418,6 +433,11 @@ sealed class AutoFillEvent { */ data object NavigateToLearnMore : AutoFillEvent() + /** + * Navigate to the web domain learn more site. + */ + data object NavigateToCompatibilityModeLearnMore : AutoFillEvent() + /** * Navigate to the autofill help page. */ @@ -528,6 +548,16 @@ sealed class AutoFillAction { */ data object HelpCardClick : AutoFillAction() + /** + * User has clicked to learn more about compatibility mode. + */ + data object WebDomainModeCompatLearnMoreClick : AutoFillAction() + + /** + * User has changed their compatibility setting. + */ + data class WebDomainModeCompatToggle(val isEnabled: Boolean) : 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 c305164006..4aedda5855 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 @@ -29,6 +29,8 @@ class AutoFillHandlers( val onBlockAutoFillClick: () -> Unit, val onLearnMoreClick: () -> Unit, val onHelpCardClick: () -> Unit, + val onWebDomainCompatModeToggled: (isEnabled: Boolean) -> Unit, + val onWebDomainModeCompatLearnMoreClick: () -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -84,6 +86,12 @@ class AutoFillHandlers( onBlockAutoFillClick = { viewModel.trySendAction(AutoFillAction.BlockAutoFillClick) }, onLearnMoreClick = { viewModel.trySendAction(AutoFillAction.LearnMoreClick) }, onHelpCardClick = { viewModel.trySendAction(AutoFillAction.HelpCardClick) }, + onWebDomainCompatModeToggled = { + viewModel.trySendAction(AutoFillAction.WebDomainModeCompatToggle(it)) + }, + onWebDomainModeCompatLearnMoreClick = { + viewModel.trySendAction(AutoFillAction.WebDomainModeCompatLearnMoreClick) + }, ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt index cd214fb8cd..dec35fa180 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt @@ -41,6 +41,7 @@ class AutofillParserTests { every { this@mockk.autofillId } returns cardAutofillId every { this@mockk.childCount } returns 0 every { this@mockk.idPackage } returns ID_PACKAGE + every { this@mockk.idEntry } returns null } private val loginAutofillHint = View.AUTOFILL_HINT_USERNAME private val loginAutofillId: AutofillId = mockk() @@ -49,6 +50,7 @@ class AutofillParserTests { every { this@mockk.autofillId } returns loginAutofillId every { this@mockk.childCount } returns 0 every { this@mockk.idPackage } returns ID_PACKAGE + every { this@mockk.idEntry } returns null } private val cardWindowNode: AssistStructure.WindowNode = mockk { every { this@mockk.rootViewNode } returns cardViewNode @@ -67,6 +69,7 @@ class AutofillParserTests { private val settingsRepository: SettingsRepository = mockk { every { isInlineAutofillEnabled } answers { mockIsInlineAutofillEnabled } every { blockedAutofillUris } returns emptyList() + every { isAutofillWebDomainCompatMode } returns false } private var mockIsInlineAutofillEnabled = true @@ -172,6 +175,153 @@ class AutofillParserTests { assertEquals(expected, actual) } + @Suppress("MaxLineLength") + @Test + fun `parse should return Fillable without website in AutofillView from url bar but compatibility mode is off`() { + // Setup + val packageName = "com.microsoft.emmx" + every { + any>().buildPackageNameOrNull(assistStructure) + } returns packageName + every { settingsRepository.isAutofillWebDomainCompatMode } returns false + every { assistStructure.windowNodeCount } returns 2 + // Override the idPackage to be Edge's package name. + every { loginViewNode.idPackage } returns packageName + every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode + val urlBarNode: AssistStructure.ViewNode = mockk { + every { autofillHints } returns emptyArray() + every { autofillId } returns null + every { childCount } returns 0 + every { idEntry } returns "url_bar" + every { idPackage } returns packageName + every { webDomain } returns "m.facebook.com" + every { webScheme } returns null + } + val urlBarWindowNode: AssistStructure.WindowNode = mockk { + every { this@mockk.rootViewNode } returns urlBarNode + } + every { assistStructure.getWindowNodeAt(1) } returns urlBarWindowNode + val loginAutofillView: AutofillView.Login = AutofillView.Login.Username( + data = AutofillView.Data( + autofillId = loginAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = null, + ), + ) + every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + val autofillPartition = AutofillPartition.Login( + views = listOf(loginAutofillView), + ) + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = packageName, + partition = autofillPartition, + uri = "androidapp://$packageName", + ) + + // Test + val actual = parser.parse( + autofillAppInfo = autofillAppInfo, + fillRequest = fillRequest, + ) + + // Verify + assertEquals(expected, actual) + verify(exactly = 1) { + fillRequest.getInlinePresentationSpecs( + autofillAppInfo = autofillAppInfo, + isInlineAutofillEnabled = true, + ) + fillRequest.getMaxInlineSuggestionsCount( + autofillAppInfo = autofillAppInfo, + isInlineAutofillEnabled = true, + ) + any>().buildPackageNameOrNull(assistStructure) + any().buildUriOrNull(packageName) + } + } + + @Suppress("MaxLineLength") + @Test + fun `parse should return Fillable with website in AutofillView from url bar but compatibility mode is on`() { + // Setup + val website = "https://m.facebook.com" + val packageName = "com.microsoft.emmx" + every { + any>().buildPackageNameOrNull(assistStructure) + } returns packageName + every { settingsRepository.isAutofillWebDomainCompatMode } returns true + every { assistStructure.windowNodeCount } returns 2 + // Override the idPackage to be Edge's package name. + every { loginViewNode.idPackage } returns packageName + every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode + val urlBarNode: AssistStructure.ViewNode = mockk { + every { autofillHints } returns emptyArray() + every { autofillId } returns null + every { childCount } returns 0 + every { idEntry } returns "url_bar" + every { idPackage } returns packageName + every { webDomain } returns "m.facebook.com" + every { webScheme } returns null + } + val urlBarWindowNode: AssistStructure.WindowNode = mockk { + every { this@mockk.rootViewNode } returns urlBarNode + } + every { assistStructure.getWindowNodeAt(1) } returns urlBarWindowNode + val loginAutofillView: AutofillView.Login.Username = AutofillView.Login.Username( + data = AutofillView.Data( + autofillId = loginAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = null, + ), + ) + every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + val autofillPartition = AutofillPartition.Login( + views = listOf( + loginAutofillView.copy(data = loginAutofillView.data.copy(website = website)), + ), + ) + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = packageName, + partition = autofillPartition, + uri = website, + ) + + // Test + val actual = parser.parse( + autofillAppInfo = autofillAppInfo, + fillRequest = fillRequest, + ) + + // Verify + assertEquals(expected, actual) + verify(exactly = 1) { + fillRequest.getInlinePresentationSpecs( + autofillAppInfo = autofillAppInfo, + isInlineAutofillEnabled = true, + ) + fillRequest.getMaxInlineSuggestionsCount( + autofillAppInfo = autofillAppInfo, + isInlineAutofillEnabled = true, + ) + any>().buildPackageNameOrNull(assistStructure) + any().buildUriOrNull(packageName) + } + } + @Test fun `parse should return Fillable when at least one node valid, ignores the invalid nodes`() { // Setup diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt index b1f2c48820..1f64e4efa4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt @@ -21,6 +21,7 @@ class ViewNodeTraversalDataExtensionsTest { autofillViews = emptyList(), idPackage = ID_PACKAGE, ignoreAutofillIds = emptyList(), + urlBarWebsites = emptyList(), ) // Test @@ -39,6 +40,7 @@ class ViewNodeTraversalDataExtensionsTest { autofillViews = emptyList(), idPackage = null, ignoreAutofillIds = emptyList(), + urlBarWebsites = emptyList(), ) val expected = "com.x8bit.bitwarden" every { windowNode.title } returns "com.x8bit.bitwarden/path.deeper.into.app" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt index be0815b31d..39655642dd 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -1257,6 +1257,23 @@ class SettingsDiskSourceTest { } } + @Test + fun `isAutofillWebDomainCompatMode should update SharedPreferences`() { + val autofillWebDomainCompatKey = "bwPreferencesStorage:autofillWebDomainCompatibility" + settingsDiskSource.isAutofillWebDomainCompatMode = true + assertTrue(fakeSharedPreferences.getBoolean(autofillWebDomainCompatKey, false)) + } + + @Test + fun `isAutofillWebDomainCompatMode should pull value from shared preferences`() { + val autofillWebDomainCompatKey = "bwPreferencesStorage:autofillWebDomainCompatibility" + fakeSharedPreferences.edit { + putBoolean(autofillWebDomainCompatKey, true) + } + + assertTrue(settingsDiskSource.isAutofillWebDomainCompatMode!!) + } + @Test fun `storeShowUnlockSettingBadge should update SharedPreferences`() { val mockUserId = "mockUserId" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt index 6db360b706..ec40a6f292 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt @@ -89,6 +89,7 @@ class FakeSettingsDiskSource : SettingsDiskSource { private var storedFlightRecorderData: FlightRecorderDataSet? = null private var storedIsDynamicColorsEnabled: Boolean? = null private var storedBrowserAutofillDialogReshowTime: Instant? = null + private var storedIsAutofillWebDomainCompatMode: Boolean? = null private val mutableShowAutoFillSettingBadgeFlowMap = mutableMapOf>() @@ -217,6 +218,12 @@ class FakeSettingsDiskSource : SettingsDiskSource { storedBrowserAutofillDialogReshowTime = value } + override var isAutofillWebDomainCompatMode: Boolean? + get() = storedIsAutofillWebDomainCompatMode + set(value) { + storedIsAutofillWebDomainCompatMode = value + } + override fun getAccountBiometricIntegrityValidity( userId: String, systemBioIntegrityState: String, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index ffbe67260f..15de4bac94 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -713,6 +713,19 @@ class SettingsRepositoryTest { assertFalse(fakeSettingsDiskSource.getAutofillSavePromptDisabled(userId = USER_ID)!!) } + @Test + fun `isAutofillWebDomainCompatMode should pull from and update SettingsDiskSource`() { + assertFalse(settingsRepository.isAutofillWebDomainCompatMode) + + // Updates to the disk source change the repository value. + fakeSettingsDiskSource.isAutofillWebDomainCompatMode = true + assertTrue(settingsRepository.isAutofillWebDomainCompatMode) + + // Updates to the repository change the disk source value + settingsRepository.isAutofillWebDomainCompatMode = false + assertFalse(fakeSettingsDiskSource.isAutofillWebDomainCompatMode!!) + } + @Test fun `blockedAutofillUris should pull from and update SettingsDiskSource`() { fakeAuthDiskSource.userState = MOCK_USER_STATE 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 a1a583c662..055147abf2 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 @@ -18,6 +18,7 @@ import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.platform.manager.util.startSystemAccessibilitySettingsActivity import com.bitwarden.ui.platform.manager.util.startSystemAutofillSettingsActivity import com.bitwarden.ui.util.assertNoDialogExists +import com.bitwarden.ui.util.performCustomAccessibilityAction import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest @@ -359,6 +360,7 @@ class AutoFillScreenTest : BitwardenComposeTest() { composeTestRule.onNode(isDialog()).assertDoesNotExist() composeTestRule .onNodeWithText("Passkey management") + .performScrollTo() .performClick() composeTestRule.onNode(isDialog()).assertExists() composeTestRule @@ -861,7 +863,89 @@ class AutoFillScreenTest : BitwardenComposeTest() { @Test fun `on NavigateToLearnMore should call launchUri`() { mutableEventFlow.tryEmit(AutoFillEvent.NavigateToLearnMore) - intentManager.launchUri("https://bitwarden.com/help/uri-match-detection/".toUri()) + verify(exactly = 1) { + intentManager.launchUri("https://bitwarden.com/help/uri-match-detection/".toUri()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on NavigateToCompatibilityModeLearnMore should launch the browser to the autofill help page`() { + mutableEventFlow.tryEmit(AutoFillEvent.NavigateToCompatibilityModeLearnMore) + verify(exactly = 1) { + intentManager.launchUri( + uri = "https://bitwarden.com/help/auto-fill-android/#compatibility-mode".toUri(), + ) + } + } + + @Test + fun `on web domain compatibility mode click should display confirmation dialog`() { + mutableStateFlow.update { it.copy(isAutoFillServicesEnabled = true) } + composeTestRule.assertNoDialogExists() + composeTestRule + .onNodeWithText(text = "Use compatibility mode for browser autofill") + .performScrollTo() + .performClick() + + composeTestRule + .onNodeWithText(text = "Warning") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `on web domain compatibility mode dialog Cancel click should close dialog`() { + mutableStateFlow.update { it.copy(isAutoFillServicesEnabled = true) } + composeTestRule.assertNoDialogExists() + composeTestRule + .onNodeWithText(text = "Use compatibility mode for browser autofill") + .performScrollTo() + .performClick() + + composeTestRule + .onNodeWithText(text = "Cancel") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule.assertNoDialogExists() + } + + @Suppress("MaxLineLength") + @Test + fun `on web domain compatibility mode dialog Accept click should close dialog and send event`() { + mutableStateFlow.update { it.copy(isAutoFillServicesEnabled = true) } + composeTestRule.assertNoDialogExists() + composeTestRule + .onNodeWithText(text = "Use compatibility mode for browser autofill") + .performScrollTo() + .performClick() + + composeTestRule + .onNodeWithText(text = "Accept") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(AutoFillAction.WebDomainModeCompatToggle(true)) + } + composeTestRule.assertNoDialogExists() + } + + @Test + fun `on learn more about compatibility mode should send WebDomainModeLearnMoreClick`() { + mutableStateFlow.update { it.copy(isAutoFillServicesEnabled = true) } + composeTestRule + .onNodeWithText( + text = "Uses a less secure autofill method compatible with more " + + "browsers.\nLearn more about compatibility mode", + ) + .performScrollTo() + .performCustomAccessibilityAction(label = "Learn more") + + verify(exactly = 1) { + viewModel.trySendAction(AutoFillAction.WebDomainModeCompatLearnMoreClick) + } } } @@ -878,4 +962,5 @@ private val DEFAULT_STATE: AutoFillState = AutoFillState( showBrowserAutofillActionCard = false, activeUserId = "activeUserId", browserAutofillSettingsOptions = persistentListOf(), + isWebDomainCompatModeEnabled = false, ) 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 4337edf33b..48f240dcf5 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 @@ -67,6 +67,8 @@ class AutoFillViewModelTest : BaseViewModelTest() { every { isAccessibilityEnabledStateFlow } returns mutableIsAccessibilityEnabledStateFlow every { isAutofillEnabledStateFlow } returns mutableIsAutofillEnabledStateFlow every { disableAutofill() } just runs + every { isAutofillWebDomainCompatMode = any() } just runs + every { isAutofillWebDomainCompatMode } returns false } @BeforeEach @@ -512,6 +514,43 @@ class AutoFillViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `when WebDomainModeLearnMoreClick action is handled NavigateToCompatibilityModeLearnMore event is sent`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AutoFillAction.WebDomainModeCompatLearnMoreClick) + assertEquals( + AutoFillEvent.NavigateToCompatibilityModeLearnMore, + awaitItem(), + ) + } + } + + @Test + fun `when WebDomainModeToggle action is handled settings repo and state is updated`() { + val viewModel = createViewModel() + + viewModel.trySendAction(AutoFillAction.WebDomainModeCompatToggle(isEnabled = true)) + assertEquals( + DEFAULT_STATE.copy(isWebDomainCompatModeEnabled = true), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { + settingsRepository.isAutofillWebDomainCompatMode = true + } + + viewModel.trySendAction(AutoFillAction.WebDomainModeCompatToggle(isEnabled = false)) + assertEquals( + DEFAULT_STATE.copy(isWebDomainCompatModeEnabled = false), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { + settingsRepository.isAutofillWebDomainCompatMode = false + } + } + private fun createViewModel( state: AutoFillState? = DEFAULT_STATE, ): AutoFillViewModel = AutoFillViewModel( @@ -536,6 +575,7 @@ private val DEFAULT_STATE: AutoFillState = AutoFillState( showBrowserAutofillActionCard = false, activeUserId = "activeUserId", browserAutofillSettingsOptions = persistentListOf(), + isWebDomainCompatModeEnabled = false, ) private val DEFAULT_BROWSER_AUTOFILL_DATA = BrowserThirdPartyAutoFillData( diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index d1dfee2d6f..94b0f28c9a 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1153,4 +1153,7 @@ Do you want to switch to this account? Lock app Use your device’s lock method to unlock the app Loading vault data… + Compatibility mode should only be enabled if autofill doesn’t work in your browser. This setting reduces security and could allow malicious sites to capture your passwords. Only enable it if you accept this risk. + Use compatibility mode for browser autofill + Uses a less secure autofill method compatible with more browsers.\nLearn more about compatibility mode