Add URI generation algorithm to autofill parsing (#582)

This commit is contained in:
Lucas Kivi 2024-01-12 12:45:56 -06:00 committed by Álison Fernandes
parent e9e538db59
commit 197feea56a
10 changed files with 549 additions and 12 deletions

View file

@ -13,6 +13,7 @@ sealed class AutofillRequest {
data class Fillable( data class Fillable(
val ignoreAutofillIds: List<AutofillId>, val ignoreAutofillIds: List<AutofillId>,
val partition: AutofillPartition, val partition: AutofillPartition,
val uri: String?,
) : AutofillRequest() ) : AutofillRequest()
/** /**

View file

@ -11,11 +11,26 @@ sealed class AutofillView {
*/ */
abstract val autofillId: AutofillId 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. * Whether the view is currently focused.
*/ */
abstract val isFocused: Boolean 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. * A view that corresponds to the card data partition for autofill fields.
*/ */
@ -26,7 +41,10 @@ sealed class AutofillView {
*/ */
data class ExpirationMonth( data class ExpirationMonth(
override val autofillId: AutofillId, override val autofillId: AutofillId,
override val idPackage: String?,
override val isFocused: Boolean, override val isFocused: Boolean,
override val webDomain: String?,
override val webScheme: String?,
) : Card() ) : Card()
/** /**
@ -34,7 +52,10 @@ sealed class AutofillView {
*/ */
data class ExpirationYear( data class ExpirationYear(
override val autofillId: AutofillId, override val autofillId: AutofillId,
override val idPackage: String?,
override val isFocused: Boolean, override val isFocused: Boolean,
override val webDomain: String?,
override val webScheme: String?,
) : Card() ) : Card()
/** /**
@ -42,7 +63,10 @@ sealed class AutofillView {
*/ */
data class Number( data class Number(
override val autofillId: AutofillId, override val autofillId: AutofillId,
override val idPackage: String?,
override val isFocused: Boolean, override val isFocused: Boolean,
override val webDomain: String?,
override val webScheme: String?,
) : Card() ) : Card()
/** /**
@ -50,7 +74,10 @@ sealed class AutofillView {
*/ */
data class SecurityCode( data class SecurityCode(
override val autofillId: AutofillId, override val autofillId: AutofillId,
override val idPackage: String?,
override val isFocused: Boolean, override val isFocused: Boolean,
override val webDomain: String?,
override val webScheme: String?,
) : Card() ) : Card()
} }
@ -64,7 +91,10 @@ sealed class AutofillView {
*/ */
data class EmailAddress( data class EmailAddress(
override val autofillId: AutofillId, override val autofillId: AutofillId,
override val idPackage: String?,
override val isFocused: Boolean, override val isFocused: Boolean,
override val webDomain: String?,
override val webScheme: String?,
) : Login() ) : Login()
/** /**
@ -72,7 +102,10 @@ sealed class AutofillView {
*/ */
data class Password( data class Password(
override val autofillId: AutofillId, override val autofillId: AutofillId,
override val idPackage: String?,
override val isFocused: Boolean, override val isFocused: Boolean,
override val webDomain: String?,
override val webScheme: String?,
) : Login() ) : Login()
/** /**
@ -80,7 +113,10 @@ sealed class AutofillView {
*/ */
data class Username( data class Username(
override val autofillId: AutofillId, override val autofillId: AutofillId,
override val idPackage: String?,
override val isFocused: Boolean, override val isFocused: Boolean,
override val webDomain: String?,
override val webScheme: String?,
) : Login() ) : Login()
} }
} }

View file

@ -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<AutofillView>,
val ignoreAutofillIds: List<AutofillId>,
)

View file

