BIT-1314: Add autofill node traversal with basic UI fulfillment (#541)

This commit is contained in:
Lucas Kivi 2024-01-08 18:43:57 -06:00 committed by Álison Fernandes
parent 35ef4e4252
commit cea26f5e32
30 changed files with 2016 additions and 55 deletions

View file

@ -186,6 +186,7 @@ koverReport {
"*.*DefaultImpls*",
// OS-level components
"com.x8bit.bitwarden.BitwardenApplication",
"com.x8bit.bitwarden.data.autofill.BitwardenAutofillService*",
"com.x8bit.bitwarden.MainActivity*",
// Empty Composables
"com.x8bit.bitwarden.ui.platform.feature.splash.SplashScreenKt",

View file

@ -1,12 +1,16 @@
package com.x8bit.bitwarden.data.autofill
import android.os.Build
import android.os.CancellationSignal
import android.service.autofill.AutofillService
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import android.service.autofill.SaveCallback
import android.service.autofill.SaveRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* The [AutofillService] implementation for the app. This fulfills autofill requests from other
@ -14,12 +18,29 @@ import dagger.hilt.android.AndroidEntryPoint
*/
@AndroidEntryPoint
class BitwardenAutofillService : AutofillService() {
/**
* A processor to handle the autofill fulfillment. We want to keep this service light because
* it isn't easily tested.
*/
@Inject
lateinit var processor: AutofillProcessor
override fun onFillRequest(
request: FillRequest,
cancellationSignal: CancellationSignal,
fillCallback: FillCallback,
) {
// TODO: parse request and perform dummy autofill (BIT-1314)
processor.processFillRequest(
autofillAppInfo = AutofillAppInfo(
context = applicationContext,
packageName = packageName,
sdkInt = Build.VERSION.SDK_INT,
),
cancellationSignal = cancellationSignal,
fillCallback = fillCallback,
request = request,
)
}
override fun onSaveRequest(

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.autofill.builder
import android.service.autofill.FillResponse
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.FilledData
/**
* A component for building fill responses out of fulfilled internal models.
*/
interface FillResponseBuilder {
/**
* Build the [filledData] into a [FillResponse]. Return null if not possible.
*/
fun build(
autofillAppInfo: AutofillAppInfo,
filledData: FilledData,
): FillResponse?
}

View file

@ -0,0 +1,47 @@
package com.x8bit.bitwarden.data.autofill.builder
import android.service.autofill.Dataset
import android.service.autofill.FillResponse
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.util.applyOverlayToDataset
import com.x8bit.bitwarden.ui.autofill.buildAutofillRemoteViews
/**
* The default implementation for [FillResponseBuilder]. This is a component for compiling fulfilled
* internal models into a [FillResponse] whenever possible.
*/
class FillResponseBuilderImpl : FillResponseBuilder {
override fun build(
autofillAppInfo: AutofillAppInfo,
filledData: FilledData,
): FillResponse? =
if (filledData.filledItems.isNotEmpty()) {
val remoteViewsPlaceholder = buildAutofillRemoteViews(
packageName = autofillAppInfo.packageName,
context = autofillAppInfo.context,
)
val datasetBuilder = Dataset.Builder()
// Set UI for each valid autofill view.
filledData.filledItems.forEach { filledItem ->
filledItem.applyOverlayToDataset(
appInfo = autofillAppInfo,
datasetBuilder = datasetBuilder,
remoteViews = remoteViewsPlaceholder,
)
}
val dataset = datasetBuilder.build()
FillResponse.Builder()
.addDataset(dataset)
.setIgnoredIds(*filledData.ignoreAutofillIds.toTypedArray())
.build()
} else {
// It is impossible for an `AutofillPartition` to be empty due to the way it is
// constructed. However, the [FillRequest] requires at least one dataset or an
// authentication intent with a presentation view. Neither of these make sense in the
// case where we have no views to fill. What we are supposed to do when we cannot
// fulfill a request is replace [FillResponse] with null in order to avoid this crash.
null
}
}

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.autofill.builder
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.FilledData
/**
* A class for converting parsed autofill data into filled data that is ready to be loaded into a
* fill response.
*/
interface FilledDataBuilder {
/**
* Construct filled data from [autofillRequest].
*/
suspend fun build(
autofillRequest: AutofillRequest.Fillable,
): FilledData
}

View file

@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.autofill.builder
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledItem
/**
* The default [FilledDataBuilder]. This converts parsed autofill data into filled data that is
* ready to be loaded into an autofill response.
*/
class FilledDataBuilderImpl : FilledDataBuilder {
override suspend fun build(autofillRequest: AutofillRequest.Fillable): FilledData {
// TODO: determine whether or not the vault is locked (BIT-1296)
val filledItems = autofillRequest
.partition
.views
.map(AutofillView::toFilledItem)
// TODO: perform fulfillment with dummy data (BIT-1315)
return FilledData(
filledItems = filledItems,
ignoreAutofillIds = autofillRequest.ignoreAutofillIds,
)
}
}
/**
* Map this [AutofillView] to a [FilledItem].
*/
private fun AutofillView.toFilledItem(): FilledItem =
FilledItem(
autofillId = autofillId,
)

View file

@ -0,0 +1,45 @@
package com.x8bit.bitwarden.data.autofill.di
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilderImpl
import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilder
import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilderImpl
import com.x8bit.bitwarden.data.autofill.parser.AutofillParser
import com.x8bit.bitwarden.data.autofill.parser.AutofillParserImpl
import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor
import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessorImpl
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* Provides dependencies within the autofill package.
*/
@Module
@InstallIn(SingletonComponent::class)
object AutofillModule {
@Provides
fun providesAutofillParser(): AutofillParser = AutofillParserImpl()
@Provides
fun providesAutofillProcessor(
dispatcherManager: DispatcherManager,
filledDataBuilder: FilledDataBuilder,
fillResponseBuilder: FillResponseBuilder,
parser: AutofillParser,
): AutofillProcessor =
AutofillProcessorImpl(
dispatcherManager = dispatcherManager,
filledDataBuilder = filledDataBuilder,
fillResponseBuilder = fillResponseBuilder,
parser = parser,
)
@Provides
fun providesFillDataBuilder(): FilledDataBuilder = FilledDataBuilderImpl()
@Provides
fun providesFillResponseBuilder(): FillResponseBuilder = FillResponseBuilderImpl()
}

View file

@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.autofill.model
import android.content.Context
/**
* The app information required for the autofill service.
*/
data class AutofillAppInfo(
val context: Context,
val packageName: String,
val sdkInt: Int,
)

View file

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.autofill.model
/**
* A partition of autofill data.
*/
sealed class AutofillPartition {
/**
* The views that correspond to this partition.
*/
abstract val views: List<AutofillView>
/**
* The credit card [AutofillPartition] data.
*/
data class Card(
override val views: List<AutofillView.Card>,
) : AutofillPartition()
/**
* The identity [AutofillPartition] data.
*/
data class Identity(
override val views: List<AutofillView.Identity>,
) : AutofillPartition()
/**
* The login [AutofillPartition] data.
*/
data class Login(
override val views: List<AutofillView.Login>,
) : AutofillPartition()
}

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.autofill.model
import android.view.autofill.AutofillId
/**
* The parsed autofill request.
*/
sealed class AutofillRequest {
/**
* An autofill request that is fillable. This means it has [partition] of data that can be
* fulfilled.
*/
data class Fillable(
val ignoreAutofillIds: List<AutofillId>,
val partition: AutofillPartition,
) : AutofillRequest()
/**
* An autofill request that is unfillable.
*/
data object Unfillable : AutofillRequest()
}

View file

@ -0,0 +1,124 @@
package com.x8bit.bitwarden.data.autofill.model
import android.view.autofill.AutofillId
/**
* The processed, relevant data from an autofill view node.
*/
sealed class AutofillView {
/**
* The [AutofillId] associated with this view.
*/
abstract val autofillId: AutofillId
/**
* Whether the view is currently focused.
*/
abstract val isFocused: Boolean
/**
* A view that corresponds to the card data partition for autofill fields.
*/
sealed class Card : AutofillView() {
/**
* The expiration month [AutofillView] for the [Card] data partition.
*/
data class ExpirationMonth(
override val autofillId: AutofillId,
override val isFocused: Boolean,
) : Card()
/**
* The expiration year [AutofillView] for the [Card] data partition.
*/
data class ExpirationYear(
override val autofillId: AutofillId,
override val isFocused: Boolean,
) : Card()
/**
* The number [AutofillView] for the [Card] data partition.
*/
data class Number(
override val autofillId: AutofillId,
override val isFocused: Boolean,
) : Card()
/**
* The security code [AutofillView] for the [Card] data partition.
*/
data class SecurityCode(
override val autofillId: AutofillId,
override val isFocused: Boolean,
) : Card()
}
/**
* A view that corresponds to the personal info data partition for autofill fields.
*/
sealed class Identity : AutofillView() {
/**
* The name [AutofillView] for the [Identity] data partition.
*/
data class Name(
override val autofillId: AutofillId,
override val isFocused: Boolean,
) : Identity()
/**
* The phone number [AutofillView] for the [Identity] data partition.
*/
data class PhoneNumber(
override val autofillId: AutofillId,
override val isFocused: Boolean,
) : Identity()
/**
* The postal address [AutofillView] for the [Identity] data partition.
*/
data class PostalAddress(
override val autofillId: AutofillId,
override val isFocused: Boolean,
) : Identity()
/**
* The postal code [AutofillView] for the [Identity] data partition.
*/
data class PostalCode(
override val autofillId: AutofillId,
override val isFocused: Boolean,
) : Identity()
}
/**
* A view that corresponds to the login data partition for autofill fields.
*/
sealed class Login : AutofillView() {
/**
* The email address [AutofillView] for the [Login] data partition.
*/
data class EmailAddress(
override val autofillId: AutofillId,
override val isFocused: Boolean,
) : Login()
/**
* The password [AutofillView] for the [Login] data partition.
*/
data class Password(
override val autofillId: AutofillId,
override val isFocused: Boolean,
) : Login()
/**
* The username [AutofillView] for the [Login] data partition.
*/
data class Username(
override val autofillId: AutofillId,
override val isFocused: Boolean,
) : Login()
}
}

View file

@ -0,0 +1,11 @@
package com.x8bit.bitwarden.data.autofill.model
import android.view.autofill.AutofillId
/**
* The fulfilled autofill data to be loaded into the a fill response.
*/
data class FilledData(
val filledItems: List<FilledItem>,
val ignoreAutofillIds: List<AutofillId>,
)

View file

@ -0,0 +1,11 @@
package com.x8bit.bitwarden.data.autofill.model
import android.view.autofill.AutofillId
/**
* A fulfilled autofill view. This contains everything required to build the autofill UI
* representing this item.
*/
data class FilledItem(
val autofillId: AutofillId,
)

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.autofill.parser
import android.app.assist.AssistStructure
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
/**
* A tool for parsing autofill data from the OS into domain models.
*/
interface AutofillParser {
/**
* Parse the useful information from [assistStructure] into an [AutofillRequest].
*/
fun parse(assistStructure: AssistStructure): AutofillRequest
}

View file

@ -0,0 +1,108 @@
package com.x8bit.bitwarden.data.autofill.parser
import android.app.assist.AssistStructure
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.util.toAutofillView
/**
* The default [AutofillParser] implementation for the app. This is a tool for parsing autofill data
* from the OS into domain models.
*/
class AutofillParserImpl : AutofillParser {
override fun parse(assistStructure: AssistStructure): AutofillRequest {
// Parse the `assistStructure` into internal models.
val traversalData = assistStructure.traverse()
// Flatten the autofill views for processing.
val autofillViews = traversalData
.map { it.autofillViews }
.flatten()
// Find the focused view (or indicate there is no fulfillment to be performed.)
val focusedView = autofillViews
.firstOrNull { it.isFocused }
?: return AutofillRequest.Unfillable
// Choose the first focused partition of data for fulfillment.
val partition = when (focusedView) {
is AutofillView.Card -> {
AutofillPartition.Card(
views = autofillViews.filterIsInstance<AutofillView.Card>(),
)
}
is AutofillView.Identity -> {
AutofillPartition.Identity(
views = autofillViews.filterIsInstance<AutofillView.Identity>(),
)
}
is AutofillView.Login -> {
AutofillPartition.Login(
views = autofillViews.filterIsInstance<AutofillView.Login>(),
)
}
}
// Flatten the ignorable autofill ids.
val ignoreAutofillIds = traversalData
.map { it.ignoreAutofillIds }
.flatten()
return AutofillRequest.Fillable(
ignoreAutofillIds = ignoreAutofillIds,
partition = partition,
)
}
}
/**
* Traverse the [AssistStructure] and convert it into a list of [ViewNodeTraversalData]s.
*/
private fun AssistStructure.traverse(): List<ViewNodeTraversalData> =
(0 until windowNodeCount)
.map { getWindowNodeAt(it) }
.mapNotNull { windowNode -> windowNode.rootViewNode?.traverse() }
/**
* Recursively traverse this [AssistStructure.ViewNode] and all of its descendants. Convert the
* data into [ViewNodeTraversalData].
*/
private fun AssistStructure.ViewNode.traverse(): ViewNodeTraversalData {
// Set up mutable lists for collecting valid AutofillViews and ignorable view ids.
val mutableAutofillViewList: MutableList<AutofillView> = mutableListOf()
val mutableIgnoreAutofillIdList: MutableList<AutofillId> = mutableListOf()
// Try converting this `ViewNode` into an `AutofillView`. If a valid instance is returned, add
// it to the list. Otherwise, ignore the `AutofillId` associated with this `ViewNode`.
toAutofillView()
?.run(mutableAutofillViewList::add)
?: autofillId?.run(mutableIgnoreAutofillIdList::add)
// Recursively traverse all of this view node's children.
for (i in 0 until childCount) {
// Extract the traversal data from each child view node and add it to the lists.
getChildAt(i)
.traverse()
.let { viewNodeTraversalData ->
viewNodeTraversalData.autofillViews.forEach(mutableAutofillViewList::add)
viewNodeTraversalData.ignoreAutofillIds.forEach(mutableIgnoreAutofillIdList::add)
}
}
// Build a new traversal data structure with this view node's data, and that of all of its
// descendant's.
return ViewNodeTraversalData(
autofillViews = mutableAutofillViewList,
ignoreAutofillIds = mutableIgnoreAutofillIdList,
)
}
/**
* A convenience data structure for view node traversal.
*/
private data class ViewNodeTraversalData(
val autofillViews: List<AutofillView>,
val ignoreAutofillIds: List<AutofillId>,
)

View file

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.autofill.processor
import android.os.CancellationSignal
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
/**
* A class to handle autofill request processing. This includes save and fill requests.
*/
interface AutofillProcessor {
/**
* Process the autofill [FillRequest] and invoke the [fillCallback] with the result.
*
* @param autofillAppInfo app data that is required for the autofill [request] processing.
* @param fillCallback the callback to invoke when the [request] has been processed.
* @param request the request data from the OS that contains data about the autofill hierarchy.
*/
fun processFillRequest(
autofillAppInfo: AutofillAppInfo,
cancellationSignal: CancellationSignal,
fillCallback: FillCallback,
request: FillRequest,
)
}

View file

@ -0,0 +1,92 @@
package com.x8bit.bitwarden.data.autofill.processor
import android.app.assist.AssistStructure
import android.os.CancellationSignal
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder
import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilder
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.parser.AutofillParser
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* The default implementation of [AutofillProcessor]. Its purpose is to handle autofill related
* processing.
*/
class AutofillProcessorImpl(
dispatcherManager: DispatcherManager,
private val filledDataBuilder: FilledDataBuilder,
private val fillResponseBuilder: FillResponseBuilder,
private val parser: AutofillParser,
) : AutofillProcessor {
/**
* The coroutine scope for launching asynchronous operations.
*/
private val scope: CoroutineScope = CoroutineScope(dispatcherManager.unconfined)
override fun processFillRequest(
autofillAppInfo: AutofillAppInfo,
cancellationSignal: CancellationSignal,
fillCallback: FillCallback,
request: FillRequest,
) {
// Attempt to get the most recent autofill context.
val assistStructure = request
.fillContexts
.lastOrNull()
?.structure
?: run {
// There is no data for us to process.
fillCallback.onSuccess(null)
return
}
// Set the listener so that any long running work is cancelled when it is no longer needed.
cancellationSignal.setOnCancelListener { scope.cancel() }
// Process the OS data and handle invoking the callback with the result.
assistStructure.process(
autofillAppInfo = autofillAppInfo,
fillCallback = fillCallback,
)
}
/**
* Process the [AssistStructure] and invoke the [FillCallback] with the response.
*/
private fun AssistStructure.process(
autofillAppInfo: AutofillAppInfo,
fillCallback: FillCallback,
) {
scope.launch {
// Parse the OS data into an [AutofillRequest] for easier processing.
val fillResponse = when (val autofillRequest = parser.parse(this@process)) {
is AutofillRequest.Fillable -> {
// Fulfill the [autofillRequest].
val filledData = filledDataBuilder.build(
autofillRequest = autofillRequest,
)
// Load the [filledData] into a [FillResponse].
fillResponseBuilder.build(
autofillAppInfo = autofillAppInfo,
filledData = filledData,
)
}
AutofillRequest.Unfillable -> {
// If we are unable to fulfill the request, we should invoke the callback
// with null. This effectively disables autofill for this view set and
// allows the [AutofillService] to be unbound.
null
}
}
fillCallback.onSuccess(fillResponse)
}
}
}

View file

@ -0,0 +1,74 @@
package com.x8bit.bitwarden.data.autofill.util
import android.annotation.SuppressLint
import android.os.Build
import android.service.autofill.Dataset
import android.service.autofill.Field
import android.service.autofill.Presentations
import android.view.autofill.AutofillId
import android.widget.RemoteViews
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.FilledItem
/**
* Apply this [FilledItem] to the dataset being built by [datasetBuilder] in the form of an
* overlay presentation.
*/
fun FilledItem.applyOverlayToDataset(
appInfo: AutofillAppInfo,
datasetBuilder: Dataset.Builder,
remoteViews: RemoteViews,
) {
if (appInfo.sdkInt >= Build.VERSION_CODES.TIRAMISU) {
setOverlay(
autoFillId = autofillId,
datasetBuilder = datasetBuilder,
remoteViews = remoteViews,
)
} else {
setOverlayPreTiramisu(
autoFillId = autofillId,
datasetBuilder = datasetBuilder,
remoteViews = remoteViews,
)
}
}
/**
* Set up an overlay presentation in the [datasetBuilder] for Android devices running on API
* Tiramisu or greater.
*/
@SuppressLint("NewApi")
private fun setOverlay(
autoFillId: AutofillId,
datasetBuilder: Dataset.Builder,
remoteViews: RemoteViews,
) {
val presentation = Presentations.Builder()
.setDialogPresentation(remoteViews)
.build()
datasetBuilder.setField(
autoFillId,
Field.Builder()
.setPresentations(presentation)
.build(),
)
}
/**
* Set up an overlay presentation in the [datasetBuilder] for Android devices running on APIs that
* predate Tiramisu.
*/
@Suppress("Deprecation")
private fun setOverlayPreTiramisu(
autoFillId: AutofillId,
datasetBuilder: Dataset.Builder,
remoteViews: RemoteViews,
) {
datasetBuilder.setValue(
autoFillId,
null,
remoteViews,
)
}

View file

@ -0,0 +1,131 @@
package com.x8bit.bitwarden.data.autofill.util
import android.app.assist.AssistStructure
import android.view.View
import android.view.autofill.AutofillId
import com.x8bit.bitwarden.data.autofill.model.AutofillView
/**
* Attempt to convert this [AssistStructure.ViewNode] into an [AutofillView]. If the view node
* doesn't contain a valid autofillId, it isn't an a view setup for autofill, so we return null. If
* it is has an autofillHint that we do not support, we also return null.
*/
fun AssistStructure.ViewNode.toAutofillView(): AutofillView? = autofillId
// We only care about nodes with a valid `AutofillId`.
?.let { nonNullAutofillId ->
autofillHints
?.firstOrNull { SUPPORTED_HINTS.contains(it) }
?.let { supportedHint ->
buildAutofillView(
autofillId = nonNullAutofillId,
isFocused = isFocused,
hint = supportedHint,
)
}
}
/**
* Convert the data into an [AutofillView] if the [hint] is supported.
*/
@Suppress("LongMethod")
private fun buildAutofillView(
autofillId: AutofillId,
isFocused: Boolean,
hint: String,
): AutofillView? = when (hint) {
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> {
AutofillView.Card.ExpirationMonth(
autofillId = autofillId,
isFocused = isFocused,
)
}
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> {
AutofillView.Card.ExpirationYear(
autofillId = autofillId,
isFocused = isFocused,
)
}
View.AUTOFILL_HINT_CREDIT_CARD_NUMBER -> {
AutofillView.Card.Number(
autofillId = autofillId,
isFocused = isFocused,
)
}
View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> {
AutofillView.Card.SecurityCode(
autofillId = autofillId,
isFocused = isFocused,
)
}
View.AUTOFILL_HINT_EMAIL_ADDRESS -> {
AutofillView.Login.EmailAddress(
autofillId = autofillId,
isFocused = isFocused,
)
}
View.AUTOFILL_HINT_NAME -> {
AutofillView.Identity.Name(
autofillId = autofillId,
isFocused = isFocused,
)
}
View.AUTOFILL_HINT_PASSWORD -> {
AutofillView.Login.Password(
autofillId = autofillId,
isFocused = isFocused,
)
}
View.AUTOFILL_HINT_PHONE -> {
AutofillView.Identity.PhoneNumber(
autofillId = autofillId,
isFocused = isFocused,
)
}
View.AUTOFILL_HINT_POSTAL_ADDRESS -> {
AutofillView.Identity.PostalAddress(
autofillId = autofillId,
isFocused = isFocused,
)
}
View.AUTOFILL_HINT_POSTAL_CODE -> {
AutofillView.Identity.PostalCode(
autofillId = autofillId,
isFocused = isFocused,
)
}
View.AUTOFILL_HINT_USERNAME -> {
AutofillView.Login.Username(
autofillId = autofillId,
isFocused = isFocused,
)
}
else -> null
}
/**
* All of the supported autofill hints for the app.
*/
private val SUPPORTED_HINTS: List<String> = listOf(
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH,
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR,
View.AUTOFILL_HINT_CREDIT_CARD_NUMBER,
View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE,
View.AUTOFILL_HINT_EMAIL_ADDRESS,
View.AUTOFILL_HINT_NAME,
View.AUTOFILL_HINT_PASSWORD,
View.AUTOFILL_HINT_PHONE,
View.AUTOFILL_HINT_POSTAL_ADDRESS,
View.AUTOFILL_HINT_POSTAL_CODE,
View.AUTOFILL_HINT_USERNAME,
)

View file

@ -0,0 +1,23 @@
package com.x8bit.bitwarden.ui.autofill
import android.content.Context
import android.widget.RemoteViews
import com.x8bit.bitwarden.R
/**
* Build [RemoteViews] for representing an autofill suggestion.
*/
fun buildAutofillRemoteViews(
context: Context,
packageName: String,
): RemoteViews =
RemoteViews(
packageName,
R.layout.autofill_remote_view,
)
.apply {
setTextViewText(
R.id.text,
context.resources.getText(R.string.app_name),
)
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp" />

View file

@ -1,54 +0,0 @@
package com.x8bit.bitwarden.data.autofill
import android.os.CancellationSignal
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import android.service.autofill.SaveCallback
import android.service.autofill.SaveRequest
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class BitwardenAutofillServiceTests {
private lateinit var bitwardenAutofillService: BitwardenAutofillService
@BeforeEach
fun setup() {
bitwardenAutofillService = BitwardenAutofillService()
}
@Nested
inner class OnFillRequest {
@Test
fun `nothing happens`() {
// Setup
val cancellationSignal: CancellationSignal = mockk()
val fillCallback: FillCallback = mockk()
val fillRequest: FillRequest = mockk()
// Test
bitwardenAutofillService.onFillRequest(
cancellationSignal = cancellationSignal,
fillCallback = fillCallback,
request = fillRequest,
)
}
}
@Nested
inner class OnSaveRequest {
@Test
fun `nothing happens`() {
// Setup
val saverRequest: SaveRequest = mockk()
val saveCallback: SaveCallback = mockk()
// Test
bitwardenAutofillService.onSaveRequest(
saveCallback = saveCallback,
saverRequest = saverRequest,
)
}
}
}

View file

@ -0,0 +1,162 @@
package com.x8bit.bitwarden.data.autofill.builder
import android.content.Context
import android.service.autofill.Dataset
import android.service.autofill.FillResponse
import android.view.autofill.AutofillId
import android.widget.RemoteViews
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledItem
import com.x8bit.bitwarden.data.autofill.util.applyOverlayToDataset
import com.x8bit.bitwarden.data.util.mockBuilder
import com.x8bit.bitwarden.ui.autofill.buildAutofillRemoteViews
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkConstructor
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.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class FillResponseBuilderTest {
private lateinit var fillResponseBuilder: FillResponseBuilder
private val dataset: Dataset = mockk()
private val context: Context = mockk()
private val fillResponse: FillResponse = mockk()
private val remoteViews: RemoteViews = mockk()
private val appInfo: AutofillAppInfo = AutofillAppInfo(
context = context,
packageName = PACKAGE_NAME,
sdkInt = 17,
)
private val autofillIdOne: AutofillId = mockk()
private val autofillIdTwo: AutofillId = mockk()
private val filledItemOne: FilledItem = mockk {
every { this@mockk.autofillId } returns autofillIdOne
}
private val filledItemTwo: FilledItem = mockk {
every { this@mockk.autofillId } returns autofillIdTwo
}
@BeforeEach
fun setup() {
mockkConstructor(Dataset.Builder::class)
mockkConstructor(FillResponse.Builder::class)
mockkStatic(::buildAutofillRemoteViews)
mockkStatic(FilledItem::applyOverlayToDataset)
every { anyConstructed<Dataset.Builder>().build() } returns dataset
every { anyConstructed<FillResponse.Builder>().build() } returns fillResponse
fillResponseBuilder = FillResponseBuilderImpl()
}
@AfterEach
fun teardown() {
unmockkConstructor(Dataset.Builder::class)
unmockkConstructor(FillResponse.Builder::class)
unmockkStatic(::buildAutofillRemoteViews)
unmockkStatic(FilledItem::applyOverlayToDataset)
}
@Test
fun `build should return null when filledItems empty`() {
// Test
val filledData = FilledData(
filledItems = emptyList(),
ignoreAutofillIds = emptyList(),
)
val actual = fillResponseBuilder.build(
autofillAppInfo = appInfo,
filledData = filledData,
)
// Verify
assertNull(actual)
}
@Test
fun `build should apply filledItems and ignore ignoreAutofillIds`() {
// Setup
val ignoredAutofillIdOne: AutofillId = mockk()
val ignoredAutofillIdTwo: AutofillId = mockk()
val ignoreAutofillIds = listOf(
ignoredAutofillIdOne,
ignoredAutofillIdTwo,
)
val filledItems = listOf(
filledItemOne,
filledItemTwo,
)
val filledData = FilledData(
filledItems = filledItems,
ignoreAutofillIds = ignoreAutofillIds,
)
every {
buildAutofillRemoteViews(
context = context,
packageName = PACKAGE_NAME,
)
} returns remoteViews
every {
filledItemOne.applyOverlayToDataset(
appInfo = appInfo,
datasetBuilder = anyConstructed(),
remoteViews = remoteViews,
)
} just runs
every {
filledItemTwo.applyOverlayToDataset(
appInfo = appInfo,
datasetBuilder = anyConstructed(),
remoteViews = remoteViews,
)
} just runs
mockBuilder<FillResponse.Builder> { it.addDataset(dataset) }
mockBuilder<FillResponse.Builder> {
it.setIgnoredIds(
ignoredAutofillIdOne,
ignoredAutofillIdTwo,
)
}
// Test
val actual = fillResponseBuilder.build(
autofillAppInfo = appInfo,
filledData = filledData,
)
// Verify
assertEquals(fillResponse, actual)
verify(exactly = 1) {
filledItemOne.applyOverlayToDataset(
appInfo = appInfo,
datasetBuilder = any(),
remoteViews = remoteViews,
)
filledItemTwo.applyOverlayToDataset(
appInfo = appInfo,
datasetBuilder = any(),
remoteViews = remoteViews,
)
anyConstructed<FillResponse.Builder>().addDataset(dataset)
anyConstructed<FillResponse.Builder>().setIgnoredIds(
ignoredAutofillIdOne,
ignoredAutofillIdTwo,
)
}
}
companion object {
private const val PACKAGE_NAME: String = "com.x8bit.bitwarden"
}
}

View file

@ -0,0 +1,57 @@
package com.x8bit.bitwarden.data.autofill.builder
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.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledItem
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class FilledDataBuilderTest {
private lateinit var filledDataBuilder: FilledDataBuilder
@BeforeEach
fun setup() {
filledDataBuilder = FilledDataBuilderImpl()
}
@Test
fun `build should return FilledData with FilledItems and ignored AutofillIds`() = runTest {
// Setup
val autofillId: AutofillId = mockk()
val autofillView = AutofillView.Identity.PostalCode(
autofillId = autofillId,
isFocused = false,
)
val autofillPartition = AutofillPartition.Identity(
views = listOf(autofillView),
)
val ignoreAutofillIds: List<AutofillId> = mockk()
val autofillRequest = AutofillRequest.Fillable(
ignoreAutofillIds = ignoreAutofillIds,
partition = autofillPartition,
)
val filledItem = FilledItem(
autofillId = autofillId,
)
val expected = FilledData(
filledItems = listOf(
filledItem,
),
ignoreAutofillIds = ignoreAutofillIds,
)
// Test
val actual = filledDataBuilder.build(
autofillRequest = autofillRequest,
)
// Verify
assertEquals(expected, actual)
}
}

View file

@ -0,0 +1,270 @@
package com.x8bit.bitwarden.data.autofill.parser
import android.app.assist.AssistStructure
import android.view.View
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.util.toAutofillView
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.BeforeEach
import org.junit.jupiter.api.Test
class AutofillParserTests {
private lateinit var parser: AutofillParser
private val assistStructure: AssistStructure = mockk()
private val cardAutofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR
private val cardAutofillId: AutofillId = mockk()
private val cardViewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillHints } returns arrayOf(cardAutofillHint)
every { this@mockk.autofillId } returns cardAutofillId
every { this@mockk.childCount } returns 0
}
private val identityAutofillHint = View.AUTOFILL_HINT_NAME
private val identityAutofillId: AutofillId = mockk()
private val identityViewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillHints } returns arrayOf(identityAutofillHint)
every { this@mockk.autofillId } returns identityAutofillId
every { this@mockk.childCount } returns 0
}
private val loginAutofillHint = View.AUTOFILL_HINT_USERNAME
private val loginAutofillId: AutofillId = mockk()
private val loginViewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillHints } returns arrayOf(loginAutofillHint)
every { this@mockk.autofillId } returns loginAutofillId
every { this@mockk.childCount } returns 0
}
private val cardWindowNode: AssistStructure.WindowNode = mockk {
every { this@mockk.rootViewNode } returns cardViewNode
}
private val identityWindowNode: AssistStructure.WindowNode = mockk {
every { this@mockk.rootViewNode } returns identityViewNode
}
private val loginWindowNode: AssistStructure.WindowNode = mockk {
every { this@mockk.rootViewNode } returns loginViewNode
}
@BeforeEach
fun setup() {
mockkStatic(AssistStructure.ViewNode::toAutofillView)
parser = AutofillParserImpl()
}
@AfterEach
fun teardown() {
unmockkStatic(AssistStructure.ViewNode::toAutofillView)
}
@Test
fun `parse should return Unfillable when windowNodeCount is 0`() {
// Setup
val expected = AutofillRequest.Unfillable
every { assistStructure.windowNodeCount } returns 0
// Test
val actual = parser.parse(assistStructure)
// Verify
assertEquals(expected, actual)
}
@Test
fun `parse should return Fillable when at least one node valid, ignores the invalid nodes`() {
// Setup
val childAutofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH
val childAutofillId: AutofillId = mockk()
val childViewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillHints } returns arrayOf(childAutofillHint)
every { this@mockk.autofillId } returns childAutofillId
every { this@mockk.childCount } returns 0
every { this@mockk.isFocused } returns false
every { this@mockk.toAutofillView() } returns null
}
val parentAutofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR
val parentAutofillId: AutofillId = mockk()
val parentAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
autofillId = parentAutofillId,
isFocused = true,
)
val parentViewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillHints } returns arrayOf(parentAutofillHint)
every { this@mockk.autofillId } returns parentAutofillId
every { this@mockk.toAutofillView() } returns parentAutofillView
every { this@mockk.childCount } returns 1
every { this@mockk.getChildAt(0) } returns childViewNode
}
val windowNode: AssistStructure.WindowNode = mockk {
every { this@mockk.rootViewNode } returns parentViewNode
}
val autofillPartition = AutofillPartition.Card(
views = listOf(parentAutofillView),
)
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = listOf(childAutofillId),
partition = autofillPartition,
)
every { assistStructure.windowNodeCount } returns 1
every { assistStructure.getWindowNodeAt(0) } returns windowNode
// Test
val actual = parser.parse(assistStructure)
// Verify
assertEquals(expected, actual)
}
@Test
fun `parse should choose AutofillPartition Card when a Card view is focused`() {
// Setup
setupAssistStructureWithAllAutofillViewTypes()
val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
autofillId = cardAutofillId,
isFocused = true,
)
val identityAutofillView: AutofillView.Identity = AutofillView.Identity.Name(
autofillId = identityAutofillId,
isFocused = false,
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
autofillId = loginAutofillId,
isFocused = false,
)
val autofillPartition = AutofillPartition.Card(
views = listOf(cardAutofillView),
)
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(),
partition = autofillPartition,
)
every { cardViewNode.toAutofillView() } returns cardAutofillView
every { identityViewNode.toAutofillView() } returns identityAutofillView
every { loginViewNode.toAutofillView() } returns loginAutofillView
// Test
val actual = parser.parse(assistStructure)
// Verify
assertEquals(expected, actual)
}
@Test
fun `parse should choose AutofillPartition Identity when an Identity view is focused`() {
// Setup
setupAssistStructureWithAllAutofillViewTypes()
val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
autofillId = cardAutofillId,
isFocused = false,
)
val identityAutofillView: AutofillView.Identity = AutofillView.Identity.Name(
autofillId = identityAutofillId,
isFocused = true,
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
autofillId = loginAutofillId,
isFocused = false,
)
val autofillPartition = AutofillPartition.Identity(
views = listOf(identityAutofillView),
)
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(),
partition = autofillPartition,
)
every { cardViewNode.toAutofillView() } returns cardAutofillView
every { identityViewNode.toAutofillView() } returns identityAutofillView
every { loginViewNode.toAutofillView() } returns loginAutofillView
// Test
val actual = parser.parse(assistStructure)
// Verify
assertEquals(expected, actual)
}
@Test
fun `parse should choose AutofillPartition Login when a Login view is focused`() {
// Setup
setupAssistStructureWithAllAutofillViewTypes()
val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
autofillId = cardAutofillId,
isFocused = false,
)
val identityAutofillView: AutofillView.Identity = AutofillView.Identity.Name(
autofillId = identityAutofillId,
isFocused = false,
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
autofillId = loginAutofillId,
isFocused = true,
)
val autofillPartition = AutofillPartition.Login(
views = listOf(loginAutofillView),
)
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(),
partition = autofillPartition,
)
every { cardViewNode.toAutofillView() } returns cardAutofillView
every { identityViewNode.toAutofillView() } returns identityAutofillView
every { loginViewNode.toAutofillView() } returns loginAutofillView
// Test
val actual = parser.parse(assistStructure)
// Verify
assertEquals(expected, actual)
}
@Test
fun `parse should choose first focused AutofillView for partition when there are multiple`() {
// Setup
setupAssistStructureWithAllAutofillViewTypes()
val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
autofillId = cardAutofillId,
isFocused = true,
)
val identityAutofillView: AutofillView.Identity = AutofillView.Identity.Name(
autofillId = identityAutofillId,
isFocused = true,
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
autofillId = loginAutofillId,
isFocused = false,
)
val autofillPartition = AutofillPartition.Card(
views = listOf(cardAutofillView),
)
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(),
partition = autofillPartition,
)
every { cardViewNode.toAutofillView() } returns cardAutofillView
every { identityViewNode.toAutofillView() } returns identityAutofillView
every { loginViewNode.toAutofillView() } returns loginAutofillView
// Test
val actual = parser.parse(assistStructure)
// Verify
assertEquals(expected, actual)
}
/**
* Setup [assistStructure] to return window nodes with each [AutofillView] type (card, identity,
* and login) so we can test how different window node configurations produce different
* partitions.
*/
private fun setupAssistStructureWithAllAutofillViewTypes() {
every { assistStructure.windowNodeCount } returns 3
every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode
every { assistStructure.getWindowNodeAt(1) } returns identityWindowNode
every { assistStructure.getWindowNodeAt(2) } returns loginWindowNode
}
}

