mirror of
https://github.com/bitwarden/android.git
synced 2025-03-16 03:08:50 +03:00
BIT-1314: Add autofill node traversal with basic UI fulfillment (#541)
This commit is contained in:
parent
35ef4e4252
commit
cea26f5e32
30 changed files with 2016 additions and 55 deletions
|
@ -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",
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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?
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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()
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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>,
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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
|
||||
}
|
|
@ -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>,
|
||||
)
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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),
|
||||
)
|
||||
}
|
6
app/src/main/res/layout/autofill_remote_view.xml
Normal file
6
app/src/main/res/layout/autofill_remote_view.xml
Normal 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" />
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue