diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/model/Browser.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/model/Browser.kt index 6d5c150ab0..37334c0d96 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/model/Browser.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/model/Browser.kt @@ -6,6 +6,7 @@ package com.x8bit.bitwarden.data.autofill.accessibility.model data class Browser( val packageName: String, val possibleUrlFieldIds: List, + val possibleUrlSemanticIds: List = emptyList(), val urlExtractor: (String) -> String? = { it }, ) { constructor( @@ -15,6 +16,7 @@ data class Browser( ) : this( packageName = packageName, possibleUrlFieldIds = listOf(urlFieldId), + possibleUrlSemanticIds = emptyList(), urlExtractor = urlExtractor, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/parser/AccessibilityParserImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/parser/AccessibilityParserImpl.kt index b83f00889d..a2fac6be35 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/parser/AccessibilityParserImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/parser/AccessibilityParserImpl.kt @@ -43,22 +43,34 @@ class AccessibilityParserImpl( return browser .possibleUrlFieldIds .flatMap { viewId -> - rootNode - .findAccessibilityNodeInfosByViewId("$packageName:id/$viewId") - .map { accessibilityNodeInfo -> - browser - .urlExtractor(accessibilityNodeInfo.text.toString()) - ?.trim() - ?.let { rawUrl -> - if (rawUrl.contains(other = ".") && !rawUrl.hasHttpProtocol()) { - "https://$rawUrl" - } else { - rawUrl - } - } + rootNode.findAccessibilityNodeInfosByViewId("$packageName:id/$viewId") + } + .ifEmpty { + browser + .possibleUrlSemanticIds + .flatMap { semanticId -> + // Semantic IDs are exposed as viewIdResourceName via testTagsAsResourceId + // and cannot be found via findAccessibilityNodeInfosByViewId on Firefox. + accessibilityNodeInfoManager.findAccessibilityNodeInfoList(rootNode) { + it.viewIdResourceName == semanticId + } + } + } + .firstNotNullOfOrNull { node -> + val urlText = node.text?.toString()?.takeIf { it.isNotEmpty() } + ?: node.contentDescription?.toString()?.takeIf { it.isNotEmpty() } + ?: return@firstNotNullOfOrNull null + browser + .urlExtractor(urlText) + ?.trim() + ?.let { rawUrl -> + if (rawUrl.contains(other = ".") && !rawUrl.hasHttpProtocol()) { + "https://$rawUrl" + } else { + rawUrl + } } } - .firstOrNull() ?.toUriOrNull() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/util/BrowserUtil.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/util/BrowserUtil.kt index 287862a35a..decb098524 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/util/BrowserUtil.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/util/BrowserUtil.kt @@ -2,6 +2,15 @@ package com.x8bit.bitwarden.data.autofill.accessibility.util import com.x8bit.bitwarden.data.autofill.accessibility.model.Browser +/** + * URL extractor for Mozilla browsers whose toolbar exposes the URL via [contentDescription] + * rather than [text]. The content description format is " $url. Search or enter address". + * Falls back to [text] for builds where the URL is still exposed via [text]. + */ +private val mozillaUrlExtractor: (String) -> String? = { text -> + text.trim().split(" ").firstOrNull()?.trimEnd('.')?.takeIf { it.isNotEmpty() } +} + /** * Determines if the [String] receiver is a package name for a supported browser and returns that * [Browser] if it is a match. @@ -36,14 +45,21 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf( Browser(packageName = "com.cookiegames.smartcookie", urlFieldId = "search"), Browser( packageName = "com.cookiejarapps.android.smartcookieweb", - urlFieldId = "mozac_browser_toolbar_url_view", + possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser(packageName = "com.duckduckgo.mobile.android", urlFieldId = "omnibarTextInput"), Browser(packageName = "com.ecosia.android", urlFieldId = "url_bar"), Browser(packageName = "com.google.android.apps.chrome", urlFieldId = "url_bar"), Browser(packageName = "com.google.android.apps.chrome_dev", urlFieldId = "url_bar"), // "com.google.android.captiveportallogin": URL displayed in ActionBar subtitle without viewId - Browser(packageName = "com.iode.firefox", urlFieldId = "mozac_browser_toolbar_url_view"), + Browser( + packageName = "com.iode.firefox", + possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, + ), Browser(packageName = "com.jamal2367.styx", urlFieldId = "search"), Browser(packageName = "com.kiwibrowser.browser", urlFieldId = "url_bar"), Browser(packageName = "com.kiwibrowser.browser.dev", urlFieldId = "url_bar"), @@ -67,7 +83,12 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf( Browser( packageName = "com.qwant.liberty", // 2nd = Legacy (before v4) - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "url_bar_title", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser(packageName = "com.rainsee.create", urlFieldId = "search_box"), Browser(packageName = "com.sec.android.app.sbrowser", urlFieldId = "location_bar_edit_text"), @@ -102,7 +123,9 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf( Browser(packageName = "idm.internet.download.manager.plus", urlFieldId = "search"), Browser( packageName = "io.github.forkmaintainers.iceraven", - urlFieldId = "mozac_browser_toolbar_url_view", + possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser(packageName = "mark.via", urlFieldId = "am,an"), Browser(packageName = "mark.via.gp", urlFieldId = "as"), @@ -129,78 +152,155 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf( Browser( packageName = "org.gnu.icecat", // 2nd = Anticipation - possibleUrlFieldIds = listOf("url_bar_title", "mozac_browser_toolbar_url_view"), + possibleUrlFieldIds = listOf( + "url_bar_title", + "mozac_browser_toolbar_url_view", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser( packageName = "org.ironfoxoss.ironfox", // 2nd = Legacy - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "url_bar_title", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser( packageName = "org.ironfoxoss.ironfox.nightly", // 2nd = Legacy - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "url_bar_title", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, + ), + Browser( + packageName = "org.mozilla.fenix", + possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), - Browser(packageName = "org.mozilla.fenix", urlFieldId = "mozac_browser_toolbar_url_view"), // [DEPRECATED ENTRY] Browser( packageName = "org.mozilla.fenix.nightly", - urlFieldId = "mozac_browser_toolbar_url_view", + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), // [DEPRECATED ENTRY] Browser( packageName = "org.mozilla.fennec_aurora", - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"), + // 2nd = Legacy + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "url_bar_title", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser( packageName = "org.mozilla.fennec_fdroid", // 2nd = Legacy - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "url_bar_title", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser( packageName = "org.mozilla.firefox", // 2nd = Legacy - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "url_bar_title", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser( packageName = "org.mozilla.firefox_beta", // 2nd = Legacy - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "url_bar_title", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser( packageName = "org.mozilla.focus", // 2nd = Legacy - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "display_url", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser( packageName = "org.mozilla.focus.beta", // 2nd = Legacy - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "display_url", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser( packageName = "org.mozilla.focus.nightly", // 2nd = Legacy - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "display_url", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser( packageName = "org.mozilla.klar", // 2nd = Legacy - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "display_url", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser( packageName = "org.mozilla.reference.browser", - urlFieldId = "mozac_browser_toolbar_url_view", + possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser(packageName = "org.mozilla.rocket", urlFieldId = "display_url"), Browser( packageName = "org.torproject.torbrowser", // 2nd = Legacy (before v10.0.3) - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "url_bar_title", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser( packageName = "org.torproject.torbrowser_alpha", // 2nd = Legacy (before v10.0a8) - possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"), + possibleUrlFieldIds = listOf( + "mozac_browser_toolbar_url_view", + "url_bar_title", + ), + possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"), + urlExtractor = mozillaUrlExtractor, ), Browser(packageName = "org.ungoogled.chromium.extensions.stable", urlFieldId = "url_bar"), Browser(packageName = "org.ungoogled.chromium.stable", urlFieldId = "url_bar"), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/parser/AccessibilityParserTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/parser/AccessibilityParserTest.kt index 1cf58d1d29..186a10e37e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/parser/AccessibilityParserTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/accessibility/parser/AccessibilityParserTest.kt @@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.autofill.accessibility.model.Browser import com.x8bit.bitwarden.data.autofill.accessibility.model.FillableFields import io.mockk.every import io.mockk.mockk +import io.mockk.verify import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -156,6 +157,102 @@ class AccessibilityParserTest { assertNull(result) } + @Suppress("MaxLineLength") + @Test + fun `parseForUriOrPackageName should return null when URL bar node has no text or content description`() { + val testBrowser = Browser(packageName = "com.android.chrome", urlFieldId = "url_bar") + val emptyNode = mockk { + every { text } returns null + every { contentDescription } returns null + } + val rootNode = mockk { + every { packageName } returns testBrowser.packageName + every { + findAccessibilityNodeInfosByViewId( + "${testBrowser.packageName}:id/${testBrowser.possibleUrlFieldIds.first()}", + ) + } returns listOf(emptyNode) + } + + val result = accessibilityParser.parseForUriOrPackageName(rootNode = rootNode) + + assertNull(result) + } + + @Test + fun `parseForUriOrPackageName should not use semantic lookup when standard lookup succeeds`() { + val firefoxPackage = "org.mozilla.firefox" + val url = "https://www.reddit.com" + val urlNode = mockk { + every { text } returns url + } + val rootNode = mockk { + every { packageName } returns firefoxPackage + every { findAccessibilityNodeInfosByViewId(any()) } returns emptyList() + every { + findAccessibilityNodeInfosByViewId( + "$firefoxPackage:id/mozac_browser_toolbar_url_view", + ) + } returns listOf(urlNode) + } + + val result = accessibilityParser.parseForUriOrPackageName(rootNode = rootNode) + + assertEquals(url.toUri(), result) + verify(exactly = 0) { + accessibilityNodeInfoManager.findAccessibilityNodeInfoList( + rootNode = any(), + predicate = any(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `parseForUriOrPackageName should return null when package is a supported browser with semantic ids and no URL bar is found`() { + val firefoxPackage = "org.mozilla.firefox" + val rootNode = mockk { + every { packageName } returns firefoxPackage + every { findAccessibilityNodeInfosByViewId(any()) } returns emptyList() + } + every { + accessibilityNodeInfoManager.findAccessibilityNodeInfoList( + rootNode = rootNode, + predicate = any(), + ) + } returns emptyList() + + val result = accessibilityParser.parseForUriOrPackageName(rootNode = rootNode) + + assertNull(result) + } + + @Suppress("MaxLineLength") + @Test + fun `parseForUriOrPackageName should return the site url from content description when URL bar is found via semantic id`() { + val firefoxPackage = "org.mozilla.firefox" + val contentDesc = " www.reddit.com. Search or enter address" + val urlNode = mockk { + every { text } returns null + every { contentDescription } returns contentDesc + } + val rootNode = mockk { + every { packageName } returns firefoxPackage + every { findAccessibilityNodeInfosByViewId(any()) } returns emptyList() + } + every { + accessibilityNodeInfoManager.findAccessibilityNodeInfoList( + rootNode = rootNode, + predicate = any(), + ) + } returns listOf(urlNode) + val expectedResult = "https://www.reddit.com".toUri() + + val result = accessibilityParser.parseForUriOrPackageName(rootNode = rootNode) + + assertEquals(expectedResult, result) + } + @Suppress("MaxLineLength") @Test fun `parseForUriOrPackageName should return the site url un-augmented with https protocol as a URI when package is a supported browser and URL is found`() {