@ -5,6 +5,8 @@ import android.view.autofill.AutofillId
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillView 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 com.x8bit.bitwarden.data.autofill.util.toAutofillView
/** /**
@ -14,9 +16,9 @@ import com.x8bit.bitwarden.data.autofill.util.toAutofillView
class AutofillParserImpl : AutofillParser { class AutofillParserImpl : AutofillParser {
override fun parse(assistStructure: AssistStructure): AutofillRequest { override fun parse(assistStructure: AssistStructure): AutofillRequest {
// Parse the `assistStructure` into internal models. // Parse the `assistStructure` into internal models.
val traversalData = assistStructure.traverse() val traversalDataList = assistStructure.traverse()
// Flatten the autofill views for processing. // Flatten the autofill views for processing.
val autofillViews = traversalData val autofillViews = traversalDataList
.map { it.autofillViews } .map { it.autofillViews }
.flatten() .flatten()
@ -25,6 +27,10 @@ class AutofillParserImpl : AutofillParser {
.firstOrNull { it.isFocused } .firstOrNull { it.isFocused }
?: return AutofillRequest.Unfillable ?: return AutofillRequest.Unfillable
val uri = traversalDataList.buildUriOrNull(
assistStructure = assistStructure,
)
// Choose the first focused partition of data for fulfillment. // Choose the first focused partition of data for fulfillment.
val partition = when (focusedView) { val partition = when (focusedView) {
is AutofillView.Card -> { is AutofillView.Card -> {
@ -40,13 +46,14 @@ class AutofillParserImpl : AutofillParser {
} }
} }
// Flatten the ignorable autofill ids. // Flatten the ignorable autofill ids.
val ignoreAutofillIds = traversalData val ignoreAutofillIds = traversalDataList
.map { it.ignoreAutofillIds } .map { it.ignoreAutofillIds }
.flatten() .flatten()
return AutofillRequest.Fillable( return AutofillRequest.Fillable(
ignoreAutofillIds = ignoreAutofillIds, ignoreAutofillIds = ignoreAutofillIds,
partition = partition, partition = partition,
uri = uri,
) )
} }
} }
@ -92,11 +99,3 @@ private fun AssistStructure.ViewNode.traverse(): ViewNodeTraversalData {
ignoreAutofillIds = mutableIgnoreAutofillIdList, ignoreAutofillIds = mutableIgnoreAutofillIdList,
) )
} }
/**
* A convenience data structure for view node traversal.
*/
private data class ViewNodeTraversalData(
val autofillViews: List<AutofillView>,
val ignoreAutofillIds: List<AutofillId>,
)

View file

