From 197feea56a9ffa250b22e3cac1bc1ba028f6a32e Mon Sep 17 00:00:00 2001 From: Lucas Kivi <125697099+lucas-livefront@users.noreply.github.com> Date: Fri, 12 Jan 2024 12:45:56 -0600 Subject: [PATCH] Add URI generation algorithm to autofill parsing (#582) --- .../data/autofill/model/AutofillRequest.kt | 1 + .../data/autofill/model/AutofillView.kt | 36 +++ .../autofill/model/ViewNodeTraversalData.kt | 11 + .../autofill/parser/AutofillParserImpl.kt | 21 +- .../data/autofill/util/ViewNodeExtensions.kt | 29 +- .../util/ViewNodeTraversalDataExtensions.kt | 101 +++++++ .../autofill/builder/FilledDataBuilderTest.kt | 12 + .../autofill/parser/AutofillParserTests.kt | 47 +++ .../autofill/util/ViewNodeExtensionsTest.kt | 33 +++ .../ViewNodeTraversalDataExtensionsTest.kt | 270 ++++++++++++++++++ 10 files changed, 549 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/model/ViewNodeTraversalData.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt index 927ab1d3e..1b3bb7121 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt @@ -13,6 +13,7 @@ sealed class AutofillRequest { data class Fillable( val ignoreAutofillIds: List, val partition: AutofillPartition, + val uri: String?, ) : AutofillRequest() /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt index da894481a..f2907c3cc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt @@ -11,11 +11,26 @@ sealed class AutofillView { */ abstract val autofillId: AutofillId + /** + * The package id for this view, if there is one. (ex: "com.x8bit.bitwarden") + */ + abstract val idPackage: String? + /** * Whether the view is currently focused. */ abstract val isFocused: Boolean + /** + * The web domain for this view, if there is one. (ex: "m.facebook.com") + */ + abstract val webDomain: String? + + /** + * The web scheme for this view, if there is one. (ex: "https") + */ + abstract val webScheme: String? + /** * A view that corresponds to the card data partition for autofill fields. */ @@ -26,7 +41,10 @@ sealed class AutofillView { */ data class ExpirationMonth( override val autofillId: AutofillId, + override val idPackage: String?, override val isFocused: Boolean, + override val webDomain: String?, + override val webScheme: String?, ) : Card() /** @@ -34,7 +52,10 @@ sealed class AutofillView { */ data class ExpirationYear( override val autofillId: AutofillId, + override val idPackage: String?, override val isFocused: Boolean, + override val webDomain: String?, + override val webScheme: String?, ) : Card() /** @@ -42,7 +63,10 @@ sealed class AutofillView { */ data class Number( override val autofillId: AutofillId, + override val idPackage: String?, override val isFocused: Boolean, + override val webDomain: String?, + override val webScheme: String?, ) : Card() /** @@ -50,7 +74,10 @@ sealed class AutofillView { */ data class SecurityCode( override val autofillId: AutofillId, + override val idPackage: String?, override val isFocused: Boolean, + override val webDomain: String?, + override val webScheme: String?, ) : Card() } @@ -64,7 +91,10 @@ sealed class AutofillView { */ data class EmailAddress( override val autofillId: AutofillId, + override val idPackage: String?, override val isFocused: Boolean, + override val webDomain: String?, + override val webScheme: String?, ) : Login() /** @@ -72,7 +102,10 @@ sealed class AutofillView { */ data class Password( override val autofillId: AutofillId, + override val idPackage: String?, override val isFocused: Boolean, + override val webDomain: String?, + override val webScheme: String?, ) : Login() /** @@ -80,7 +113,10 @@ sealed class AutofillView { */ data class Username( override val autofillId: AutofillId, + override val idPackage: String?, override val isFocused: Boolean, + override val webDomain: String?, + override val webScheme: String?, ) : Login() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/ViewNodeTraversalData.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/ViewNodeTraversalData.kt new file mode 100644 index 000000000..b76aeca54 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/ViewNodeTraversalData.kt @@ -0,0 +1,11 @@ +package com.x8bit.bitwarden.data.autofill.model + +import android.view.autofill.AutofillId + +/** + * A convenience data structure for view node traversal. + */ +data class ViewNodeTraversalData( + val autofillViews: List, + val ignoreAutofillIds: List, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt index c45eb2689..c8dd330d5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt @@ -5,6 +5,8 @@ import android.view.autofill.AutofillId 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.buildUriOrNull import com.x8bit.bitwarden.data.autofill.util.toAutofillView /** @@ -14,9 +16,9 @@ import com.x8bit.bitwarden.data.autofill.util.toAutofillView class AutofillParserImpl : AutofillParser { override fun parse(assistStructure: AssistStructure): AutofillRequest { // Parse the `assistStructure` into internal models. - val traversalData = assistStructure.traverse() + val traversalDataList = assistStructure.traverse() // Flatten the autofill views for processing. - val autofillViews = traversalData + val autofillViews = traversalDataList .map { it.autofillViews } .flatten() @@ -25,6 +27,10 @@ class AutofillParserImpl : AutofillParser { .firstOrNull { it.isFocused } ?: return AutofillRequest.Unfillable + val uri = traversalDataList.buildUriOrNull( + assistStructure = assistStructure, + ) + // Choose the first focused partition of data for fulfillment. val partition = when (focusedView) { is AutofillView.Card -> { @@ -40,13 +46,14 @@ class AutofillParserImpl : AutofillParser { } } // Flatten the ignorable autofill ids. - val ignoreAutofillIds = traversalData + val ignoreAutofillIds = traversalDataList .map { it.ignoreAutofillIds } .flatten() return AutofillRequest.Fillable( ignoreAutofillIds = ignoreAutofillIds, partition = partition, + uri = uri, ) } } @@ -92,11 +99,3 @@ private fun AssistStructure.ViewNode.traverse(): ViewNodeTraversalData { ignoreAutofillIds = mutableIgnoreAutofillIdList, ) } - -/** - * A convenience data structure for view node traversal. - */ -private data class ViewNodeTraversalData( - val autofillViews: List, - val ignoreAutofillIds: List, -) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt index a561621a4..bc9e2a406 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt @@ -18,8 +18,11 @@ fun AssistStructure.ViewNode.toAutofillView(): AutofillView? = autofillId ?.let { supportedHint -> buildAutofillView( autofillId = nonNullAutofillId, + idPackage = idPackage, isFocused = isFocused, hint = supportedHint, + webDomain = webDomain, + webScheme = webScheme, ) } } @@ -27,58 +30,82 @@ fun AssistStructure.ViewNode.toAutofillView(): AutofillView? = autofillId /** * Convert the data into an [AutofillView] if the [hint] is supported. */ -@Suppress("LongMethod") +@Suppress("LongMethod", "LongParameterList") private fun buildAutofillView( autofillId: AutofillId, + idPackage: String?, isFocused: Boolean, hint: String, + webDomain: String?, + webScheme: String?, ): AutofillView? = when (hint) { View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> { AutofillView.Card.ExpirationMonth( autofillId = autofillId, + idPackage = idPackage, isFocused = isFocused, + webDomain = webDomain, + webScheme = webScheme, ) } View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> { AutofillView.Card.ExpirationYear( autofillId = autofillId, + idPackage = idPackage, isFocused = isFocused, + webDomain = webDomain, + webScheme = webScheme, ) } View.AUTOFILL_HINT_CREDIT_CARD_NUMBER -> { AutofillView.Card.Number( autofillId = autofillId, + idPackage = idPackage, isFocused = isFocused, + webDomain = webDomain, + webScheme = webScheme, ) } View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> { AutofillView.Card.SecurityCode( autofillId = autofillId, + idPackage = idPackage, isFocused = isFocused, + webDomain = webDomain, + webScheme = webScheme, ) } View.AUTOFILL_HINT_EMAIL_ADDRESS -> { AutofillView.Login.EmailAddress( autofillId = autofillId, + idPackage = idPackage, isFocused = isFocused, + webDomain = webDomain, + webScheme = webScheme, ) } View.AUTOFILL_HINT_PASSWORD -> { AutofillView.Login.Password( autofillId = autofillId, + idPackage = idPackage, isFocused = isFocused, + webDomain = webDomain, + webScheme = webScheme, ) } View.AUTOFILL_HINT_USERNAME -> { AutofillView.Login.Username( autofillId = autofillId, + idPackage = idPackage, isFocused = isFocused, + webDomain = webDomain, + webScheme = webScheme, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensions.kt new file mode 100644 index 000000000..8fea3d252 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensions.kt @@ -0,0 +1,101 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.app.assist.AssistStructure +import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData +import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank + +/** + * The android app URI scheme. Example: androidapp://com.x8bit.bitwarden + */ +private const val ANDROID_APP_SCHEME: String = "androidapp" + +/** + * The default web URI scheme. + */ +private const val DEFAULT_SCHEME: String = "https" + +/** + * Try and build a URI. The try progression looks like this: + * 1. Try searching traversal data for website URIs. + * 2. Try searching traversal data for package names, if one is found, convert it into a URI. + * 3. Try extracting a package name from [assistStructure], if one is found, convert it into a URI. + */ +@Suppress("ReturnCount") +fun List.buildUriOrNull( + assistStructure: AssistStructure, +): String? { + // Search list of [ViewNodeTraversalData] for a website URI. + buildWebsiteUriOrNull() + ?.let { websiteUri -> + return websiteUri + } + + // Search list of [ViewNodeTraversalData] for a valid package name. + buildPackageNameOrNull() + ?.let { packageName -> + return buildUri( + domain = packageName, + scheme = ANDROID_APP_SCHEME, + ) + } + + // Try getting the package name from the [AssistStructure] as a last ditch effort. + return assistStructure + .buildPackageNameOrNull() + ?.let { packageName -> + buildUri( + domain = packageName, + scheme = ANDROID_APP_SCHEME, + ) + } +} + +/** + * Combine [domain] and [scheme] into a URI. + */ +private fun buildUri( + domain: String, + scheme: String, +): String = "$scheme://$domain" + +/** + * Attempt to extract the package name from the title of the [AssistStructure]. + * + * As an example, the title might look like this: com.facebook.katana/com.facebook.bloks.facebook... + * Then this function would return: com.facebook.katana + */ +private fun AssistStructure.buildPackageNameOrNull(): String? = if (windowNodeCount > 0) { + getWindowNodeAt(0) + .title + ?.toString() + ?.orNullIfBlank() + ?.split('/') + ?.firstOrNull() +} else { + null +} + +/** + * Search each [ViewNodeTraversalData.autofillViews] list for a valid package id. If one is found + * return it and terminate the search. + */ +private fun List.buildPackageNameOrNull(): String? = + flatMap { it.autofillViews } + .firstOrNull { !it.idPackage.isNullOrEmpty() } + ?.idPackage + +/** + * Search each [ViewNodeTraversalData.autofillViews] list for a valid web domain. If one is found, + * combine it with its scheme and return it. + */ +private fun List.buildWebsiteUriOrNull(): String? = + flatMap { it.autofillViews } + .firstOrNull { !it.webDomain.isNullOrEmpty() } + ?.let { autofillView -> + val webDomain = requireNotNull(autofillView.webDomain) + val webScheme = autofillView.webScheme.orNullIfBlank() ?: DEFAULT_SCHEME + buildUri( + domain = webDomain, + scheme = webScheme, + ) + } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt index b51c8c52f..874779ae9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt @@ -27,7 +27,10 @@ class FilledDataBuilderTest { val autofillId: AutofillId = mockk() val autofillView = AutofillView.Login.Username( autofillId = autofillId, + idPackage = null, isFocused = false, + webDomain = null, + webScheme = null, ) val autofillPartition = AutofillPartition.Login( views = listOf(autofillView), @@ -36,6 +39,7 @@ class FilledDataBuilderTest { val autofillRequest = AutofillRequest.Fillable( ignoreAutofillIds = ignoreAutofillIds, partition = autofillPartition, + uri = URI, ) val filledItem = FilledItem( autofillId = autofillId, @@ -67,7 +71,10 @@ class FilledDataBuilderTest { val autofillId: AutofillId = mockk() val autofillView = AutofillView.Card.Number( autofillId = autofillId, + idPackage = null, isFocused = false, + webDomain = null, + webScheme = null, ) val autofillPartition = AutofillPartition.Card( views = listOf(autofillView), @@ -76,6 +83,7 @@ class FilledDataBuilderTest { val autofillRequest = AutofillRequest.Fillable( ignoreAutofillIds = ignoreAutofillIds, partition = autofillPartition, + uri = URI, ) val filledItem = FilledItem( autofillId = autofillId, @@ -100,4 +108,8 @@ class FilledDataBuilderTest { // Verify assertEquals(expected, actual) } + + companion object { + private const val URI: String = "androidapp://com.x8bit.bitwarden" + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt index 7ba4843b4..e0e33d5a3 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt @@ -6,11 +6,14 @@ import android.view.autofill.AutofillId 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.buildUriOrNull import com.x8bit.bitwarden.data.autofill.util.toAutofillView import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic +import io.mockk.verify import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach @@ -44,12 +47,15 @@ class AutofillParserTests { @BeforeEach fun setup() { mockkStatic(AssistStructure.ViewNode::toAutofillView) + mockkStatic(List::buildUriOrNull) + every { any>().buildUriOrNull(assistStructure) } returns URI parser = AutofillParserImpl() } @AfterEach fun teardown() { unmockkStatic(AssistStructure.ViewNode::toAutofillView) + unmockkStatic(List::buildUriOrNull) } @Test @@ -82,6 +88,9 @@ class AutofillParserTests { val parentAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth( autofillId = parentAutofillId, isFocused = true, + idPackage = null, + webDomain = null, + webScheme = null, ) val parentViewNode: AssistStructure.ViewNode = mockk { every { this@mockk.autofillHints } returns arrayOf(parentAutofillHint) @@ -99,6 +108,7 @@ class AutofillParserTests { val expected = AutofillRequest.Fillable( ignoreAutofillIds = listOf(childAutofillId), partition = autofillPartition, + uri = URI, ) every { assistStructure.windowNodeCount } returns 1 every { assistStructure.getWindowNodeAt(0) } returns windowNode @@ -108,6 +118,9 @@ class AutofillParserTests { // Verify assertEquals(expected, actual) + verify(exactly = 1) { + any>().buildUriOrNull(assistStructure) + } } @Test @@ -117,10 +130,16 @@ class AutofillParserTests { val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth( autofillId = cardAutofillId, isFocused = true, + idPackage = null, + webDomain = null, + webScheme = null, ) val loginAutofillView: AutofillView.Login = AutofillView.Login.Username( autofillId = loginAutofillId, isFocused = false, + idPackage = null, + webDomain = null, + webScheme = null, ) val autofillPartition = AutofillPartition.Card( views = listOf(cardAutofillView), @@ -128,6 +147,7 @@ class AutofillParserTests { val expected = AutofillRequest.Fillable( ignoreAutofillIds = emptyList(), partition = autofillPartition, + uri = URI, ) every { cardViewNode.toAutofillView() } returns cardAutofillView every { loginViewNode.toAutofillView() } returns loginAutofillView @@ -137,6 +157,9 @@ class AutofillParserTests { // Verify assertEquals(expected, actual) + verify(exactly = 1) { + any>().buildUriOrNull(assistStructure) + } } @Test @@ -146,10 +169,16 @@ class AutofillParserTests { val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth( autofillId = cardAutofillId, isFocused = false, + idPackage = null, + webDomain = null, + webScheme = null, ) val loginAutofillView: AutofillView.Login = AutofillView.Login.Username( autofillId = loginAutofillId, isFocused = true, + idPackage = null, + webDomain = null, + webScheme = null, ) val autofillPartition = AutofillPartition.Login( views = listOf(loginAutofillView), @@ -157,6 +186,7 @@ class AutofillParserTests { val expected = AutofillRequest.Fillable( ignoreAutofillIds = emptyList(), partition = autofillPartition, + uri = URI, ) every { cardViewNode.toAutofillView() } returns cardAutofillView every { loginViewNode.toAutofillView() } returns loginAutofillView @@ -166,6 +196,9 @@ class AutofillParserTests { // Verify assertEquals(expected, actual) + verify(exactly = 1) { + any>().buildUriOrNull(assistStructure) + } } @Test @@ -175,10 +208,16 @@ class AutofillParserTests { val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth( autofillId = cardAutofillId, isFocused = true, + idPackage = null, + webDomain = null, + webScheme = null, ) val loginAutofillView: AutofillView.Login = AutofillView.Login.Username( autofillId = loginAutofillId, isFocused = true, + idPackage = null, + webDomain = null, + webScheme = null, ) val autofillPartition = AutofillPartition.Card( views = listOf(cardAutofillView), @@ -186,6 +225,7 @@ class AutofillParserTests { val expected = AutofillRequest.Fillable( ignoreAutofillIds = emptyList(), partition = autofillPartition, + uri = URI, ) every { cardViewNode.toAutofillView() } returns cardAutofillView every { loginViewNode.toAutofillView() } returns loginAutofillView @@ -195,6 +235,9 @@ class AutofillParserTests { // Verify assertEquals(expected, actual) + verify(exactly = 1) { + any>().buildUriOrNull(assistStructure) + } } /** @@ -206,4 +249,8 @@ class AutofillParserTests { every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode every { assistStructure.getWindowNodeAt(1) } returns loginWindowNode } + + companion object { + private const val URI: String = "androidapp://com.x8bit.bitwarden" + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt index aab41c70c..caaff1de5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt @@ -16,7 +16,10 @@ class ViewNodeExtensionsTest { private val viewNode: AssistStructure.ViewNode = mockk { every { this@mockk.autofillId } returns expectedAutofillId every { this@mockk.childCount } returns 0 + every { this@mockk.idPackage } returns ID_PACKAGE every { this@mockk.isFocused } returns expectedIsFocused + every { this@mockk.webDomain } returns WEB_DOMAIN + every { this@mockk.webScheme } returns WEB_SCHEME } @Test @@ -25,7 +28,10 @@ class ViewNodeExtensionsTest { val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH val expected = AutofillView.Card.ExpirationMonth( autofillId = expectedAutofillId, + idPackage = ID_PACKAGE, isFocused = expectedIsFocused, + webDomain = WEB_DOMAIN, + webScheme = WEB_SCHEME, ) every { viewNode.autofillHints } returns arrayOf(autofillHint) @@ -42,7 +48,10 @@ class ViewNodeExtensionsTest { val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR val expected = AutofillView.Card.ExpirationYear( autofillId = expectedAutofillId, + idPackage = ID_PACKAGE, isFocused = expectedIsFocused, + webDomain = WEB_DOMAIN, + webScheme = WEB_SCHEME, ) every { viewNode.autofillHints } returns arrayOf(autofillHint) @@ -59,7 +68,10 @@ class ViewNodeExtensionsTest { val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_NUMBER val expected = AutofillView.Card.Number( autofillId = expectedAutofillId, + idPackage = ID_PACKAGE, isFocused = expectedIsFocused, + webDomain = WEB_DOMAIN, + webScheme = WEB_SCHEME, ) every { viewNode.autofillHints } returns arrayOf(autofillHint) @@ -76,7 +88,10 @@ class ViewNodeExtensionsTest { val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE val expected = AutofillView.Card.SecurityCode( autofillId = expectedAutofillId, + idPackage = ID_PACKAGE, isFocused = expectedIsFocused, + webDomain = WEB_DOMAIN, + webScheme = WEB_SCHEME, ) every { viewNode.autofillHints } returns arrayOf(autofillHint) @@ -93,7 +108,10 @@ class ViewNodeExtensionsTest { val autofillHint = View.AUTOFILL_HINT_EMAIL_ADDRESS val expected = AutofillView.Login.EmailAddress( autofillId = expectedAutofillId, + idPackage = ID_PACKAGE, isFocused = expectedIsFocused, + webDomain = WEB_DOMAIN, + webScheme = WEB_SCHEME, ) every { viewNode.autofillHints } returns arrayOf(autofillHint) @@ -110,7 +128,10 @@ class ViewNodeExtensionsTest { val autofillHint = View.AUTOFILL_HINT_PASSWORD val expected = AutofillView.Login.Password( autofillId = expectedAutofillId, + idPackage = ID_PACKAGE, isFocused = expectedIsFocused, + webDomain = WEB_DOMAIN, + webScheme = WEB_SCHEME, ) every { viewNode.autofillHints } returns arrayOf(autofillHint) @@ -127,7 +148,10 @@ class ViewNodeExtensionsTest { val autofillHint = View.AUTOFILL_HINT_USERNAME val expected = AutofillView.Login.Username( autofillId = expectedAutofillId, + idPackage = ID_PACKAGE, isFocused = expectedIsFocused, + webDomain = WEB_DOMAIN, + webScheme = WEB_SCHEME, ) every { viewNode.autofillHints } returns arrayOf(autofillHint) @@ -158,7 +182,10 @@ class ViewNodeExtensionsTest { val autofillHintTwo = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR val expected = AutofillView.Card.ExpirationYear( autofillId = expectedAutofillId, + idPackage = ID_PACKAGE, isFocused = expectedIsFocused, + webDomain = WEB_DOMAIN, + webScheme = WEB_SCHEME, ) every { viewNode.autofillHints } returns arrayOf(autofillHintOne, autofillHintTwo) @@ -168,4 +195,10 @@ class ViewNodeExtensionsTest { // Verify assertEquals(expected, actual) } + + companion object { + private const val ID_PACKAGE: String = "ID_PACKAGE" + private const val WEB_DOMAIN: String = "WEB_DOMAIN" + private const val WEB_SCHEME: String = "WEB_SCHEME" + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt new file mode 100644 index 000000000..5ad200c72 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt @@ -0,0 +1,270 @@ +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.ViewNodeTraversalData +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class ViewNodeTraversalDataExtensionsTest { + private val windowNode: AssistStructure.WindowNode = mockk() + private val assistStructure: AssistStructure = mockk { + every { this@mockk.windowNodeCount } returns 1 + every { this@mockk.getWindowNodeAt(0) } returns windowNode + } + + @Test + fun `buildUriOrNull should return URI when contains valid domain and scheme`() { + // Setup + val autofillView = AutofillView.Card.Number( + autofillId = mockk(), + idPackage = null, + isFocused = false, + webDomain = WEB_DOMAIN, + webScheme = WEB_SCHEME, + ) + val viewNodeTraversalData = ViewNodeTraversalData( + autofillViews = listOf( + autofillView, + ), + ignoreAutofillIds = emptyList(), + ) + val expected = "$WEB_SCHEME://$WEB_DOMAIN" + + // Test + val actual = listOf(viewNodeTraversalData).buildUriOrNull( + assistStructure = assistStructure, + ) + + // Verify + assertEquals(expected, actual) + } + + @Test + fun `buildUriOrNull should return URI with default scheme when domain valid and scheme null`() { + // Setup + val autofillView = AutofillView.Card.Number( + autofillId = mockk(), + idPackage = null, + isFocused = false, + webDomain = WEB_DOMAIN, + webScheme = null, + ) + val viewNodeTraversalData = ViewNodeTraversalData( + autofillViews = listOf( + autofillView, + ), + ignoreAutofillIds = emptyList(), + ) + val expected = "https://$WEB_DOMAIN" + + // Test + val actual = listOf(viewNodeTraversalData).buildUriOrNull( + assistStructure = assistStructure, + ) + + // Verify + assertEquals(expected, actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildUriOrNull should return URI with default scheme when domain valid and scheme empty`() { + // Setup + val autofillView = AutofillView.Card.Number( + autofillId = mockk(), + idPackage = null, + isFocused = false, + webDomain = WEB_DOMAIN, + webScheme = "", + ) + val viewNodeTraversalData = ViewNodeTraversalData( + autofillViews = listOf( + autofillView, + ), + ignoreAutofillIds = emptyList(), + ) + val expected = "https://$WEB_DOMAIN" + + // Test + val actual = listOf(viewNodeTraversalData).buildUriOrNull( + assistStructure = assistStructure, + ) + + // Verify + assertEquals(expected, actual) + } + + @Test + fun `buildUriOrNull should return idPackage URI when domain is null`() { + // Setup + val autofillView = AutofillView.Card.Number( + autofillId = mockk(), + idPackage = ID_PACKAGE, + isFocused = false, + webDomain = null, + webScheme = null, + ) + val viewNodeTraversalData = ViewNodeTraversalData( + autofillViews = listOf( + autofillView, + ), + ignoreAutofillIds = emptyList(), + ) + val expected = "androidapp://$ID_PACKAGE" + + // Test + val actual = listOf(viewNodeTraversalData).buildUriOrNull( + assistStructure = assistStructure, + ) + + // Verify + assertEquals(expected, actual) + } + + @Test + fun `buildUriOrNull should return idPackage URI when domain is empty`() { + // Setup + val autofillView = AutofillView.Card.Number( + autofillId = mockk(), + idPackage = ID_PACKAGE, + isFocused = false, + webDomain = null, + webScheme = null, + ) + val viewNodeTraversalData = ViewNodeTraversalData( + autofillViews = listOf( + autofillView, + ), + ignoreAutofillIds = emptyList(), + ) + val expected = "androidapp://$ID_PACKAGE" + + // Test + val actual = listOf(viewNodeTraversalData).buildUriOrNull( + assistStructure = assistStructure, + ) + + // Verify + assertEquals(expected, actual) + } + + @Test + fun `buildUriOrNull should return title URI when domain and idPackage are null`() { + // Setup + val autofillView = AutofillView.Card.Number( + autofillId = mockk(), + idPackage = null, + isFocused = false, + webDomain = null, + webScheme = null, + ) + val viewNodeTraversalData = ViewNodeTraversalData( + autofillViews = listOf( + autofillView, + ), + ignoreAutofillIds = emptyList(), + ) + val expected = "androidapp://com.x8bit.bitwarden" + every { windowNode.title } returns "com.x8bit.bitwarden/path.deeper.into.app" + + // Test + val actual = listOf(viewNodeTraversalData).buildUriOrNull( + assistStructure = assistStructure, + ) + + // Verify + assertEquals(expected, actual) + } + + @Test + fun `buildUriOrNull should return title URI when domain and idPackage are empty`() { + // Setup + val autofillView = AutofillView.Card.Number( + autofillId = mockk(), + idPackage = "", + isFocused = false, + webDomain = "", + webScheme = null, + ) + val viewNodeTraversalData = ViewNodeTraversalData( + autofillViews = listOf( + autofillView, + ), + ignoreAutofillIds = emptyList(), + ) + val expected = "androidapp://com.x8bit.bitwarden" + every { windowNode.title } returns "com.x8bit.bitwarden/path.deeper.into.app" + + // Test + val actual = listOf(viewNodeTraversalData).buildUriOrNull( + assistStructure = assistStructure, + ) + + // Verify + assertEquals(expected, actual) + } + + @Test + fun `buildUriOrNull should return null when title, domain, and idPackage are null`() { + // Setup + val autofillView = AutofillView.Card.Number( + autofillId = mockk(), + idPackage = null, + isFocused = false, + webDomain = null, + webScheme = null, + ) + val viewNodeTraversalData = ViewNodeTraversalData( + autofillViews = listOf( + autofillView, + ), + ignoreAutofillIds = emptyList(), + ) + every { windowNode.title } returns null + + // Test + val actual = listOf(viewNodeTraversalData).buildUriOrNull( + assistStructure = assistStructure, + ) + + // Verify + assertNull(actual) + } + + @Test + fun `buildUriOrNull should return null when title, domain, and idPackage are empty`() { + // Setup + val autofillView = AutofillView.Card.Number( + autofillId = mockk(), + idPackage = "", + isFocused = false, + webDomain = "", + webScheme = null, + ) + val viewNodeTraversalData = ViewNodeTraversalData( + autofillViews = listOf( + autofillView, + ), + ignoreAutofillIds = emptyList(), + ) + every { windowNode.title } returns "" + + // Test + val actual = listOf(viewNodeTraversalData).buildUriOrNull( + assistStructure = assistStructure, + ) + + // Verify + assertNull(actual) + } + + companion object { + private const val ID_PACKAGE: String = "com.x8bit.bitwarden" + private const val WEB_DOMAIN: String = "www.google.com" + private const val WEB_SCHEME: String = "https" + } +}