From cea26f5e32eccf43546fa471fef3ad2c23111f76 Mon Sep 17 00:00:00 2001 From: Lucas Kivi <125697099+lucas-livefront@users.noreply.github.com> Date: Mon, 8 Jan 2024 18:43:57 -0600 Subject: [PATCH] BIT-1314: Add autofill node traversal with basic UI fulfillment (#541) --- app/build.gradle.kts | 1 + .../data/autofill/BitwardenAutofillService.kt | 23 +- .../autofill/builder/FillResponseBuilder.kt | 18 ++ .../builder/FillResponseBuilderImpl.kt | 47 +++ .../autofill/builder/FilledDataBuilder.kt | 17 ++ .../autofill/builder/FilledDataBuilderImpl.kt | 36 +++ .../data/autofill/di/AutofillModule.kt | 45 +++ .../data/autofill/model/AutofillAppInfo.kt | 12 + .../data/autofill/model/AutofillPartition.kt | 32 +++ .../data/autofill/model/AutofillRequest.kt | 22 ++ .../data/autofill/model/AutofillView.kt | 124 ++++++++ .../data/autofill/model/FilledData.kt | 11 + .../data/autofill/model/FilledItem.kt | 11 + .../data/autofill/parser/AutofillParser.kt | 15 + .../autofill/parser/AutofillParserImpl.kt | 108 +++++++ .../autofill/processor/AutofillProcessor.kt | 25 ++ .../processor/AutofillProcessorImpl.kt | 92 ++++++ .../autofill/util/FilledItemExtensions.kt | 74 +++++ .../data/autofill/util/ViewNodeExtensions.kt | 131 +++++++++ .../ui/autofill/BitwardenRemoteViews.kt | 23 ++ .../main/res/layout/autofill_remote_view.xml | 6 + .../autofill/BitwardenAutofillServiceTests.kt | 54 ---- .../builder/FillResponseBuilderTest.kt | 162 +++++++++++ .../autofill/builder/FilledDataBuilderTest.kt | 57 ++++ .../autofill/parser/AutofillParserTests.kt | 270 ++++++++++++++++++ .../processor/AutofillProcessorTest.kt | 189 ++++++++++++ .../autofill/util/FilledItemExtensionsTest.kt | 120 ++++++++ .../autofill/util/ViewNodeExtensionsTest.kt | 239 ++++++++++++++++ .../x8bit/bitwarden/data/util/TestHelpers.kt | 30 ++ .../ui/autofill/BitwardenRemoteViewsTest.kt | 77 +++++ 30 files changed, 2016 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilder.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilder.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillAppInfo.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillPartition.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledData.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledItem.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParser.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessor.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledItemExtensions.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViews.kt create mode 100644 app/src/main/res/layout/autofill_remote_view.xml delete mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillServiceTests.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledItemExtensionsTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViewsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 74d59600c9..09b0dd9978 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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", diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillService.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillService.kt index 0c92b4ad84..630c5608d6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillService.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilder.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilder.kt new file mode 100644 index 0000000000..c34ff87ec2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilder.kt @@ -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? +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt new file mode 100644 index 0000000000..45b8078cfb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt @@ -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 + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilder.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilder.kt new file mode 100644 index 0000000000..664c4c42bb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilder.kt @@ -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 +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt new file mode 100644 index 0000000000..843b852771 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt @@ -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, + ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt new file mode 100644 index 0000000000..63fbaf55f7 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillAppInfo.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillAppInfo.kt new file mode 100644 index 0000000000..6c9e524803 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillAppInfo.kt @@ -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, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillPartition.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillPartition.kt new file mode 100644 index 0000000000..4b25ae1cdd --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillPartition.kt @@ -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 + + /** + * The credit card [AutofillPartition] data. + */ + data class Card( + override val views: List, + ) : AutofillPartition() + + /** + * The identity [AutofillPartition] data. + */ + data class Identity( + override val views: List, + ) : AutofillPartition() + + /** + * The login [AutofillPartition] data. + */ + data class Login( + override val views: List, + ) : AutofillPartition() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt new file mode 100644 index 0000000000..927ab1d3ec --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt @@ -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, + val partition: AutofillPartition, + ) : AutofillRequest() + + /** + * An autofill request that is unfillable. + */ + data object Unfillable : AutofillRequest() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt new file mode 100644 index 0000000000..75e4181429 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt @@ -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() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledData.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledData.kt new file mode 100644 index 0000000000..8c4adb30f3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledData.kt @@ -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, + val ignoreAutofillIds: List, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledItem.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledItem.kt new file mode 100644 index 0000000000..d8685b3791 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledItem.kt @@ -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, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParser.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParser.kt new file mode 100644 index 0000000000..f0ae5a65e0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParser.kt @@ -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 +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt new file mode 100644 index 0000000000..bbffa52d22 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt @@ -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(), + ) + } + + is AutofillView.Identity -> { + AutofillPartition.Identity( + views = autofillViews.filterIsInstance(), + ) + } + + is AutofillView.Login -> { + AutofillPartition.Login( + views = autofillViews.filterIsInstance(), + ) + } + } + // 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 = + (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 = mutableListOf() + val mutableIgnoreAutofillIdList: MutableList = 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, + val ignoreAutofillIds: List, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessor.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessor.kt new file mode 100644 index 0000000000..ffdceec38d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessor.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorImpl.kt new file mode 100644 index 0000000000..048a3899f9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorImpl.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledItemExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledItemExtensions.kt new file mode 100644 index 0000000000..f16fa17d37 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledItemExtensions.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt new file mode 100644 index 0000000000..70520fbcb8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt @@ -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 = 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, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViews.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViews.kt new file mode 100644 index 0000000000..3ba7286672 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViews.kt @@ -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), + ) + } diff --git a/app/src/main/res/layout/autofill_remote_view.xml b/app/src/main/res/layout/autofill_remote_view.xml new file mode 100644 index 0000000000..b433796732 --- /dev/null +++ b/app/src/main/res/layout/autofill_remote_view.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillServiceTests.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillServiceTests.kt deleted file mode 100644 index 2bde8f9568..0000000000 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillServiceTests.kt +++ /dev/null @@ -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, - ) - } - } -} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt new file mode 100644 index 0000000000..175806067d --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt @@ -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().build() } returns dataset + every { anyConstructed().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 { it.addDataset(dataset) } + mockBuilder { + 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().addDataset(dataset) + anyConstructed().setIgnoredIds( + ignoredAutofillIdOne, + ignoredAutofillIdTwo, + ) + } + } + + companion object { + private const val PACKAGE_NAME: String = "com.x8bit.bitwarden" + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt new file mode 100644 index 0000000000..599273da3d --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt @@ -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 = 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) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt new file mode 100644 index 0000000000..02a7fd5789 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt @@ -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 + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt new file mode 100644 index 0000000000..9dd02f3be6 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt @@ -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 = 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 = listOf(mockk(), lastFillContext) + val fillRequest: FillRequest = mockk { + every { this@mockk.fillContexts } returns fillContextList + } + val filledItems: List = 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) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledItemExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledItemExtensionsTest.kt new file mode 100644 index 0000000000..0d78021b8f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledItemExtensionsTest.kt @@ -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().build() } returns presentations + every { anyConstructed().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 { it.setDialogPresentation(remoteViews) } + mockBuilder { it.setPresentations(presentations) } + every { + datasetBuilder.setField( + autofillId, + field, + ) + } returns datasetBuilder + + // Test + filledItem.applyOverlayToDataset( + appInfo = appInfo, + datasetBuilder = datasetBuilder, + remoteViews = remoteViews, + ) + + // Verify + verify(exactly = 1) { + anyConstructed().setDialogPresentation(remoteViews) + anyConstructed().setPresentations(presentations) + datasetBuilder.setField( + autofillId, + field, + ) + } + } + + companion object { + private const val PACKAGE_NAME: String = "com.x8bit.bitwarden" + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt new file mode 100644 index 0000000000..142ae29df8 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/util/TestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/data/util/TestHelpers.kt index c99ea6a57b..40de9de267 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/util/TestHelpers.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/util/TestHelpers.kt @@ -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 { it.setIgnoredIds() } + * every { anyConstructed().build() } returns mockk() + * + * // Test + * ... + * + * // Verify + * verify(exactly = 1) { + * anyConstructed().setIgnoredIds() + * anyConstructed().build() + * } + * unmockkConstructor(FillResponse.Builder::class) + * ``` + */ +inline fun mockBuilder(crossinline block: MockKMatcherScope.(T) -> T) { + every { block(anyConstructed()) } answers { + this.self as T + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViewsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViewsTest.kt new file mode 100644 index 0000000000..3e5616cc3c --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViewsTest.kt @@ -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() + .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() + .setTextViewText( + R.id.text, + "Bitwarden", + ) + } + } + + companion object { + private const val PACKAGE_NAME: String = "com.x8bit.bitwarden" + } +}