@ -18,8 +18,11 @@ fun AssistStructure.ViewNode.toAutofillView(): AutofillView? = autofillId
?.let { supportedHint -> ?.let { supportedHint ->
buildAutofillView( buildAutofillView(
autofillId = nonNullAutofillId, autofillId = nonNullAutofillId,
idPackage = idPackage,
isFocused = isFocused, isFocused = isFocused,
hint = supportedHint, 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. * Convert the data into an [AutofillView] if the [hint] is supported.
*/ */
@Suppress("LongMethod") @Suppress("LongMethod", "LongParameterList")
private fun buildAutofillView( private fun buildAutofillView(
autofillId: AutofillId, autofillId: AutofillId,
idPackage: String?,
isFocused: Boolean, isFocused: Boolean,
hint: String, hint: String,
webDomain: String?,
webScheme: String?,
): AutofillView? = when (hint) { ): AutofillView? = when (hint) {
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> { View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> {
AutofillView.Card.ExpirationMonth( AutofillView.Card.ExpirationMonth(
autofillId = autofillId, autofillId = autofillId,
idPackage = idPackage,
isFocused = isFocused, isFocused = isFocused,
webDomain = webDomain,
webScheme = webScheme,
) )
} }
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> { View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> {
AutofillView.Card.ExpirationYear( AutofillView.Card.ExpirationYear(
autofillId = autofillId, autofillId = autofillId,
idPackage = idPackage,
isFocused = isFocused, isFocused = isFocused,
webDomain = webDomain,
webScheme = webScheme,
) )
} }
View.AUTOFILL_HINT_CREDIT_CARD_NUMBER -> { View.AUTOFILL_HINT_CREDIT_CARD_NUMBER -> {
AutofillView.Card.Number( AutofillView.Card.Number(
autofillId = autofillId, autofillId = autofillId,
idPackage = idPackage,
isFocused = isFocused, isFocused = isFocused,
webDomain = webDomain,
webScheme = webScheme,
) )
} }
View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> { View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> {
AutofillView.Card.SecurityCode( AutofillView.Card.SecurityCode(
autofillId = autofillId, autofillId = autofillId,
idPackage = idPackage,
isFocused = isFocused, isFocused = isFocused,
webDomain = webDomain,
webScheme = webScheme,
) )
} }
View.AUTOFILL_HINT_EMAIL_ADDRESS -> { View.AUTOFILL_HINT_EMAIL_ADDRESS -> {
AutofillView.Login.EmailAddress( AutofillView.Login.EmailAddress(
autofillId = autofillId, autofillId = autofillId,
idPackage = idPackage,
isFocused = isFocused, isFocused = isFocused,
webDomain = webDomain,
webScheme = webScheme,
) )
} }
View.AUTOFILL_HINT_PASSWORD -> { View.AUTOFILL_HINT_PASSWORD -> {
AutofillView.Login.Password( AutofillView.Login.Password(
autofillId = autofillId, autofillId = autofillId,
idPackage = idPackage,
isFocused = isFocused, isFocused = isFocused,
webDomain = webDomain,
webScheme = webScheme,
) )
} }
View.AUTOFILL_HINT_USERNAME -> { View.AUTOFILL_HINT_USERNAME -> {
AutofillView.Login.Username( AutofillView.Login.Username(
autofillId = autofillId, autofillId = autofillId,
idPackage = idPackage,
isFocused = isFocused, isFocused = isFocused,
webDomain = webDomain,
webScheme = webScheme,
) )
} }

View file

@ -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<ViewNodeTraversalData>.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<ViewNodeTraversalData>.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<ViewNodeTraversalData>.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,
)
}

View file

@ -27,7 +27,10 @@ class FilledDataBuilderTest {
val autofillId: AutofillId = mockk() val autofillId: AutofillId = mockk()
val autofillView = AutofillView.Login.Username( val autofillView = AutofillView.Login.Username(
autofillId = autofillId, autofillId = autofillId,
idPackage = null,
isFocused = false, isFocused = false,
webDomain = null,
webScheme = null,
) )
val autofillPartition = AutofillPartition.Login( val autofillPartition = AutofillPartition.Login(
views = listOf(autofillView), views = listOf(autofillView),
@ -36,6 +39,7 @@ class FilledDataBuilderTest {
val autofillRequest = AutofillRequest.Fillable( val autofillRequest = AutofillRequest.Fillable(
ignoreAutofillIds = ignoreAutofillIds, ignoreAutofillIds = ignoreAutofillIds,
partition = autofillPartition, partition = autofillPartition,
uri = URI,
) )
val filledItem = FilledItem( val filledItem = FilledItem(
autofillId = autofillId, autofillId = autofillId,
@ -67,7 +71,10 @@ class FilledDataBuilderTest {
val autofillId: AutofillId = mockk() val autofillId: AutofillId = mockk()
val autofillView = AutofillView.Card.Number( val autofillView = AutofillView.Card.Number(
autofillId = autofillId, autofillId = autofillId,
idPackage = null,
isFocused = false, isFocused = false,
webDomain = null,
webScheme = null,
) )
val autofillPartition = AutofillPartition.Card( val autofillPartition = AutofillPartition.Card(
views = listOf(autofillView), views = listOf(autofillView),
@ -76,6 +83,7 @@ class FilledDataBuilderTest {
val autofillRequest = AutofillRequest.Fillable( val autofillRequest = AutofillRequest.Fillable(
ignoreAutofillIds = ignoreAutofillIds, ignoreAutofillIds = ignoreAutofillIds,
partition = autofillPartition, partition = autofillPartition,
uri = URI,
) )
val filledItem = FilledItem( val filledItem = FilledItem(
autofillId = autofillId, autofillId = autofillId,
@ -100,4 +108,8 @@ class FilledDataBuilderTest {
// Verify // Verify
assertEquals(expected, actual) assertEquals(expected, actual)
} }
companion object {
private const val URI: String = "androidapp://com.x8bit.bitwarden"
}
} }

View file

@ -6,11 +6,14 @@ import android.view.autofill.AutofillId
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillView 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 com.x8bit.bitwarden.data.autofill.util.toAutofillView
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkStatic import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
@ -44,12 +47,15 @@ class AutofillParserTests {
@BeforeEach @BeforeEach
fun setup() { fun setup() {
mockkStatic(AssistStructure.ViewNode::toAutofillView) mockkStatic(AssistStructure.ViewNode::toAutofillView)
mockkStatic(List<ViewNodeTraversalData>::buildUriOrNull)
every { any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure) } returns URI
parser = AutofillParserImpl() parser = AutofillParserImpl()
} }
@AfterEach @AfterEach
fun teardown() { fun teardown() {
unmockkStatic(AssistStructure.ViewNode::toAutofillView) unmockkStatic(AssistStructure.ViewNode::toAutofillView)
unmockkStatic(List<ViewNodeTraversalData>::buildUriOrNull)
} }
@Test @Test
@ -82,6 +88,9 @@ class AutofillParserTests {
val parentAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth( val parentAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
autofillId = parentAutofillId, autofillId = parentAutofillId,
isFocused = true, isFocused = true,
idPackage = null,
webDomain = null,
webScheme = null,
) )
val parentViewNode: AssistStructure.ViewNode = mockk { val parentViewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillHints } returns arrayOf(parentAutofillHint) every { this@mockk.autofillHints } returns arrayOf(parentAutofillHint)
@ -99,6 +108,7 @@ class AutofillParserTests {
val expected = AutofillRequest.Fillable( val expected = AutofillRequest.Fillable(
ignoreAutofillIds = listOf(childAutofillId), ignoreAutofillIds = listOf(childAutofillId),
partition = autofillPartition, partition = autofillPartition,
uri = URI,
) )
every { assistStructure.windowNodeCount } returns 1 every { assistStructure.windowNodeCount } returns 1
every { assistStructure.getWindowNodeAt(0) } returns windowNode every { assistStructure.getWindowNodeAt(0) } returns windowNode
@ -108,6 +118,9 @@ class AutofillParserTests {
// Verify // Verify
assertEquals(expected, actual) assertEquals(expected, actual)
verify(exactly = 1) {
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
}
} }
@Test @Test
@ -117,10 +130,16 @@ class AutofillParserTests {
val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth( val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
autofillId = cardAutofillId, autofillId = cardAutofillId,
isFocused = true, isFocused = true,
idPackage = null,
webDomain = null,
webScheme = null,
) )
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username( val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
autofillId = loginAutofillId, autofillId = loginAutofillId,
isFocused = false, isFocused = false,
idPackage = null,
webDomain = null,
webScheme = null,
) )
val autofillPartition = AutofillPartition.Card( val autofillPartition = AutofillPartition.Card(
views = listOf(cardAutofillView), views = listOf(cardAutofillView),
@ -128,6 +147,7 @@ class AutofillParserTests {
val expected = AutofillRequest.Fillable( val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(), ignoreAutofillIds = emptyList(),
partition = autofillPartition, partition = autofillPartition,
uri = URI,
) )
every { cardViewNode.toAutofillView() } returns cardAutofillView every { cardViewNode.toAutofillView() } returns cardAutofillView
every { loginViewNode.toAutofillView() } returns loginAutofillView every { loginViewNode.toAutofillView() } returns loginAutofillView
@ -137,6 +157,9 @@ class AutofillParserTests {
// Verify // Verify
assertEquals(expected, actual) assertEquals(expected, actual)
verify(exactly = 1) {
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
}
} }
@Test @Test
@ -146,10 +169,16 @@ class AutofillParserTests {
val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth( val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
autofillId = cardAutofillId, autofillId = cardAutofillId,
isFocused = false, isFocused = false,
idPackage = null,
webDomain = null,
webScheme = null,
) )
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username( val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
autofillId = loginAutofillId, autofillId = loginAutofillId,
isFocused = true, isFocused = true,
idPackage = null,
webDomain = null,
webScheme = null,
) )
val autofillPartition = AutofillPartition.Login( val autofillPartition = AutofillPartition.Login(
views = listOf(loginAutofillView), views = listOf(loginAutofillView),
@ -157,6 +186,7 @@ class AutofillParserTests {
val expected = AutofillRequest.Fillable( val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(), ignoreAutofillIds = emptyList(),
partition = autofillPartition, partition = autofillPartition,
uri = URI,
) )
every { cardViewNode.toAutofillView() } returns cardAutofillView every { cardViewNode.toAutofillView() } returns cardAutofillView
every { loginViewNode.toAutofillView() } returns loginAutofillView every { loginViewNode.toAutofillView() } returns loginAutofillView
@ -166,6 +196,9 @@ class AutofillParserTests {
// Verify // Verify
assertEquals(expected, actual) assertEquals(expected, actual)
verify(exactly = 1) {
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
}
} }
@Test @Test
@ -175,10 +208,16 @@ class AutofillParserTests {
val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth( val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
autofillId = cardAutofillId, autofillId = cardAutofillId,
isFocused = true, isFocused = true,
idPackage = null,
webDomain = null,
webScheme = null,
) )
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username( val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
autofillId = loginAutofillId, autofillId = loginAutofillId,
isFocused = true, isFocused = true,
idPackage = null,
webDomain = null,
webScheme = null,
) )
val autofillPartition = AutofillPartition.Card( val autofillPartition = AutofillPartition.Card(
views = listOf(cardAutofillView), views = listOf(cardAutofillView),
@ -186,6 +225,7 @@ class AutofillParserTests {
val expected = AutofillRequest.Fillable( val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(), ignoreAutofillIds = emptyList(),
partition = autofillPartition, partition = autofillPartition,
uri = URI,
) )
every { cardViewNode.toAutofillView() } returns cardAutofillView every { cardViewNode.toAutofillView() } returns cardAutofillView
every { loginViewNode.toAutofillView() } returns loginAutofillView every { loginViewNode.toAutofillView() } returns loginAutofillView
@ -195,6 +235,9 @@ class AutofillParserTests {
// Verify // Verify
assertEquals(expected, actual) assertEquals(expected, actual)
verify(exactly = 1) {
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
}
} }
/** /**
@ -206,4 +249,8 @@ class AutofillParserTests {
every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode
every { assistStructure.getWindowNodeAt(1) } returns loginWindowNode every { assistStructure.getWindowNodeAt(1) } returns loginWindowNode
} }
companion object {
private const val URI: String = "androidapp://com.x8bit.bitwarden"
}
} }

View file

@ -16,7 +16,10 @@ class ViewNodeExtensionsTest {
private val viewNode: AssistStructure.ViewNode = mockk { private val viewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillId } returns expectedAutofillId every { this@mockk.autofillId } returns expectedAutofillId
every { this@mockk.childCount } returns 0 every { this@mockk.childCount } returns 0
every { this@mockk.idPackage } returns ID_PACKAGE
every { this@mockk.isFocused } returns expectedIsFocused every { this@mockk.isFocused } returns expectedIsFocused
every { this@mockk.webDomain } returns WEB_DOMAIN
every { this@mockk.webScheme } returns WEB_SCHEME
} }
@Test @Test
@ -25,7 +28,10 @@ class ViewNodeExtensionsTest {
val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH
val expected = AutofillView.Card.ExpirationMonth( val expected = AutofillView.Card.ExpirationMonth(
autofillId = expectedAutofillId, autofillId = expectedAutofillId,
idPackage = ID_PACKAGE,
isFocused = expectedIsFocused, isFocused = expectedIsFocused,
webDomain = WEB_DOMAIN,
webScheme = WEB_SCHEME,
) )
every { viewNode.autofillHints } returns arrayOf(autofillHint) every { viewNode.autofillHints } returns arrayOf(autofillHint)
@ -42,7 +48,10 @@ class ViewNodeExtensionsTest {
val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR
val expected = AutofillView.Card.ExpirationYear( val expected = AutofillView.Card.ExpirationYear(
autofillId = expectedAutofillId, autofillId = expectedAutofillId,
idPackage = ID_PACKAGE,
isFocused = expectedIsFocused, isFocused = expectedIsFocused,
webDomain = WEB_DOMAIN,
webScheme = WEB_SCHEME,
) )
every { viewNode.autofillHints } returns arrayOf(autofillHint) every { viewNode.autofillHints } returns arrayOf(autofillHint)
@ -59,7 +68,10 @@ class ViewNodeExtensionsTest {
val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_NUMBER val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_NUMBER
val expected = AutofillView.Card.Number( val expected = AutofillView.Card.Number(
autofillId = expectedAutofillId, autofillId = expectedAutofillId,
idPackage = ID_PACKAGE,
isFocused = expectedIsFocused, isFocused = expectedIsFocused,
webDomain = WEB_DOMAIN,
webScheme = WEB_SCHEME,
) )
every { viewNode.autofillHints } returns arrayOf(autofillHint) every { viewNode.autofillHints } returns arrayOf(autofillHint)
@ -76,7 +88,10 @@ class ViewNodeExtensionsTest {
val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE
val expected = AutofillView.Card.SecurityCode( val expected = AutofillView.Card.SecurityCode(
autofillId = expectedAutofillId, autofillId = expectedAutofillId,
idPackage = ID_PACKAGE,
isFocused = expectedIsFocused, isFocused = expectedIsFocused,
webDomain = WEB_DOMAIN,
webScheme = WEB_SCHEME,
) )
every { viewNode.autofillHints } returns arrayOf(autofillHint) every { viewNode.autofillHints } returns arrayOf(autofillHint)
@ -93,7 +108,10 @@ class ViewNodeExtensionsTest {
val autofillHint = View.AUTOFILL_HINT_EMAIL_ADDRESS val autofillHint = View.AUTOFILL_HINT_EMAIL_ADDRESS
val expected = AutofillView.Login.EmailAddress( val expected = AutofillView.Login.EmailAddress(
autofillId = expectedAutofillId, autofillId = expectedAutofillId,
idPackage = ID_PACKAGE,
isFocused = expectedIsFocused, isFocused = expectedIsFocused,
webDomain = WEB_DOMAIN,
webScheme = WEB_SCHEME,
) )
every { viewNode.autofillHints } returns arrayOf(autofillHint) every { viewNode.autofillHints } returns arrayOf(autofillHint)
@ -110,7 +128,10 @@ class ViewNodeExtensionsTest {
val autofillHint = View.AUTOFILL_HINT_PASSWORD val autofillHint = View.AUTOFILL_HINT_PASSWORD
val expected = AutofillView.Login.Password( val expected = AutofillView.Login.Password(
autofillId = expectedAutofillId, autofillId = expectedAutofillId,
idPackage = ID_PACKAGE,
isFocused = expectedIsFocused, isFocused = expectedIsFocused,
webDomain = WEB_DOMAIN,
webScheme = WEB_SCHEME,
) )
every { viewNode.autofillHints } returns arrayOf(autofillHint) every { viewNode.autofillHints } returns arrayOf(autofillHint)
@ -127,7 +148,10 @@ class ViewNodeExtensionsTest {
val autofillHint = View.AUTOFILL_HINT_USERNAME val autofillHint = View.AUTOFILL_HINT_USERNAME
val expected = AutofillView.Login.Username( val expected = AutofillView.Login.Username(
autofillId = expectedAutofillId, autofillId = expectedAutofillId,
idPackage = ID_PACKAGE,
isFocused = expectedIsFocused, isFocused = expectedIsFocused,
webDomain = WEB_DOMAIN,
webScheme = WEB_SCHEME,
) )
every { viewNode.autofillHints } returns arrayOf(autofillHint) every { viewNode.autofillHints } returns arrayOf(autofillHint)
@ -158,7 +182,10 @@ class ViewNodeExtensionsTest {
val autofillHintTwo = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR val autofillHintTwo = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR
val expected = AutofillView.Card.ExpirationYear( val expected = AutofillView.Card.ExpirationYear(
autofillId = expectedAutofillId, autofillId = expectedAutofillId,
idPackage = ID_PACKAGE,
isFocused = expectedIsFocused, isFocused = expectedIsFocused,
webDomain = WEB_DOMAIN,
webScheme = WEB_SCHEME,
) )
every { viewNode.autofillHints } returns arrayOf(autofillHintOne, autofillHintTwo) every { viewNode.autofillHints } returns arrayOf(autofillHintOne, autofillHintTwo)
@ -168,4 +195,10 @@ class ViewNodeExtensionsTest {
// Verify // Verify
assertEquals(expected, actual) 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"
}
} }

View file

@ -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"
}
}