diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/BitwardenApplication.kt b/app/src/main/kotlin/com/x8bit/bitwarden/BitwardenApplication.kt index cce69972ab2..83df6cde99c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/BitwardenApplication.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/BitwardenApplication.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden import android.app.Application import com.bitwarden.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.platform.manager.LogsManager import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager @@ -20,6 +21,9 @@ import javax.inject.Inject class BitwardenApplication : Application() { // Inject classes here that must be triggered on startup but are not otherwise consumed by // other callers. + @Inject + lateinit var fillAssistManager: FillAssistManager + @Inject lateinit var logsManager: LogsManager diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt index b9937312231..cc06d2097ed 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt @@ -20,6 +20,7 @@ import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogMa import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManagerImpl import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManagerImpl +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.autofill.parser.AutofillParser import com.x8bit.bitwarden.data.autofill.parser.AutofillParserImpl import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor @@ -27,6 +28,7 @@ import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessorImpl import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProviderImpl import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager @@ -100,9 +102,13 @@ object AutofillModule { @Provides fun providesAutofillParser( settingsRepository: SettingsRepository, + fillAssistManager: FillAssistManager, + featureFlagManager: FeatureFlagManager, ): AutofillParser = AutofillParserImpl( settingsRepository = settingsRepository, + fillAssistManager = fillAssistManager, + featureFlagManager = featureFlagManager, ) @Singleton diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt index bbd2bf506b2..7516199c075 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt @@ -43,6 +43,11 @@ private val ID_SHORTHAND_REGEX = Regex("""#([^.\[#\s]+)""") // Extracts the leading tag name from a selector (e.g. "input", "select", "form"). private val TAG_REGEX = Regex("""^([a-zA-Z][a-zA-Z0-9]*)""") +// Splits on whitespace that is outside [...] attribute brackets, so that descendant selectors +// like "div#container input#field" are split correctly while attribute values containing spaces +// (e.g. [placeholder='Email address']) are preserved intact. +private val DESCENDANT_SEPARATOR_REGEX = Regex("""\s+(?![^\[]*])""") + /** * Primary implementation of [FillAssistManager]. */ @@ -207,13 +212,13 @@ private fun parseCompositeSelectorArray(element: JsonElement): List>>), extract the last segment — the actual target - // element. Android's autofill framework may expose these elements via htmlInfo when they - // are reachable (e.g. open shadow roots), so we parse their attributes for matching. - val effective = if (selector.contains(">>>")) { - selector.substringAfterLast(">>>").trim() - } else { - selector + // For descendant selectors, only the last segment describes the target element — earlier + // parts describe ancestors that are not represented as view nodes by the autofill framework. + // Shadow DOM (>>>) and space-separated CSS descendant selectors are both handled this way. + // Whitespace inside [...] is part of an attribute value and must not be treated as a separator. + val effective = when { + selector.contains(">>>") -> selector.substringAfterLast(">>>").trim() + else -> selector.split(DESCENDANT_SEPARATOR_REGEX).last().trim() } if (effective.trimStart().startsWith(".")) return null 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 61f54b71e6a..7fa53c03b93 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 @@ -3,17 +3,22 @@ package com.x8bit.bitwarden.data.autofill.parser import android.app.assist.AssistStructure import android.service.autofill.FillRequest import android.view.autofill.AutofillId +import androidx.core.net.toUri +import com.bitwarden.core.data.manager.model.FlagKey +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillView import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData +import com.x8bit.bitwarden.data.autofill.util.buildFillAssistViews import com.x8bit.bitwarden.data.autofill.util.buildPackageNameOrNull import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount import com.x8bit.bitwarden.data.autofill.util.toAutofillView import com.x8bit.bitwarden.data.autofill.util.website +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import timber.log.Timber @@ -50,12 +55,30 @@ private val URL_BARS: Map = mapOf( "com.brave.browser_nightly" to "url_bar", ) +/** + * A list of categories from Fill Assist that are used for [AutofillView.Login] + */ +private val LOGIN_FILL_ASSIST_CATEGORIES: List = listOf( + "account-login", + "account-creation", + "account-update", +) + +/** + * A list of categories from Fill Assist that are used for [AutofillView.Card] + */ +private val CARD_FILL_ASSIST_CATEGORIES: List = listOf( + "payment-card", +) + /** * The default [AutofillParser] implementation for the app. This is a tool for parsing autofill data * from the OS into domain models. */ class AutofillParserImpl( private val settingsRepository: SettingsRepository, + private val fillAssistManager: FillAssistManager, + private val featureFlagManager: FeatureFlagManager, ) : AutofillParser { override fun parse( autofillAppInfo: AutofillAppInfo, @@ -100,56 +123,44 @@ class AutofillParserImpl( val urlBarWebsite = traversalDataList .flatMap { it.urlBarWebsites } .firstOrNull() + val autofillViews = traversalDataList.toAutofillViews(urlBarWebsite = urlBarWebsite) - // 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 - .filter { views -> views.any { it.data.isFocused } } - .flatten() - .filter { it !is AutofillView.Unused } - .takeUnless { it.isEmpty() } - ?: autofillViewsList - .flatten() - .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) - val focusedView = autofillViews - .firstOrNull { it.data.isFocused } + val focusedView = autofillViews.firstOrNull { it.data.isFocused } ?: autofillViews.firstOrNull() + ?: return AutofillRequest.Unfillable + + val packageName = + traversalDataList.buildPackageNameOrNull(assistStructure = assistStructure) + val uri = focusedView.buildUriOrNull(packageName = packageName) - if (focusedView == null) { - // The view is unfillable if there are no focused views. + if ((settingsRepository.blockedAutofillUris + BLOCK_LISTED_URIS).contains(uri)) { return AutofillRequest.Unfillable } - val packageName = traversalDataList.buildPackageNameOrNull( + val effectiveViews = resolveEffectiveViews( assistStructure = assistStructure, - ) - val uri = focusedView.buildUriOrNull( - packageName = packageName, + autofillViews = autofillViews, + uri = uri, + focusedView = focusedView, + urlBarWebsite = urlBarWebsite, ) - val blockListedURIs = settingsRepository.blockedAutofillUris + BLOCK_LISTED_URIS - if (blockListedURIs.contains(uri)) { - // The view is unfillable if the URI is block listed. - return AutofillRequest.Unfillable - } + val effectiveFocusedView = effectiveViews + .firstOrNull { it.data.isFocused } + ?: effectiveViews.firstOrNull() + ?: return AutofillRequest.Unfillable // Choose the first focused partition of data for fulfillment. - val partition = when (focusedView) { + val partition = when (effectiveFocusedView) { is AutofillView.Card -> { AutofillPartition.Card( - views = autofillViews.filterIsInstance(), + views = effectiveViews.filterIsInstance(), ) } is AutofillView.Login -> { AutofillPartition.Login( - views = autofillViews.filterIsInstance(), + views = effectiveViews.filterIsInstance(), ) } @@ -166,7 +177,6 @@ class AutofillParserImpl( // Get inline information if available val isInlineAutofillEnabled = settingsRepository.isInlineAutofillEnabled - Timber.d("Autofill request isInlineEnabled=$isInlineAutofillEnabled -- ${fillRequest?.id}") val maxInlineSuggestionsCount = fillRequest.getMaxInlineSuggestionsCount( autofillAppInfo = autofillAppInfo, isInlineAutofillEnabled = isInlineAutofillEnabled, @@ -185,6 +195,46 @@ class AutofillParserImpl( uri = uri, ) } + + /** + * Returns the effective [AutofillView] list for filling. Applies fill-assist targeting rules + * when the feature flag is enabled and the host rules cover the current partition type; + * otherwise returns the heuristic [autofillViews]. + */ + private fun resolveEffectiveViews( + assistStructure: AssistStructure, + autofillViews: List, + uri: String?, + focusedView: AutofillView, + urlBarWebsite: String?, + ): List { + val hostRules = uri + ?.takeUnless { it.startsWith("androidapp://") } + ?.toUri() + ?.host + ?.takeIf { featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules) } + ?.let { host -> + fillAssistManager.getFillAssistRules()?.hostRules?.get(host.removePrefix("www.")) + } + ?: return autofillViews + + val coversCurrentPartition = hostRules.any { rule -> + when (focusedView) { + is AutofillView.Card -> rule.category in CARD_FILL_ASSIST_CATEGORIES + is AutofillView.Login -> rule.category in LOGIN_FILL_ASSIST_CATEGORIES + is AutofillView.Unused -> false + } + } + + return if (coversCurrentPartition) { + assistStructure.buildFillAssistViews( + hostRules = hostRules, + urlBarWebsite = urlBarWebsite, + ) + } else { + autofillViews + } + } } /** @@ -201,6 +251,27 @@ private fun AssistStructure.traverse(): List = ?.updateForMissingUsernameFields() } +/** + * Assembles the [AutofillView] list from this [ViewNodeTraversalData] list. + * Take only the autofill views from the node that currently has focus. + * Then remove all the fields that cannot be filled with data. + * We fall back to taking all the fillable views if nothing has focus. + */ +private fun List.toAutofillViews( + urlBarWebsite: String?, +): List { + val viewsLists = map { it.autofillViews } + return (viewsLists + .filter { views -> views.any { it.data.isFocused } } + .flatten() + .filter { it !is AutofillView.Unused } + .takeUnless { it.isEmpty() } + ?: viewsLists + .flatten() + .filter { it !is AutofillView.Unused }) + .map { it.updateWebsiteIfNecessary(website = urlBarWebsite) } +} + /** * This helper function updates the [ViewNodeTraversalData] if necessary for missing password * fields that were marked invalid because they contained a specific `hint` or `idEntry`. If the diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt new file mode 100644 index 00000000000..109c676f3c8 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt @@ -0,0 +1,65 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.app.assist.AssistStructure +import com.x8bit.bitwarden.data.autofill.model.AutofillView +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules + +/** + * Traverses the [AssistStructure] and returns a list of [AutofillView]s classified by the + * provided [hostRules]. Only view nodes whose [android.view.ViewStructure.HtmlInfo] attributes + * match a [FillAssistRules.SelectorClause] are included; unmatched nodes are omitted (no + * heuristic fallback). + */ +internal fun AssistStructure.buildFillAssistViews( + hostRules: List, + urlBarWebsite: String?, +): List = + (0 until windowNodeCount) + .mapNotNull { getWindowNodeAt(it).rootViewNode } + .flatMap { it.traverseForFillAssist(hostRules = hostRules, parentWebsite = urlBarWebsite) } + +private fun AssistStructure.ViewNode.traverseForFillAssist( + hostRules: List, + parentWebsite: String?, +): List { + val website = this.website ?: parentWebsite + val ownView = autofillId?.let { id -> + hostRules + .flatMap { it.fields.entries } + .filter { (_, alternatives) -> + alternatives.any { + htmlInfo?.matchesSelectorClause(it) ?: false + } + } + .takeIf { it.isNotEmpty() } + ?.let { matchingEntries -> + val data = toAutofillViewData(autofillId = id, website = website) + matchingEntries.firstNotNullOfOrNull { (key, _) -> + key.toAutofillViewForFieldKey( + data, + ) + } + } + } + val childViews = (0 until childCount) + .flatMap { index -> + getChildAt(index).traverseForFillAssist( + hostRules = hostRules, + parentWebsite = website, + ) + } + return listOfNotNull(ownView) + childViews +} + +private fun String.toAutofillViewForFieldKey(data: AutofillView.Data): AutofillView? = when (this) { + "username" -> AutofillView.Login.Username(data = data) + "password", "newPassword" -> AutofillView.Login.Password(data = data) + "cardNumber" -> AutofillView.Card.Number(data = data) + "cardholderName" -> AutofillView.Card.CardholderName(data = data) + "cardExpirationDate" -> AutofillView.Card.ExpirationDate(data = data) + "cardExpirationMonth" -> AutofillView.Card.ExpirationMonth(data = data, monthValue = null) + "cardExpirationYear" -> AutofillView.Card.ExpirationYear(data = data, yearValue = null) + "cardCvv" -> AutofillView.Card.SecurityCode(data = data) + "cardType" -> AutofillView.Card.Brand(data = data, brandValue = null) + else -> null +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt index 918d96bb63e..35275259270 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.autofill.util import android.view.ViewStructure.HtmlInfo +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules /** * Whether this [HtmlInfo] represents a password field. @@ -93,6 +94,29 @@ fun HtmlInfo?.hints(): List = this */ val HtmlInfo?.isInputField: Boolean get() = this?.tag == "input" +/** + * Whether this [HtmlInfo] matches the given [SelectorClause]. + * + * This function is untestable as [HtmlInfo] contains [android.util.Pair] which requires + * instrumentation testing. + */ +internal fun HtmlInfo.matchesSelectorClause(clause: FillAssistRules.SelectorClause): Boolean { + if (clause.tag != null && clause.tag != tag) return false + val attrs = attributes + ?: return clause.id == null && + clause.name == null && + clause.type == null && + clause.role == null + fun hasAttr(key: String, value: String) = attrs.any { it.first == key && it.second == value } + return listOf( + clause.id to "id", + clause.name to "name", + clause.type to "type", + clause.role to "role", + ) + .all { (value, key) -> value == null || hasAttr(key, value) } +} + /** * Checks if the list of strings contains any of the specified patterns. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt index 5d80e86fc8f..127d92db025 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.autofill.util import android.app.assist.AssistStructure import android.view.View +import android.view.autofill.AutofillId import android.widget.EditText import androidx.annotation.VisibleForTesting import com.bitwarden.ui.platform.base.util.orNullIfBlank @@ -74,6 +75,23 @@ fun AssistStructure.ViewNode.toAutofillView( ) } +/** + * Builds an [AutofillView.Data] for this [AssistStructure.ViewNode] using the given [autofillId] + * and [website]. + */ +internal fun AssistStructure.ViewNode.toAutofillViewData( + autofillId: AutofillId, + website: String?, +): AutofillView.Data = AutofillView.Data( + autofillId = autofillId, + autofillOptions = autofillOptions?.map { it.toString() }.orEmpty(), + autofillType = autofillType, + isFocused = isFocused, + textValue = autofillValue?.extractTextValue(), + hasPasswordTerms = hasPasswordTerms(), + website = website, +) + /** * The first supported autofill hint for this view node, or null if none are found. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt index 3a343e4b226..11816466861 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt @@ -15,6 +15,7 @@ import com.bitwarden.vault.DecryptCipherListResult import com.bitwarden.vault.FolderView import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.auth.manager.UserStateManager import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState @@ -80,6 +81,7 @@ class VaultSyncManagerImpl( private val authDiskSource: AuthDiskSource, private val vaultDiskSource: VaultDiskSource, private val vaultSdkSource: VaultSdkSource, + private val fillAssistManager: FillAssistManager, private val userLogoutManager: UserLogoutManager, private val userStateManager: UserStateManager, private val vaultLockManager: VaultLockManager, @@ -342,6 +344,7 @@ class VaultSyncManagerImpl( lastSyncTime = clock.instant(), ) vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse) + fillAssistManager.syncIfNecessary() val itemsAvailable = syncResponse.ciphers?.isNotEmpty() == true SyncVaultDataResult.Success(itemsAvailable = itemsAvailable) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index 31ef577c1e7..11ece6832fa 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -44,6 +44,7 @@ import com.x8bit.bitwarden.data.vault.manager.VaultLockManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManagerImpl +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -224,6 +225,7 @@ object VaultManagerModule { @Provides @Singleton fun provideVaultSyncManager( + fillAssistManager: FillAssistManager, syncService: SyncService, settingsDiskSource: SettingsDiskSource, authDiskSource: AuthDiskSource, @@ -237,6 +239,7 @@ object VaultManagerModule { pushManager: PushManager, dispatcherManager: DispatcherManager, ): VaultSyncManager = VaultSyncManagerImpl( + fillAssistManager = fillAssistManager, syncService = syncService, settingsDiskSource = settingsDiskSource, authDiskSource = authDiskSource, 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 e23a4faa0b0..e0bd36448d2 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 @@ -1,28 +1,38 @@ package com.x8bit.bitwarden.data.autofill.parser import android.app.assist.AssistStructure +import android.net.Uri +import android.net.Uri.parse import android.service.autofill.FillContext import android.service.autofill.FillRequest import android.view.View +import android.view.ViewStructure.HtmlInfo import android.view.autofill.AutofillId import android.widget.inline.InlinePresentationSpec +import com.bitwarden.core.data.manager.model.FlagKey +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillView +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData import com.x8bit.bitwarden.data.autofill.util.buildPackageNameOrNull import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount +import com.x8bit.bitwarden.data.autofill.util.matchesSelectorClause import com.x8bit.bitwarden.data.autofill.util.toAutofillView +import com.x8bit.bitwarden.data.autofill.util.toAutofillViewData import com.x8bit.bitwarden.data.autofill.util.website +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach @@ -40,6 +50,7 @@ class AutofillParserTests { every { this@mockk.autofillHints } returns arrayOf(cardAutofillHint) every { this@mockk.autofillId } returns cardAutofillId every { this@mockk.childCount } returns 0 + every { this@mockk.htmlInfo } returns mockk(relaxed = true) every { this@mockk.idPackage } returns ID_PACKAGE every { this@mockk.idEntry } returns null } @@ -49,6 +60,7 @@ class AutofillParserTests { every { this@mockk.autofillHints } returns arrayOf(loginAutofillHint) every { this@mockk.autofillId } returns loginAutofillId every { this@mockk.childCount } returns 0 + every { this@mockk.htmlInfo } returns mockk(relaxed = true) every { this@mockk.idPackage } returns ID_PACKAGE every { this@mockk.idEntry } returns null } @@ -70,13 +82,40 @@ class AutofillParserTests { every { isInlineAutofillEnabled } answers { mockIsInlineAutofillEnabled } every { blockedAutofillUris } returns emptyList() } + private val fillAssistManager: FillAssistManager = mockk() + private val mutableFillAssistFlagFlow = MutableStateFlow(false) + private val featureFlagManager: FeatureFlagManager = mockk { + every { + getFeatureFlag(FlagKey.FillAssistTargetingRules) + } answers { + mutableFillAssistFlagFlow.value + } + every { getFeatureFlagFlow(FlagKey.ManageDevices) } returns mutableFillAssistFlagFlow + } private var mockIsInlineAutofillEnabled = true + private var mockIsFillAssistEnabled = false @BeforeEach fun setup() { + mockIsFillAssistEnabled = false + // toAutofillView, website, and toAutofillViewData all compile into the same + // ViewNodeExtensionsKt class, so one mockkStatic call covers all three. mockkStatic(AssistStructure.ViewNode::toAutofillView) - mockkStatic(AssistStructure.ViewNode::website) + // Default stub for toAutofillViewData (same mocked class — no separate mockkStatic needed). + every { + any().toAutofillViewData(autofillId = any(), website = any()) + } answers { + AutofillView.Data( + autofillId = firstArg(), + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = false, + textValue = null, + hasPasswordTerms = false, + website = secondArg(), + ) + } mockkStatic( FillRequest::getMaxInlineSuggestionsCount, FillRequest::getInlinePresentationSpecs, @@ -127,13 +166,23 @@ class AutofillParserTests { every { any().buildUriOrNull(PACKAGE_NAME) } returns URI parser = AutofillParserImpl( settingsRepository = settingsRepository, + fillAssistManager = fillAssistManager, + featureFlagManager = featureFlagManager, ) + + mockkStatic(Uri::parse) + every { parse(any()) } returns mockk { + every { host } returns FILL_ASSIST_URI + } + mockkStatic(HtmlInfo::matchesSelectorClause) + every { any().matchesSelectorClause(any()) } returns false } @AfterEach fun teardown() { unmockkStatic(AssistStructure.ViewNode::toAutofillView) - unmockkStatic(AssistStructure.ViewNode::website) + unmockkStatic(Uri::parse) + unmockkStatic(HtmlInfo::matchesSelectorClause) unmockkStatic( FillRequest::getMaxInlineSuggestionsCount, FillRequest::getInlinePresentationSpecs, @@ -545,10 +594,9 @@ class AutofillParserTests { val rootViewNode: AssistStructure.ViewNode = mockk { every { this@mockk.autofillHints } returns emptyArray() every { this@mockk.autofillId } returns rootAutofillId - every { this@mockk.childCount } returns 0 + every { this@mockk.childCount } returns 2 every { this@mockk.idPackage } returns ID_PACKAGE every { this@mockk.website } returns WEBSITE - every { this@mockk.childCount } returns 2 every { this@mockk.getChildAt(0) } returns hiddenUserNameViewNode every { this@mockk.getChildAt(1) } returns passwordViewNode } @@ -947,6 +995,273 @@ class AutofillParserTests { } } + @Suppress("MaxLineLength") + @Test + fun `parse should fall back to heuristics when fill-assist rules exist but only cover login and a card view is focused`() { + // Setup: fill-assist enabled with login-only rules, but a card view is focused. + mutableFillAssistFlagFlow.value = true + mockIsFillAssistEnabled = true + every { + any().buildUriOrNull(PACKAGE_NAME) + } returns FILL_ASSIST_URI + every { fillAssistManager.getFillAssistRules() } returns FillAssistRules( + hostRules = mapOf( + FILL_ASSIST_URI to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), + ) + every { assistStructure.windowNodeCount } returns 1 + every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode + val cardAutofillView = AutofillView.Card.ExpirationYear( + data = AutofillView.Data( + autofillId = cardAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = FILL_ASSIST_URI, + ), + yearValue = null, + ) + every { cardViewNode.toAutofillView(parentWebsite = any()) } returns cardAutofillView + + // Test + val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) + + // Verify: heuristic card view used + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = PACKAGE_NAME, + partition = AutofillPartition.Card(views = listOf(cardAutofillView)), + uri = FILL_ASSIST_URI, + ) + assertEquals(expected, actual) + } + + @Suppress("MaxLineLength") + @Test + fun `parse should fall back to heuristics when fill-assist rules exist but only cover payment-card and a login view is focused`() { + // Setup: fill-assist enabled with card-only rules, but a login view is focused. + mutableFillAssistFlagFlow.value = true + mockIsFillAssistEnabled = true + every { any().buildUriOrNull(PACKAGE_NAME) } returns FILL_ASSIST_URI + every { fillAssistManager.getFillAssistRules() } returns FillAssistRules( + hostRules = mapOf( + FILL_ASSIST_URI to listOf( + FillAssistRules.HostRule( + category = "payment-card", + fields = mapOf( + "cardNumber" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "card-number", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), + ) + every { assistStructure.windowNodeCount } returns 1 + every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode + val loginAutofillView = AutofillView.Login.Username( + data = AutofillView.Data( + autofillId = loginAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = FILL_ASSIST_URI, + ), + ) + every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + + // Test + val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) + + // Verify: heuristic login view used + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = PACKAGE_NAME, + partition = AutofillPartition.Login(views = listOf(loginAutofillView)), + uri = FILL_ASSIST_URI, + ) + assertEquals(expected, actual) + } + + @Suppress("MaxLineLength") + @Test + fun `parse should use fill-assist views when rules cover login and a login view is focused`() { + // Setup: fill-assist with login rules, login view focused. + // The heuristic and fill-assist paths produce views with DIFFERENT autofillIds so the + // assertion proves which path was actually taken. If heuristics are used the partition + // contains loginAutofillId; if fill-assist is used it contains fillAssistAutofillId. + mutableFillAssistFlagFlow.value = true + mockIsFillAssistEnabled = true + every { any().buildUriOrNull(PACKAGE_NAME) } returns FILL_ASSIST_URI + every { fillAssistManager.getFillAssistRules() } returns FillAssistRules( + hostRules = mapOf( + FILL_ASSIST_URI to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), + ) + val heuristicLoginView = AutofillView.Login.Username( + data = AutofillView.Data( + autofillId = loginAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = FILL_ASSIST_URI, + ), + ) + val fillAssistAutofillId: AutofillId = mockk() + val fillAssistLoginData = AutofillView.Data( + autofillId = fillAssistAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = WEBSITE, + ) + every { any().matchesSelectorClause(any()) } returns true + every { + loginViewNode.toAutofillViewData(autofillId = loginAutofillId, website = WEBSITE) + } returns fillAssistLoginData + every { assistStructure.windowNodeCount } returns 1 + every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode + every { loginViewNode.toAutofillView(parentWebsite = any()) } returns heuristicLoginView + + // Test + val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) + + // Verify: fill-assist views used — partition contains fillAssistAutofillId. + // Heuristics would have produced loginAutofillId + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = PACKAGE_NAME, + partition = AutofillPartition.Login( + views = listOf(AutofillView.Login.Username(data = fillAssistLoginData)), + ), + uri = FILL_ASSIST_URI, + ) + assertEquals(expected, actual) + } + + @Suppress("MaxLineLength") + @Test + fun `parse should use fill-assist when rules cover payment-card and a card view is focused`() { + mutableFillAssistFlagFlow.value = true + mockIsFillAssistEnabled = true + every { any().buildUriOrNull(PACKAGE_NAME) } returns FILL_ASSIST_URI + every { fillAssistManager.getFillAssistRules() } returns FillAssistRules( + hostRules = mapOf( + FILL_ASSIST_URI to listOf( + FillAssistRules.HostRule( + category = "payment-card", + fields = mapOf( + "cardNumber" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "card-number", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), + ) + val heuristicCardView = AutofillView.Card.ExpirationYear( + data = AutofillView.Data( + autofillId = cardAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = FILL_ASSIST_URI, + ), + yearValue = null, + ) + val fillAssistAutofillId: AutofillId = mockk() + val fillAssistCardData = AutofillView.Data( + autofillId = fillAssistAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = WEBSITE, + ) + every { any().matchesSelectorClause(any()) } returns true + every { + cardViewNode.toAutofillViewData(autofillId = cardAutofillId, website = WEBSITE) + } returns fillAssistCardData + every { assistStructure.windowNodeCount } returns 1 + every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode + every { cardViewNode.toAutofillView(parentWebsite = any()) } returns heuristicCardView + + // Test + val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) + + // Verify: fill-assist views used — partition contains fillAssistAutofillId. + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = PACKAGE_NAME, + partition = AutofillPartition.Card( + views = listOf(AutofillView.Card.Number(data = fillAssistCardData)), + ), + uri = FILL_ASSIST_URI, + ) + assertEquals(expected, actual) + } + /** * Setup [assistStructure] to return window nodes with each [AutofillView] type (card and login) * so we can test how different window node configurations produce different partitions. @@ -958,6 +1273,8 @@ class AutofillParserTests { } } +private const val FILL_ASSIST_URI: String = "https://example.com" + private val BLOCK_LISTED_URIS: List = listOf( "androidapp://android", "androidapp://com.android.settings", diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt new file mode 100644 index 00000000000..ed4a08c74c2 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt @@ -0,0 +1,609 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.app.assist.AssistStructure +import android.view.View +import android.view.ViewStructure.HtmlInfo +import android.view.autofill.AutofillId +import com.x8bit.bitwarden.data.autofill.model.AutofillView +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@Suppress("LargeClass") +class FillAssistViewNodeExtensionsTest { + + private val autofillId: AutofillId = mockk() + + @BeforeEach + fun setup() { + mockkStatic(AssistStructure.ViewNode::toAutofillViewData) + mockkStatic(HtmlInfo::matchesSelectorClause) + } + + @AfterEach + fun teardown() { + unmockkStatic(AssistStructure.ViewNode::toAutofillViewData) + unmockkStatic(HtmlInfo::matchesSelectorClause) + } + + @Test + fun `buildFillAssistViews should return empty list when there are no window nodes`() { + val assistStructure: AssistStructure = mockk { + every { windowNodeCount } returns 0 + } + + val actual = assistStructure.buildFillAssistViews( + hostRules = emptyList(), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Test + fun `buildFillAssistViews should exclude node with null htmlInfo`() { + val viewNode = createViewNode(htmlInfo = null) + val assistStructure = createAssistStructure(viewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should return Login Username when htmlInfo matches username clause`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should return Login Password when htmlInfo matches password clause`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "password" to listOf(selectorClause(tag = "input", id = "pass")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Password(data = data)), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should return Login Password when htmlInfo matches newPassword clause`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-registration", + fields = mapOf( + "newPassword" to listOf(selectorClause(tag = "input", id = "new-pass")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Password(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should map all card field keys to correct AutofillView subtypes`() { + val cardFieldExpectations: List> = listOf( + "cardNumber" to AutofillView.Card.Number(data = autofillData()), + "cardholderName" to AutofillView.Card.CardholderName(data = autofillData()), + "cardExpirationDate" to AutofillView.Card.ExpirationDate(data = autofillData()), + "cardExpirationMonth" to AutofillView.Card.ExpirationMonth( + data = autofillData(), + monthValue = null, + ), + "cardExpirationYear" to AutofillView.Card.ExpirationYear( + data = autofillData(), + yearValue = null, + ), + "cardCvv" to AutofillView.Card.SecurityCode(data = autofillData()), + "cardType" to AutofillView.Card.Brand(data = autofillData(), brandValue = null), + ) + + cardFieldExpectations.forEach { (fieldKey, expectedView) -> + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { + viewNode.toAutofillViewData(autofillId = autofillId, website = null) + } returns data + + val hostRule = FillAssistRules.HostRule( + category = "payment-card", + fields = mapOf( + fieldKey to listOf(selectorClause(tag = "input", id = "field-$fieldKey")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals( + listOf(expectedView), + actual, + "Failed for field key: $fieldKey", + ) + } + } + + @Test + fun `buildFillAssistViews should exclude node whose matched field key is unknown`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + every { + viewNode.toAutofillViewData(autofillId = autofillId, website = null) + } returns autofillData() + + val hostRule = FillAssistRules.HostRule( + category = "unknown", + fields = mapOf( + "unknownFieldKey" to listOf(selectorClause(tag = "input", id = "mystery")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should pick first mapped key when multiple keys match the same node`() { + // Two field keys ("email" - unknown - and "username") both match the same node. The + // implementation iterates in insertion order and selects the first key whose mapping is + // non-null. Since "email" is unknown, "username" wins; this also demonstrates that an + // earlier known key wins over a later one. + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = linkedMapOf( + "email" to listOf(selectorClause(tag = "input", id = "shared")), + "username" to listOf(selectorClause(tag = "input", id = "shared")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should traverse child nodes recursively`() { + val childHtmlInfo = createHtmlInfo() + val childViewNode = createViewNode(htmlInfo = childHtmlInfo) + val childData = autofillData() + every { + childViewNode.toAutofillViewData(autofillId = autofillId, website = null) + } returns childData + + val rootHtmlInfo = createHtmlInfo(matches = false) + val rootViewNode = createViewNode( + htmlInfo = rootHtmlInfo, + children = listOf(childViewNode), + ) + + val assistStructure = createAssistStructure(rootViewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = childData)), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should propagate urlBarWebsite as website when node has no website`() { + val urlBarWebsite = "https://example.com" + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo, website = null) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData(website = urlBarWebsite) + every { + viewNode.toAutofillViewData(autofillId = autofillId, website = urlBarWebsite) + } returns data + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = urlBarWebsite, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should prefer node's own website over urlBarWebsite`() { + val nodeWebsite = "https://node.example.com" + val urlBarWebsite = "https://urlbar.example.com" + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo, website = nodeWebsite) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData(website = nodeWebsite) + every { + viewNode.toAutofillViewData(autofillId = autofillId, website = nodeWebsite) + } returns data + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = urlBarWebsite, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should exclude node with no autofillId`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo, autofillId = null) + val assistStructure = createAssistStructure(viewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Test + fun `buildFillAssistViews should not match when htmlInfo tag differs from clause tag`() { + val htmlInfo = createHtmlInfo(matches = false) + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should match when htmlInfo has no attributes and all clause attrs are null`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = null, + name = null, + type = null, + role = null, + ), + ), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should not match when htmlInfo has no attributes but clause requires id`() { + val htmlInfo = createHtmlInfo(matches = false) + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Test + fun `buildFillAssistViews should match when htmlInfo has correct id attribute`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should not match when htmlInfo id attribute is wrong`() { + val htmlInfo = createHtmlInfo(matches = false) + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should require all non-null clause attributes to match (AND logic)`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = "username", + type = "text", + role = "textbox", + ), + ), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should not match when one of multiple required attributes is wrong`() { + val htmlInfo = createHtmlInfo(matches = false) + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = "username", + type = "text", + role = "textbox", + ), + ), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Test + fun `buildFillAssistViews should skip null clause attributes (not required)`() { + // Clause only requires tag + id; node has extra attributes which should be ignored. + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should match when clause has null tag (tag check skipped)`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = null, + id = "user", + name = null, + type = null, + role = null, + ), + ), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should return views from all children when only some match`() { + val matchingChildHtmlInfo = createHtmlInfo() + val matchingChild = createViewNode(htmlInfo = matchingChildHtmlInfo) + val matchingChildData = autofillData() + every { + matchingChild.toAutofillViewData(autofillId = autofillId, website = null) + } returns matchingChildData + + val nonMatchingChildHtmlInfo = createHtmlInfo(matches = false) + val nonMatchingChild = createViewNode(htmlInfo = nonMatchingChildHtmlInfo) + + val rootHtmlInfo = createHtmlInfo(matches = false) + val rootViewNode = createViewNode( + htmlInfo = rootHtmlInfo, + children = listOf(matchingChild, nonMatchingChild), + ) + + val assistStructure = createAssistStructure(rootViewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals( + listOf( + AutofillView.Login.Username( + data = matchingChildData, + ), + ), + actual, + ) + // Sanity check that traversal visited multiple children. + assertTrue(actual.size == 1) + } + + private fun autofillData(website: String? = null): AutofillView.Data = AutofillView.Data( + autofillId = autofillId, + autofillOptions = emptyList(), + autofillType = View.AUTOFILL_TYPE_TEXT, + isFocused = false, + textValue = null, + hasPasswordTerms = false, + website = website, + ) + + private fun usernameHostRule( + tag: String?, + id: String?, + ): FillAssistRules.HostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf(selectorClause(tag = tag, id = id)), + ), + ) + + private fun selectorClause( + tag: String? = null, + id: String? = null, + name: String? = null, + type: String? = null, + role: String? = null, + ): FillAssistRules.SelectorClause = FillAssistRules.SelectorClause( + tag = tag, + id = id, + name = name, + type = type, + role = role, + ) + + private fun createHtmlInfo(matches: Boolean = true): HtmlInfo = mockk().also { + every { it.matchesSelectorClause(any()) } returns matches + } + + private fun createViewNode( + htmlInfo: HtmlInfo?, + autofillId: AutofillId? = this.autofillId, + website: String? = null, + children: List = emptyList(), + ): AssistStructure.ViewNode = mockk { + every { this@mockk.htmlInfo } returns htmlInfo + every { this@mockk.autofillId } returns autofillId + every { this@mockk.website } returns website + every { this@mockk.childCount } returns children.size + children.forEachIndexed { index, child -> + every { this@mockk.getChildAt(index) } returns child + } + } + + private fun createAssistStructure( + rootViewNode: AssistStructure.ViewNode, + ): AssistStructure { + val windowNode: AssistStructure.WindowNode = mockk { + every { this@mockk.rootViewNode } returns rootViewNode + } + return mockk { + every { windowNodeCount } returns 1 + every { getWindowNodeAt(0) } returns windowNode + } + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt index aabad130275..0321c4f65ae 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt @@ -34,6 +34,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.manager.UserStateManager +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.createMockWrappedAccountCryptographicState import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource @@ -144,7 +145,12 @@ class VaultSyncManagerTest { every { databaseSchemeChangeFlow } returns mutableDatabaseSchemeChangeFlow } + private val fillAssistManager: FillAssistManager = mockk { + every { syncIfNecessary() } just runs + } + private val vaultSyncManager: VaultSyncManager = VaultSyncManagerImpl( + fillAssistManager = fillAssistManager, syncService = syncService, settingsDiskSource = settingsDiskSource, authDiskSource = fakeAuthDiskSource, @@ -777,6 +783,7 @@ class VaultSyncManagerTest { ), ) } + verify(exactly = 1) { fillAssistManager.syncIfNecessary() } } @Suppress("MaxLineLength") @@ -817,6 +824,7 @@ class VaultSyncManagerTest { ), ) } + verify(exactly = 0) { fillAssistManager.syncIfNecessary() } } @Test @@ -1183,6 +1191,7 @@ class VaultSyncManagerTest { val syncResult = vaultSyncManager.syncForResult() assertEquals(SyncVaultDataResult.Success(itemsAvailable = true), syncResult) + verify(exactly = 1) { fillAssistManager.syncIfNecessary() } } @Suppress("MaxLineLength") @@ -1214,6 +1223,7 @@ class VaultSyncManagerTest { val syncResult = vaultSyncManager.syncForResult() assertEquals(SyncVaultDataResult.Success(itemsAvailable = false), syncResult) + verify(exactly = 1) { fillAssistManager.syncIfNecessary() } } @Test @@ -1263,6 +1273,7 @@ class VaultSyncManagerTest { ) } coVerify(exactly = 0) { syncService.sync() } + verify(exactly = 0) { fillAssistManager.syncIfNecessary() } } //region Helper functions