View file

@ -0,0 +1,189 @@
package com.x8bit.bitwarden.data.autofill.processor
import android.app.assist.AssistStructure
import android.os.CancellationSignal
import android.service.autofill.FillCallback
import android.service.autofill.FillContext
import android.service.autofill.FillRequest
import android.service.autofill.FillResponse
import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilder
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledItem
import com.x8bit.bitwarden.data.autofill.parser.AutofillParser
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AutofillProcessorTest {
private lateinit var autofillProcessor: AutofillProcessor
private val dispatcherManager: DispatcherManager = mockk()
private val cancellationSignal: CancellationSignal = mockk()
private val filledDataBuilder: FilledDataBuilder = mockk()
private val fillResponseBuilder: FillResponseBuilder = mockk()
private val parser: AutofillParser = mockk()
private val testDispatcher = UnconfinedTestDispatcher()
private val appInfo: AutofillAppInfo = AutofillAppInfo(
context = mockk(),
packageName = "com.x8bit.bitwarden",
sdkInt = 42,
)
private val assistStructure: AssistStructure = mockk()
private val fillCallback: FillCallback = mockk()
@BeforeEach
fun setup() {
every { dispatcherManager.unconfined } returns testDispatcher
autofillProcessor = AutofillProcessorImpl(
dispatcherManager = dispatcherManager,
filledDataBuilder = filledDataBuilder,
fillResponseBuilder = fillResponseBuilder,
parser = parser,
)
}
@AfterEach
fun teardown() {
verify(exactly = 1) {
dispatcherManager.unconfined
}
confirmVerified(
cancellationSignal,
dispatcherManager,
filledDataBuilder,
fillResponseBuilder,
parser,
)
}
@Test
fun `processFillRequest should invoke callback with null when no fillContexts`() {
// Setup
val fillRequest: FillRequest = mockk {
every { this@mockk.fillContexts } returns emptyList()
}
every { fillCallback.onSuccess(null) } just runs
// Test
autofillProcessor.processFillRequest(
autofillAppInfo = appInfo,
cancellationSignal = cancellationSignal,
fillCallback = fillCallback,
request = fillRequest,
)
// Verify
verify(exactly = 1) {
fillCallback.onSuccess(null)
}
}
@Test
fun `processFillRequest should invoke callback with null when parse returns Unfillable`() {
// Setup
val autofillRequest = AutofillRequest.Unfillable
val lastFillContext: FillContext = mockk {
every { this@mockk.structure } returns assistStructure
}
val fillContexts: List<FillContext> = listOf(mockk(), lastFillContext)
val fillRequest: FillRequest = mockk {
every { this@mockk.fillContexts } returns fillContexts
}
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { parser.parse(assistStructure) } returns autofillRequest
every { fillCallback.onSuccess(null) } just runs
// Test
autofillProcessor.processFillRequest(
autofillAppInfo = appInfo,
cancellationSignal = cancellationSignal,
fillCallback = fillCallback,
request = fillRequest,
)
// Verify
verify(exactly = 1) {
cancellationSignal.setOnCancelListener(any())
parser.parse(assistStructure)
fillCallback.onSuccess(null)
}
}
@Test
fun `processFillRequest should invoke callback with filled response when has filledItems`() =
runTest {
// Setup
val lastFillContext: FillContext = mockk {
every { this@mockk.structure } returns assistStructure
}
val fillContextList: List<FillContext> = listOf(mockk(), lastFillContext)
val fillRequest: FillRequest = mockk {
every { this@mockk.fillContexts } returns fillContextList
}
val filledItems: List<FilledItem> = listOf(mockk())
val filledData = FilledData(
filledItems = filledItems,
ignoreAutofillIds = emptyList(),
)
val fillResponse: FillResponse = mockk()
val autofillRequest: AutofillRequest.Fillable = mockk {
every { this@mockk.ignoreAutofillIds } returns emptyList()
}
coEvery {
filledDataBuilder.build(
autofillRequest = autofillRequest,
)
} returns filledData
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { parser.parse(assistStructure) } returns autofillRequest
every {
fillResponseBuilder.build(
autofillAppInfo = appInfo,
filledData = filledData,
)
} returns fillResponse
every { fillCallback.onSuccess(fillResponse) } just runs
// Test
autofillProcessor.processFillRequest(
autofillAppInfo = appInfo,
cancellationSignal = cancellationSignal,
fillCallback = fillCallback,
request = fillRequest,
)
// Verify
coVerify(exactly = 1) {
filledDataBuilder.build(
autofillRequest = autofillRequest,
)
}
verify(exactly = 1) {
cancellationSignal.setOnCancelListener(any())
parser.parse(assistStructure)
fillResponseBuilder.build(
autofillAppInfo = appInfo,
filledData = filledData,
)
fillCallback.onSuccess(fillResponse)
}
}
}

