PM-11486: Parse the Accessibility Nodes for username and password fields (#3935)

This commit is contained in:
David Perez 2024-09-18 15:35:15 -05:00 committed by GitHub
parent f89b053d2e
commit 2068948035
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1376 additions and 19 deletions

View file

@ -9,6 +9,8 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityComp
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityNodeInfoManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityNodeInfoManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManager
@ -58,7 +60,16 @@ object AccessibilityModule {
@Singleton
@Provides
fun providesAccessibilityParser(): AccessibilityParser = AccessibilityParserImpl()
fun providesAccessibilityNodeInfoManager(): AccessibilityNodeInfoManager =
AccessibilityNodeInfoManagerImpl()
@Singleton
@Provides
fun providesAccessibilityParser(
accessibilityNodeInfoManager: AccessibilityNodeInfoManager,
): AccessibilityParser = AccessibilityParserImpl(
accessibilityNodeInfoManager = accessibilityNodeInfoManager,
)
@Singleton
@Provides

View file

@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.net.Uri
import android.view.accessibility.AccessibilityNodeInfo
/**
* The default maximum recursive depth that the
* [AccessibilityNodeInfoManager.findAccessibilityNodeInfoList] will go.
*/
const val DEFAULT_MAX_RECURSION_DEPTH: Int = 100
/**
* A manager for finding fields that match particular characteristics.
*/
interface AccessibilityNodeInfoManager {
/**
* A helper function for retrieving the appropriate nodes based on the given [predicate].
*
* This function is recursive but will stop recurring if the depth it reaches is greater than
* the [maxRecursionDepth].
*/
fun findAccessibilityNodeInfoList(
rootNode: AccessibilityNodeInfo,
maxRecursionDepth: Int = DEFAULT_MAX_RECURSION_DEPTH,
predicate: (AccessibilityNodeInfo) -> Boolean,
): List<AccessibilityNodeInfo>
/**
* Determines which [AccessibilityNodeInfo] is a username field.
*/
fun findUsernameAccessibilityNodeInfo(
uri: Uri,
allNodes: List<AccessibilityNodeInfo>,
passwordNodes: List<AccessibilityNodeInfo>,
): AccessibilityNodeInfo?
}

View file

@ -0,0 +1,96 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.net.Uri
import android.util.Log
import android.view.accessibility.AccessibilityNodeInfo
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.autofill.accessibility.util.getKnownUsernameFieldNull
import com.x8bit.bitwarden.data.autofill.accessibility.util.isUsername
private const val MAX_NODE_COUNT: Int = 100
/**
* The default implementation for the [AccessibilityNodeInfoManager].
*/
class AccessibilityNodeInfoManagerImpl : AccessibilityNodeInfoManager {
override fun findAccessibilityNodeInfoList(
rootNode: AccessibilityNodeInfo,
maxRecursionDepth: Int,
predicate: (AccessibilityNodeInfo) -> Boolean,
): List<AccessibilityNodeInfo> =
findAccessibilityNodeInfoList(
rootNode = rootNode,
maxRecursionDepth = maxRecursionDepth,
currentRecursionDepth = 0,
predicate = predicate,
)
override fun findUsernameAccessibilityNodeInfo(
uri: Uri,
allNodes: List<AccessibilityNodeInfo>,
passwordNodes: List<AccessibilityNodeInfo>,
): AccessibilityNodeInfo? {
val uriPath = uri
.path
?: return findMissingUsernameNodeInfo(
allNodes = allNodes,
passwordNodes = passwordNodes,
)
return uri
.authority
?.removePrefix(prefix = "www.")
?.getKnownUsernameFieldNull()
?.let { usernameField ->
allNodes.firstOrNull { node ->
node.isUsername(
uriPath = uriPath,
knownUsernameField = usernameField,
)
}
}
?: findMissingUsernameNodeInfo(allNodes = allNodes, passwordNodes = passwordNodes)
}
private fun findAccessibilityNodeInfoList(
rootNode: AccessibilityNodeInfo,
maxRecursionDepth: Int,
currentRecursionDepth: Int,
predicate: (AccessibilityNodeInfo) -> Boolean,
): List<AccessibilityNodeInfo> {
if (predicate(rootNode)) return listOf(rootNode)
if (currentRecursionDepth >= maxRecursionDepth) return emptyList()
val childNodeCount = rootNode.childCount - 1
if (childNodeCount > MAX_NODE_COUNT) log(message = "Too many child iterations.")
return (0..childNodeCount.coerceAtMost(maximumValue = MAX_NODE_COUNT)).flatMap {
val childNode = rootNode.getChild(it) ?: return@flatMap emptyList()
if (childNode.hashCode() == this.hashCode()) {
log(message = "Child node is the same as parent for some reason.")
emptyList()
} else {
findAccessibilityNodeInfoList(
rootNode = childNode,
maxRecursionDepth = maxRecursionDepth,
currentRecursionDepth = currentRecursionDepth + 1,
predicate = predicate,
)
}
}
}
/**
* Attempts to find a username [AccessibilityNodeInfo] if there isn't one already. This
* functions by finding the first known password node and taking the node directly above it.
*/
private fun findMissingUsernameNodeInfo(
allNodes: List<AccessibilityNodeInfo>,
passwordNodes: List<AccessibilityNodeInfo>,
): AccessibilityNodeInfo? =
passwordNodes
.firstOrNull()
?.let { allNodes.getOrNull(index = allNodes.indexOf(element = it) - 1) }
private fun log(message: String) {
if (!BuildConfig.DEBUG) return
Log.i("AccessibilityNodeInfoManager", message)
}
}

View file

@ -6,6 +6,6 @@ import android.view.accessibility.AccessibilityNodeInfo
* Represents the fillable fields for accessibility based autofill.
*/
data class FillableFields(
val usernameFields: List<AccessibilityNodeInfo>,
val usernameField: AccessibilityNodeInfo?,
val passwordFields: List<AccessibilityNodeInfo>,
)

View file

@ -0,0 +1,71 @@
package com.x8bit.bitwarden.data.autofill.accessibility.model
/**
* Represents the known username fields for a given [uriAuthority].
*/
data class KnownUsernameField(
val uriAuthority: String,
val accessOptions: List<AccessOptions>,
) {
constructor(
uriAuthority: String,
accessOption: AccessOptions,
) : this(uriAuthority = uriAuthority, accessOptions = listOf(accessOption))
}
/**
* Represents the view IDs for a given uri path.
*/
data class AccessOptions(
val matchValue: String,
val matchingStrategy: MatchingStrategy = MatchingStrategy.ENDS_WITH_CASE_SENSITIVE,
val usernameViewIds: List<String>,
) {
constructor(
matchValue: String,
matchingStrategy: MatchingStrategy = MatchingStrategy.ENDS_WITH_CASE_SENSITIVE,
usernameViewId: String,
) : this(
matchValue = matchValue,
matchingStrategy = matchingStrategy,
usernameViewIds = listOf(usernameViewId),
)
/**
* Indicates the matching strategy needed for the particular [AccessOptions].
*/
enum class MatchingStrategy(
val matches: (uriPath: String, matchValue: String) -> Boolean,
) {
CONTAINS_CASE_INSENSITIVE(
matches = { uriPath, matchValue ->
uriPath.contains(other = matchValue, ignoreCase = true)
},
),
CONTAINS_CASE_SENSITIVE(
matches = { uriPath, matchValue ->
uriPath.contains(other = matchValue, ignoreCase = false)
},
),
ENDS_WITH_CASE_INSENSITIVE(
matches = { uriPath, matchValue ->
uriPath.endsWith(suffix = matchValue, ignoreCase = true)
},
),
ENDS_WITH_CASE_SENSITIVE(
matches = { uriPath, matchValue ->
uriPath.endsWith(suffix = matchValue, ignoreCase = false)
},
),
STARTS_WITH_CASE_INSENSITIVE(
matches = { uriPath, matchValue ->
uriPath.startsWith(prefix = matchValue, ignoreCase = true)
},
),
STARTS_WITH_CASE_SENSITIVE(
matches = { uriPath, matchValue ->
uriPath.startsWith(prefix = matchValue, ignoreCase = false)
},
),
}
}

View file

@ -11,7 +11,7 @@ interface AccessibilityParser {
/**
* Parses the fillable fields from [rootNode].
*/
fun parseForFillableFields(rootNode: AccessibilityNodeInfo): FillableFields
fun parseForFillableFields(rootNode: AccessibilityNodeInfo, uri: Uri): FillableFields
/**
* Parses the [Uri] from [rootNode] and returns a url, package name.

View file

@ -3,20 +3,35 @@ package com.x8bit.bitwarden.data.autofill.accessibility.parser
import android.net.Uri
import android.view.accessibility.AccessibilityNodeInfo
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityNodeInfoManager
import com.x8bit.bitwarden.data.autofill.accessibility.model.FillableFields
import com.x8bit.bitwarden.data.autofill.accessibility.util.getSupportedBrowserOrNull
import com.x8bit.bitwarden.data.autofill.accessibility.util.isEditText
import com.x8bit.bitwarden.data.autofill.accessibility.util.toUriOrNull
import com.x8bit.bitwarden.data.platform.util.hasHttpProtocol
/**
* The default implementation for the [AccessibilityParser].
*/
class AccessibilityParserImpl : AccessibilityParser {
override fun parseForFillableFields(rootNode: AccessibilityNodeInfo): FillableFields {
// TODO: Parse for username and password fields (PM-11486)
class AccessibilityParserImpl(
private val accessibilityNodeInfoManager: AccessibilityNodeInfoManager,
) : AccessibilityParser {
override fun parseForFillableFields(
rootNode: AccessibilityNodeInfo,
uri: Uri,
): FillableFields {
val nodes = accessibilityNodeInfoManager
.findAccessibilityNodeInfoList(rootNode = rootNode) {
it.isEditText || it.isPassword
}
val passwordNodes = nodes.filter { it.isPassword }
return FillableFields(
usernameFields = listOf(),
passwordFields = listOf(),
usernameField = accessibilityNodeInfoManager.findUsernameAccessibilityNodeInfo(
uri = uri,
allNodes = nodes,
passwordNodes = passwordNodes,
),
passwordFields = passwordNodes,
)
}

View file

@ -75,10 +75,11 @@ class BitwardenAccessibilityProcessorImpl(
attemptFill: AccessibilityAction.AttemptFill,
) {
val loginView = attemptFill.cipherView.login ?: return
val fields = accessibilityParser.parseForFillableFields(rootNode = rootNode)
fields.usernameFields.forEach { usernameField ->
usernameField.fillTextField(value = loginView.username)
}
val fields = accessibilityParser.parseForFillableFields(
rootNode = rootNode,
uri = attemptFill.uri,
)
fields.usernameField?.fillTextField(value = loginView.username)
fields.passwordFields.forEach { passwordField ->
passwordField.fillTextField(value = loginView.password)
}

View file

@ -1,7 +1,9 @@
package com.x8bit.bitwarden.data.autofill.accessibility.util
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.EditText
import androidx.core.os.bundleOf
import com.x8bit.bitwarden.data.autofill.accessibility.model.KnownUsernameField
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
private const val PACKAGE_NAME_BITWARDEN_PREFIX: String = "com.x8bit.bitwarden"
@ -50,3 +52,36 @@ fun AccessibilityNodeInfo.fillTextField(value: String?) {
bundleOf(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE to value),
)
}
/**
* Determines if the [AccessibilityNodeInfo] is an instance of an EditText.
*/
val AccessibilityNodeInfo.isEditText: Boolean
get() = className
?.let {
try {
Class.forName(it.toString())
} catch (e: ClassNotFoundException) {
null
}
}
?.let { EditText::class.java.isAssignableFrom(it) }
?: (className?.contains(other = "EditText") == true)
/**
* Determines if the [AccessibilityNodeInfo] is a username field.
*/
fun AccessibilityNodeInfo.isUsername(
knownUsernameField: KnownUsernameField,
uriPath: String,
): Boolean {
knownUsernameField.accessOptions.map { options ->
if (options.matchingStrategy.matches(uriPath, options.matchValue)) {
options
.usernameViewIds
.firstOrNull { viewId -> viewId == this@isUsername.viewIdResourceName }
?.let { return true }
}
}
return false
}

View file

@ -0,0 +1,672 @@
package com.x8bit.bitwarden.data.autofill.accessibility.util
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessOptions
import com.x8bit.bitwarden.data.autofill.accessibility.model.KnownUsernameField
/**
* Determines if the [String] receiver is a uri authority for a known username field and returns
* that [KnownUsernameField] if it is a match.
*/
fun String.getKnownUsernameFieldNull(): KnownUsernameField? =
LEGACY_KNOWN_USERNAME_FIELDS.find { it.uriAuthority == this@getKnownUsernameFieldNull }
/**
* A list of known username fields and their IDs.
*/
private val LEGACY_KNOWN_USERNAME_FIELDS: List<KnownUsernameField> = listOf(
// SECTION A ——— World-renowned web sites/applications
KnownUsernameField(
uriAuthority = "amazon.ae",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.ca",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.cn",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.co.jp",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.co.uk",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.com",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.com.au",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.com.br",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.com.mx",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.com.tr",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.de",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.es",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.fr",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.in",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.it",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.nl",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.pl",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.sa",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.se",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.sg",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "signin.aws.amazon.com",
accessOption = AccessOptions(matchValue = "signin", usernameViewId = "resolving_input"),
),
KnownUsernameField(
uriAuthority = "id.atlassian.com",
accessOption = AccessOptions(matchValue = "login", usernameViewId = "username"),
),
KnownUsernameField(
uriAuthority = "bitly.com",
accessOption = AccessOptions(matchValue = "/sso/url_slug", usernameViewId = "url_slug"),
),
KnownUsernameField(
uriAuthority = "signin.befr.ebay.be",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.benl.ebay.be",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.cafr.ebay.ca",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.at",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.be",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.ca",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.ch",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.co.uk",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.com",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.com.au",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.com.hk",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.com.my",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.com.sg",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.de",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.es",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.fr",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.ie",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.in",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.it",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.nl",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.ph",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.pl",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "accounts.google.com",
accessOptions = listOf(
AccessOptions(matchValue = "identifier", usernameViewId = "identifierId"),
AccessOptions(matchValue = "ServiceLogin", usernameViewId = "Email"),
),
),
KnownUsernameField(
uriAuthority = "paypal.com",
accessOptions = listOf(
AccessOptions(matchValue = "signin", usernameViewId = "email"),
AccessOptions(
matchValue = "/connect/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewId = "email",
),
),
),
KnownUsernameField(
uriAuthority = "tumblr.com",
accessOption = AccessOptions(
matchValue = "login",
usernameViewId = "signup_determine_email",
),
),
KnownUsernameField(
uriAuthority = "passport.yandex.az",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.by",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.co.il",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.com",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.com.am",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.com.ge",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.com.tr",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.ee",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.fi",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.fr",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.kg",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.kz",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.lt",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.lv",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.md",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.pl",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.ru",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.tj",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.tm",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.ua",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.uz",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
// SECTION B ——— Top 100 worldwide
// As of July 2020, all entries that needed to be added from
// Top 100 (SimilarWeb, 2019) and Top 50 (Alexa Internet, 2020)
// matched section A.
// Therefore, no entry currently.
// SECTION C ——— Top 20 for selected countries
// For these selected countries, the Top 20 (SimilarWeb, 2020)
// and the Top 20 (Alexa Internet, 2020) are covered.
// Mobile and desktop versions supported.
// Could not be added, however:
// web sites/applications that don't use an "id" attribute for their login field.
KnownUsernameField(
uriAuthority = "cfg.smt.docomo.ne.jp",
accessOption = AccessOptions(
matchValue = "/auth/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewId = "Di_Uid",
),
),
KnownUsernameField(
uriAuthority = "id.smt.docomo.ne.jp",
accessOption = AccessOptions(
matchValue = "/cgi7/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewId = "Di_Uid",
),
),
// SECTION D ——— Miscellaneous
// No entry, currently.
// SECTION Z ——— Special forms
// Despite "user ID + password" fields both visible, detection rules required.
// No entry, currently.
// Test/example purposes only
// GitHub is a VERY special case (signup form, just to test the proper functioning
// of special forms).
KnownUsernameField(
uriAuthority = "github.com",
accessOption = AccessOptions(matchValue = "", usernameViewId = "user[login]-footer"),
),
)

View file

@ -0,0 +1,216 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.net.Uri
import android.view.accessibility.AccessibilityNodeInfo
import com.x8bit.bitwarden.data.autofill.accessibility.util.isUsername
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.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AccessibilityNodeInfoManagerTest {
private val accessibilityNodeInfoManager: AccessibilityNodeInfoManager =
AccessibilityNodeInfoManagerImpl()
@BeforeEach
fun setup() {
mockkStatic(AccessibilityNodeInfo::isUsername)
}
@AfterEach
fun tearDown() {
unmockkStatic(AccessibilityNodeInfo::isUsername)
}
@Suppress("MaxLineLength")
@Test
fun `findAccessibilityNodeInfoList with the node matching the predicate should return that node`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo>()
val result = accessibilityNodeInfoManager.findAccessibilityNodeInfoList(
rootNode = accessibilityNodeInfo,
maxRecursionDepth = 100,
predicate = { true },
)
assertEquals(listOf(accessibilityNodeInfo), result)
}
@Test
fun `findAccessibilityNodeInfoList with a high recursion depth should return an empty list`() {
// This node will always returns itself, so it should recur until it hits the max depth
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { childCount } returns 1
every { getChild(any()) } returns this
}
val result = accessibilityNodeInfoManager.findAccessibilityNodeInfoList(
rootNode = accessibilityNodeInfo,
maxRecursionDepth = 100,
predicate = { false },
)
assertEquals(emptyList<AccessibilityNodeInfo>(), result)
}
@Test
fun `findAccessibilityNodeInfoList where child node is null should return an empty list`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { childCount } returns 1
every { getChild(any()) } returns null
}
val result = accessibilityNodeInfoManager.findAccessibilityNodeInfoList(
rootNode = accessibilityNodeInfo,
maxRecursionDepth = 100,
predicate = { false },
)
assertEquals(emptyList<AccessibilityNodeInfo>(), result)
}
@Suppress("MaxLineLength")
@Test
fun `findAccessibilityNodeInfoList with child nodes should return list of matching child nodes`() {
val childChildAccessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { isPassword } returns true
}
val childAccessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { isPassword } returns false
every { childCount } returns 3
every { getChild(any()) } returns childChildAccessibilityNodeInfo
}
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { isPassword } returns false
every { childCount } returns 3
every { getChild(any()) } returns childAccessibilityNodeInfo
}
val result = accessibilityNodeInfoManager.findAccessibilityNodeInfoList(
rootNode = accessibilityNodeInfo,
maxRecursionDepth = 100,
predicate = { it.isPassword },
)
assertEquals(
listOf(
childChildAccessibilityNodeInfo,
childChildAccessibilityNodeInfo,
childChildAccessibilityNodeInfo,
childChildAccessibilityNodeInfo,
childChildAccessibilityNodeInfo,
childChildAccessibilityNodeInfo,
childChildAccessibilityNodeInfo,
childChildAccessibilityNodeInfo,
childChildAccessibilityNodeInfo,
),
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `findUsernameAccessibilityNodeInfo with null uri path and no password fields should return null`() {
val uri: Uri = mockk {
every { path } returns null
}
val result = accessibilityNodeInfoManager.findUsernameAccessibilityNodeInfo(
uri = uri,
allNodes = emptyList(),
passwordNodes = emptyList(),
)
assertNull(result)
}
@Suppress("MaxLineLength")
@Test
fun `findUsernameAccessibilityNodeInfo with null uri path and a password field should return field above it`() {
val uri: Uri = mockk {
every { path } returns null
}
val usernameField = mockk<AccessibilityNodeInfo>()
val passwordField = mockk<AccessibilityNodeInfo>()
val result = accessibilityNodeInfoManager.findUsernameAccessibilityNodeInfo(
uri = uri,
allNodes = listOf(usernameField, passwordField),
passwordNodes = listOf(passwordField),
)
assertEquals(usernameField, result)
}
@Suppress("MaxLineLength")
@Test
fun `findUsernameAccessibilityNodeInfo with null uri authority and no possible username field should return null`() {
val uri: Uri = mockk {
every { path } returns "amazon/qa"
every { authority } returns null
}
val passwordField = mockk<AccessibilityNodeInfo>()
val result = accessibilityNodeInfoManager.findUsernameAccessibilityNodeInfo(
uri = uri,
allNodes = listOf(passwordField),
passwordNodes = listOf(passwordField),
)
assertNull(result)
}
@Suppress("MaxLineLength")
@Test
fun `findUsernameAccessibilityNodeInfo with known username field but no matches should return null`() {
val uriPath = "/ap/signin"
val uri: Uri = mockk {
every { path } returns uriPath
every { authority } returns "www.amazon.com"
}
val usernameField = mockk<AccessibilityNodeInfo> {
every { isUsername(knownUsernameField = any(), uriPath = uriPath) } returns false
}
val passwordField = mockk<AccessibilityNodeInfo> {
every { isUsername(knownUsernameField = any(), uriPath = uriPath) } returns false
}
val result = accessibilityNodeInfoManager.findUsernameAccessibilityNodeInfo(
uri = uri,
allNodes = listOf(passwordField, usernameField),
passwordNodes = listOf(passwordField),
)
assertNull(result)
}
@Suppress("MaxLineLength")
@Test
fun `findUsernameAccessibilityNodeInfo with known username field should return correct field`() {
val uriPath = "/ap/signin"
val uri: Uri = mockk {
every { path } returns uriPath
every { authority } returns "amazon.com"
}
val usernameField = mockk<AccessibilityNodeInfo> {
every { isUsername(knownUsernameField = any(), uriPath = uriPath) } returns true
}
val passwordField = mockk<AccessibilityNodeInfo> {
every { isUsername(knownUsernameField = any(), uriPath = uriPath) } returns false
}
val result = accessibilityNodeInfoManager.findUsernameAccessibilityNodeInfo(
uri = uri,
allNodes = listOf(passwordField, usernameField),
passwordNodes = listOf(passwordField),
)
assertEquals(usernameField, result)
}
}

View file

@ -1,7 +1,9 @@
package com.x8bit.bitwarden.data.autofill.accessibility.parser
import android.net.Uri
import android.view.accessibility.AccessibilityNodeInfo
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityNodeInfoManager
import com.x8bit.bitwarden.data.autofill.accessibility.model.Browser
import com.x8bit.bitwarden.data.autofill.accessibility.model.FillableFields
import io.mockk.every
@ -12,17 +14,112 @@ import org.junit.jupiter.api.Test
class AccessibilityParserTest {
private val accessibilityParser: AccessibilityParser = AccessibilityParserImpl()
private val accessibilityNodeInfoManager: AccessibilityNodeInfoManager = mockk()
private val accessibilityParser: AccessibilityParser = AccessibilityParserImpl(
accessibilityNodeInfoManager = accessibilityNodeInfoManager,
)
@Test
fun `parseForFillableFields should return empty data`() {
fun `parseForFillableFields with no accessibility nodes should return empty data`() {
val uri = mockk<Uri>()
val rootNode = mockk<AccessibilityNodeInfo>()
every {
accessibilityNodeInfoManager.findAccessibilityNodeInfoList(
rootNode = rootNode,
predicate = any(),
)
} returns emptyList()
every {
accessibilityNodeInfoManager.findUsernameAccessibilityNodeInfo(
uri = uri,
allNodes = emptyList(),
passwordNodes = emptyList(),
)
} returns null
val expectedResult = FillableFields(
usernameFields = emptyList(),
usernameField = null,
passwordFields = emptyList(),
)
val result = accessibilityParser.parseForFillableFields(rootNode = rootNode)
val result = accessibilityParser.parseForFillableFields(
rootNode = rootNode,
uri = uri,
)
assertEquals(expectedResult, result)
}
@Suppress("MaxLineLength")
@Test
fun `parseForFillableFields with password fields should return no username field and all password fields`() {
val uri = mockk<Uri>()
val rootNode = mockk<AccessibilityNodeInfo>()
val passwordField = mockk<AccessibilityNodeInfo> {
every { isPassword } returns true
}
val allFields = listOf(passwordField, passwordField)
every {
accessibilityNodeInfoManager.findAccessibilityNodeInfoList(
rootNode = rootNode,
predicate = any(),
)
} returns allFields
every {
accessibilityNodeInfoManager.findUsernameAccessibilityNodeInfo(
uri = uri,
allNodes = allFields,
passwordNodes = allFields,
)
} returns null
val expectedResult = FillableFields(
usernameField = null,
passwordFields = allFields,
)
val result = accessibilityParser.parseForFillableFields(
rootNode = rootNode,
uri = uri,
)
assertEquals(expectedResult, result)
}
@Suppress("MaxLineLength")
@Test
fun `parseForFillableFields with password fields and username field should return username field and all password fields`() {
val uri = mockk<Uri>()
val rootNode = mockk<AccessibilityNodeInfo>()
val usernameField = mockk<AccessibilityNodeInfo> {
every { isPassword } returns false
}
val passwordField = mockk<AccessibilityNodeInfo> {
every { isPassword } returns true
}
val allFields = listOf(usernameField, passwordField, passwordField)
val passwordFields = listOf(passwordField, passwordField)
every {
accessibilityNodeInfoManager.findAccessibilityNodeInfoList(
rootNode = rootNode,
predicate = any(),
)
} returns allFields
every {
accessibilityNodeInfoManager.findUsernameAccessibilityNodeInfo(
uri = uri,
allNodes = allFields,
passwordNodes = passwordFields,
)
} returns usernameField
val expectedResult = FillableFields(
usernameField = usernameField,
passwordFields = passwordFields,
)
val result = accessibilityParser.parseForFillableFields(
rootNode = rootNode,
uri = uri,
)
assertEquals(expectedResult, result)
}

View file

@ -279,7 +279,7 @@ class BitwardenAccessibilityProcessorTest {
every { fillTextField(testPassword) } just runs
}
val fillableFields = FillableFields(
usernameFields = listOf(mockUsernameField),
usernameField = mockUsernameField,
passwordFields = listOf(mockPasswordField),
)
val uri = mockk<Uri>()
@ -293,7 +293,7 @@ class BitwardenAccessibilityProcessorTest {
every { accessibilityAutofillManager.accessibilityAction } returns attemptFill
every { accessibilityAutofillManager.accessibilityAction = null } just runs
every {
accessibilityParser.parseForFillableFields(rootNode = rootNode)
accessibilityParser.parseForFillableFields(rootNode = rootNode, uri = uri)
} returns fillableFields
bitwardenAccessibilityProcessor.processAccessibilityEvent(
@ -307,7 +307,7 @@ class BitwardenAccessibilityProcessorTest {
accessibilityAutofillManager.accessibilityAction
accessibilityAutofillManager.accessibilityAction = null
cipherView.login
accessibilityParser.parseForFillableFields(rootNode = rootNode)
accessibilityParser.parseForFillableFields(rootNode = rootNode, uri = uri)
mockUsernameField.fillTextField(testUsername)
mockPasswordField.fillTextField(testPassword)
}

View file

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.data.autofill.accessibility.util
import android.view.accessibility.AccessibilityNodeInfo
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessOptions
import com.x8bit.bitwarden.data.autofill.accessibility.model.KnownUsernameField
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertFalse
@ -9,6 +11,100 @@ import org.junit.jupiter.api.Test
class AccessibilityNodeInfoExtensionsTest {
@Test
fun `isUsername without uri match should return false`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo>()
val result = accessibilityNodeInfo.isUsername(
knownUsernameField = MOCK_KNOWN_USERNAME_FIELD,
uriPath = "",
)
assertFalse(result)
}
@Test
fun `isUsername with uri match and no matching viewId should return false`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { viewIdResourceName } returns ""
}
val result = accessibilityNodeInfo.isUsername(
knownUsernameField = MOCK_KNOWN_USERNAME_FIELD,
uriPath = MOCK_MATCH_VALUE,
)
assertFalse(result)
}
@Test
fun `isUsername with uri match and matching viewId should return true`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { viewIdResourceName } returns MOCK_USERNAME_VIEW_ID
}
val result = accessibilityNodeInfo.isUsername(
knownUsernameField = MOCK_KNOWN_USERNAME_FIELD,
uriPath = MOCK_MATCH_VALUE,
)
assertTrue(result)
}
@Test
fun `isEditText when className is null should return false`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { className } returns null
}
assertFalse(accessibilityNodeInfo.isEditText)
}
@Test
fun `isEditText when className does not contain 'EditText' should return false`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { className } returns "TextView"
}
assertFalse(accessibilityNodeInfo.isEditText)
}
@Test
fun `isEditText when className is an EditText should return true`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { className } returns "android.widget.EditText"
}
assertTrue(accessibilityNodeInfo.isEditText)
}
@Test
fun `isEditText when className is assignable to 'EditText' should return true`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { className } returns "android.widget.AutoCompleteTextView"
}
assertTrue(accessibilityNodeInfo.isEditText)
}
@Test
fun `isEditText when className does contains 'EditText' should return true`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { className } returns "com.EditText"
}
assertTrue(accessibilityNodeInfo.isEditText)
}
@Test
fun `isEditText when className is exactly 'EditText' should return true`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
every { className } returns "EditText"
}
assertTrue(accessibilityNodeInfo.isEditText)
}
@Test
fun `shouldSkipPackage when packageName is null should return true`() {
val accessibilityNodeInfo = mockk<AccessibilityNodeInfo> {
@ -73,3 +169,14 @@ class AccessibilityNodeInfoExtensionsTest {
assertFalse(accessibilityNodeInfo.shouldSkipPackage)
}
}
private const val MOCK_MATCH_VALUE: String = "/ap/signin"
private const val MOCK_USERNAME_VIEW_ID: String = "ap_email"
private val MOCK_KNOWN_USERNAME_FIELD: KnownUsernameField = KnownUsernameField(
uriAuthority = "amazon.com",
accessOption = AccessOptions(
matchValue = MOCK_MATCH_VALUE,
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", MOCK_USERNAME_VIEW_ID),
),
)