View file

@ -0,0 +1,120 @@
package com.x8bit.bitwarden.data.autofill.util
import android.content.Context
import android.service.autofill.Dataset
import android.service.autofill.Field
import android.service.autofill.Presentations
import android.view.autofill.AutofillId
import android.widget.RemoteViews
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.FilledItem
import com.x8bit.bitwarden.data.util.mockBuilder
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.unmockkConstructor
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class FilledItemExtensionsTest {
private val autofillId: AutofillId = mockk()
private val context: Context = mockk()
private val datasetBuilder: Dataset.Builder = mockk()
private val field: Field = mockk()
private val filledItem = FilledItem(
autofillId = autofillId,
)
private val presentations: Presentations = mockk()
private val remoteViews: RemoteViews = mockk()
@BeforeEach
fun setup() {
mockkConstructor(Dataset.Builder::class)
mockkConstructor(Presentations.Builder::class)
mockkConstructor(Field.Builder::class)
every { anyConstructed<Presentations.Builder>().build() } returns presentations
every { anyConstructed<Field.Builder>().build() } returns field
}
@AfterEach
fun teardown() {
unmockkConstructor(Dataset.Builder::class)
unmockkConstructor(Presentations.Builder::class)
unmockkConstructor(Field.Builder::class)
}
@Suppress("Deprecation")
@Test
fun `applyOverlayToDataset should use setValue to set RemoteViews when before tiramisu`() {
// Setup
val appInfo = AutofillAppInfo(
context = context,
packageName = PACKAGE_NAME,
sdkInt = 1,
)
every {
datasetBuilder.setValue(
autofillId,
null,
remoteViews,
)
} returns datasetBuilder
// Test
filledItem.applyOverlayToDataset(
appInfo = appInfo,
datasetBuilder = datasetBuilder,
remoteViews = remoteViews,
)
// Verify
verify(exactly = 1) {
datasetBuilder.setValue(
autofillId,
null,
remoteViews,
)
}
}
@Test
fun `applyOverlayToDataset should use setField to set Presentation on or after Tiramisu`() {
// Setup
val appInfo = AutofillAppInfo(
context = context,
packageName = PACKAGE_NAME,
sdkInt = 34,
)
mockBuilder<Presentations.Builder> { it.setDialogPresentation(remoteViews) }
mockBuilder<Field.Builder> { it.setPresentations(presentations) }
every {
datasetBuilder.setField(
autofillId,
field,
)
} returns datasetBuilder
// Test
filledItem.applyOverlayToDataset(
appInfo = appInfo,
datasetBuilder = datasetBuilder,
remoteViews = remoteViews,
)
// Verify
verify(exactly = 1) {
anyConstructed<Presentations.Builder>().setDialogPresentation(remoteViews)
anyConstructed<Field.Builder>().setPresentations(presentations)
datasetBuilder.setField(
autofillId,
field,
)
}
}
companion object {
private const val PACKAGE_NAME: String = "com.x8bit.bitwarden"
}
}

View file

@ -0,0 +1,239 @@
package com.x8bit.bitwarden.data.autofill.util
import android.app.assist.AssistStructure
import android.view.View
import android.view.autofill.AutofillId
import com.x8bit.bitwarden.data.autofill.model.AutofillView
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 ViewNodeExtensionsTest {
private val expectedAutofillId: AutofillId = mockk()
private val expectedIsFocused = true
private val viewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillId } returns expectedAutofillId
every { this@mockk.childCount } returns 0
every { this@mockk.isFocused } returns expectedIsFocused
}
@Test
fun `toAutofillView should return AutofillView Card ExpirationMonth when hint matches`() {
// Setup
val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH
val expected = AutofillView.Card.ExpirationMonth(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillView should return AutofillView Card ExpirationYear when hint matches`() {
// Setup
val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR
val expected = AutofillView.Card.ExpirationYear(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillView should return AutofillView Card Number when hint matches`() {
// Setup
val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_NUMBER
val expected = AutofillView.Card.Number(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillView should return AutofillView Card SecurityCode when hint matches`() {
// Setup
val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE
val expected = AutofillView.Card.SecurityCode(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillView should return AutofillView Login EmailAddress when hint matches`() {
// Setup
val autofillHint = View.AUTOFILL_HINT_EMAIL_ADDRESS
val expected = AutofillView.Login.EmailAddress(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillView should return AutofillView Identity Name when hint matches`() {
// Setup
val autofillHint = View.AUTOFILL_HINT_NAME
val expected = AutofillView.Identity.Name(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillView should return AutofillView Login Password when hint matches`() {
// Setup
val autofillHint = View.AUTOFILL_HINT_PASSWORD
val expected = AutofillView.Login.Password(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillView should return AutofillView Identity PhoneNumber when hint matches`() {
// Setup
val autofillHint = View.AUTOFILL_HINT_PHONE
val expected = AutofillView.Identity.PhoneNumber(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillView should return AutofillView Identity PostalAddress when hint matches`() {
// Setup
val autofillHint = View.AUTOFILL_HINT_POSTAL_ADDRESS
val expected = AutofillView.Identity.PostalAddress(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillView should return AutofillView Identity PostalCOde when hint matches`() {
// Setup
val autofillHint = View.AUTOFILL_HINT_POSTAL_CODE
val expected = AutofillView.Identity.PostalCode(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillView should return AutofillView Login Username when hint matches`() {
// Setup
val autofillHint = View.AUTOFILL_HINT_USERNAME
val expected = AutofillView.Login.Username(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillView should return null when hint is not supported`() {
// Setup
val autofillHint = "Shenanigans"
every { viewNode.autofillHints } returns arrayOf(autofillHint)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertNull(actual)
}
@Test
fun `toAutofillView should skip unsupported hint and return supported hint mapping`() {
// Setup
val autofillHintOne = "Shenanigans"
val autofillHintTwo = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR
val expected = AutofillView.Card.ExpirationYear(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
)
every { viewNode.autofillHints } returns arrayOf(autofillHintOne, autofillHintTwo)
// Test
val actual = viewNode.toAutofillView()
// Verify
assertEquals(expected, actual)
}
}

View file

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.data.util
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
import io.mockk.MockKMatcherScope
import io.mockk.every
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
@ -17,3 +19,31 @@ fun assertJsonEquals(
json.parseToJsonElement(actual),
)
}
/**
* Helper method for mocking pipeline operations within the builder pattern. This saves a lot of
* boiler plate. In order to use this, the builder's constructor must be mockked.
*
* Example:
* ```
* // Setup
* mockkConstructor(FillResponse.Builder::class)
* mockBuilder<FillResponse.Builder> { it.setIgnoredIds() }
* every { anyConstructed<FillResponse.Builder>().build() } returns mockk()
*
* // Test
* ...
*
* // Verify
* verify(exactly = 1) {
* anyConstructed<FillResponse.Builder>().setIgnoredIds()
* anyConstructed<FillResponse.Builder>().build()
* }
* unmockkConstructor(FillResponse.Builder::class)
* ```
*/
inline fun <reified T : Any> mockBuilder(crossinline block: MockKMatcherScope.(T) -> T) {
every { block(anyConstructed<T>()) } answers {
this.self as T
}
}

View file

@ -0,0 +1,77 @@
package com.x8bit.bitwarden.ui.autofill
import android.content.Context
import android.content.res.Resources
import android.widget.RemoteViews
import com.x8bit.bitwarden.R
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.runs
import io.mockk.unmockkConstructor
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class BitwardenRemoteViewsTest {
private val testResources: Resources = mockk()
private val context: Context = mockk {
every { this@mockk.resources } returns testResources
}
@BeforeEach
fun setup() {
mockkConstructor(RemoteViews::class)
}
@AfterEach
fun teardown() {
unmockkConstructor(RemoteViews::class)
confirmVerified(
context,
testResources,
)
}
@Test
fun `buildAutofillRemoteViews should set text`() {
// Setup
val appName = "Bitwarden"
every { testResources.getText(R.string.app_name) } returns appName
every {
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.text,
appName,
)
} just runs
// Test
buildAutofillRemoteViews(
context = context,
packageName = PACKAGE_NAME,
)
// Note: impossible to do a useful test of the returned RemoteViews due to mockking
// constraints of the [RemoteViews] constructor. Our best bet is to make sure the correct
// operations are performed on the constructed [RemoteViews].
// Verify
verify(exactly = 1) {
context.resources
testResources.getText(R.string.app_name)
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.text,
"Bitwarden",
)
}
}
companion object {
private const val PACKAGE_NAME: String = "com.x8bit.bitwarden"
}
}