diff --git a/README.md b/README.md index bd3cbf61a8..34788cb403 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,11 @@ The following is a list of all third-party dependencies included as part of the - Purpose: Allows access to new APIs on older API versions. - License: Apache 2.0 +- **AndroidX Autofill** + - https://developer.android.com/jetpack/androidx/releases/autofill + - Purpose: Allows access to tools for building inline autofill UI. + - License: Apache 2.0 + - **AndroidX Browser** - https://developer.android.com/jetpack/androidx/releases/browser - Purpose: Displays webpages with the user's default browser. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff3414f500..541fb7c78c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -149,6 +149,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) + implementation(libs.androidx.autofill) implementation(libs.androidx.browser) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bb12e899cb..03cd10c6d9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,6 +51,9 @@ android:exported="true" android:label="Bitwarden" android:permission="android.permission.BIND_AUTOFILL_SERVICE"> + 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 index 2a9bbc23f5..79aa111f95 100644 --- 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 @@ -34,6 +34,9 @@ class FillResponseBuilderImpl : FillResponseBuilder { fillResponseBuilder.addDataset(dataset) } } + + // TODO: add vault item dataset (BIT-1296) + fillResponseBuilder .setIgnoredIds(*filledData.ignoreAutofillIds.toTypedArray()) .build() 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 index 84b38a5b29..dccc29c624 100644 --- 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 @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.data.autofill.builder +import android.widget.inline.InlinePresentationSpec import com.x8bit.bitwarden.data.autofill.model.AutofillCipher import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillRequest @@ -19,6 +20,23 @@ class FilledDataBuilderImpl( override suspend fun build(autofillRequest: AutofillRequest.Fillable): FilledData { // TODO: determine whether or not the vault is locked (BIT-1296) + // Subtract one to make sure there is space for the vault item. + val maxCipherInlineSuggestionsCount = autofillRequest.maxInlineSuggestionsCount - 1 + // Track the number of inline suggestions that have been added. + var inlineSuggestionsAdded = 0 + + // A function for managing the cipher InlinePresentationSpecs. + fun getCipherInlinePresentationOrNull(): InlinePresentationSpec? = + if (inlineSuggestionsAdded < maxCipherInlineSuggestionsCount) { + // Use getOrLastOrNull so if the list has run dry take the last spec. + autofillRequest + .inlinePresentationSpecs + .getOrLastOrNull(inlineSuggestionsAdded) + } else { + null + } + ?.also { inlineSuggestionsAdded += 1 } + val filledPartitions = when (autofillRequest.partition) { is AutofillPartition.Card -> { autofillCipherProvider @@ -27,6 +45,7 @@ class FilledDataBuilderImpl( fillCardPartition( autofillCipher = autofillCipher, autofillViews = autofillRequest.partition.views, + inlinePresentationSpec = getCipherInlinePresentationOrNull(), ) } } @@ -43,6 +62,7 @@ class FilledDataBuilderImpl( fillLoginPartition( autofillCipher = autofillCipher, autofillViews = autofillRequest.partition.views, + inlinePresentationSpec = getCipherInlinePresentationOrNull(), ) } } @@ -50,9 +70,15 @@ class FilledDataBuilderImpl( } } + // Use getOrLastOrNull so if the list has run dry take the last spec. + val vaultItemInlinePresentationSpec = autofillRequest + .inlinePresentationSpecs + .getOrLastOrNull(inlineSuggestionsAdded) + return FilledData( filledPartitions = filledPartitions, ignoreAutofillIds = autofillRequest.ignoreAutofillIds, + vaultItemInlinePresentationSpec = vaultItemInlinePresentationSpec, ) } @@ -63,6 +89,7 @@ class FilledDataBuilderImpl( private fun fillCardPartition( autofillCipher: AutofillCipher.Card, autofillViews: List, + inlinePresentationSpec: InlinePresentationSpec?, ): FilledPartition { val filledItems = autofillViews .map { autofillView -> @@ -80,6 +107,7 @@ class FilledDataBuilderImpl( return FilledPartition( autofillCipher = autofillCipher, filledItems = filledItems, + inlinePresentationSpec = inlinePresentationSpec, ) } @@ -90,6 +118,7 @@ class FilledDataBuilderImpl( private fun fillLoginPartition( autofillCipher: AutofillCipher.Login, autofillViews: List, + inlinePresentationSpec: InlinePresentationSpec?, ): FilledPartition { val filledItems = autofillViews .map { autofillView -> @@ -108,6 +137,15 @@ class FilledDataBuilderImpl( return FilledPartition( autofillCipher = autofillCipher, filledItems = filledItems, + inlinePresentationSpec = inlinePresentationSpec, ) } } + +/** + * Get the item at the [index]. If that fails, return the last item in the list. If that also fails, + * return null. + */ +private fun List.getOrLastOrNull(index: Int): T? = + getOrNull(index) + ?: lastOrNull() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillCipher.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillCipher.kt index b5a614a007..c90f881ed2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillCipher.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillCipher.kt @@ -1,9 +1,17 @@ package com.x8bit.bitwarden.data.autofill.model +import androidx.annotation.DrawableRes +import com.x8bit.bitwarden.R + /** * A paired down model of the CipherView for use within the autofill feature. */ sealed class AutofillCipher { + /** + * The icon res to represent this [AutofillCipher]. + */ + abstract val iconRes: Int + /** * The name of the cipher. */ @@ -26,7 +34,10 @@ sealed class AutofillCipher { val expirationMonth: String, val expirationYear: String, val number: String, - ) : AutofillCipher() + ) : AutofillCipher() { + override val iconRes: Int + @DrawableRes get() = R.drawable.ic_card_item + } /** * The card [AutofillCipher] model. This contains all of the data for building fulfilling a @@ -37,5 +48,8 @@ sealed class AutofillCipher { override val subtitle: String, val password: String, val username: String, - ) : AutofillCipher() + ) : AutofillCipher() { + override val iconRes: Int + @DrawableRes get() = R.drawable.ic_login_item + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt index 1b3bb71217..9817deb84c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.autofill.model import android.view.autofill.AutofillId +import android.widget.inline.InlinePresentationSpec /** * The parsed autofill request. @@ -12,6 +13,8 @@ sealed class AutofillRequest { */ data class Fillable( val ignoreAutofillIds: List, + val inlinePresentationSpecs: List, + val maxInlineSuggestionsCount: Int, val partition: AutofillPartition, val uri: String?, ) : AutofillRequest() 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 index 64cea126ff..faddbc1712 100644 --- 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 @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.autofill.model import android.view.autofill.AutofillId +import android.widget.inline.InlinePresentationSpec /** * A fulfilled autofill dataset. This is all of the data to fulfill each view of the autofill @@ -9,4 +10,5 @@ import android.view.autofill.AutofillId data class FilledData( val filledPartitions: List, val ignoreAutofillIds: List, + val vaultItemInlinePresentationSpec: InlinePresentationSpec?, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledPartition.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledPartition.kt index 04dbbae00e..9a53c6a9dd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledPartition.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledPartition.kt @@ -1,13 +1,17 @@ package com.x8bit.bitwarden.data.autofill.model +import android.widget.inline.InlinePresentationSpec + /** * All of the data required to build a `Dataset` for fulfilling a partition of data based on an * [AutofillCipher]. * * @param autofillCipher The cipher used to fulfill these [filledItems]. * @param filledItems A filled copy of each view from this partition. + * @param inlinePresentationSpec The spec for the inline presentation given one is expected. */ data class FilledPartition( val autofillCipher: AutofillCipher, val filledItems: List, + val inlinePresentationSpec: InlinePresentationSpec?, ) 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 index 1f1fb75629..b9407d2c73 100644 --- 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 @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.autofill.parser import android.service.autofill.FillRequest +import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillRequest /** @@ -10,6 +11,12 @@ interface AutofillParser { /** * Parse the useful information from [fillRequest] into an [AutofillRequest]. + * + * @param autofillAppInfo Provides app context that is required to properly parse the request. + * @param fillRequest The request that needs parsing. */ - fun parse(fillRequest: FillRequest): AutofillRequest + fun parse( + autofillAppInfo: AutofillAppInfo, + fillRequest: FillRequest, + ): 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 index 159d1c6f55..82f9cc9cc1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt @@ -3,11 +3,14 @@ package com.x8bit.bitwarden.data.autofill.parser import android.app.assist.AssistStructure import android.service.autofill.FillRequest import android.view.autofill.AutofillId +import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillView import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull +import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs +import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount import com.x8bit.bitwarden.data.autofill.util.toAutofillView /** @@ -15,7 +18,10 @@ import com.x8bit.bitwarden.data.autofill.util.toAutofillView * from the OS into domain models. */ class AutofillParserImpl : AutofillParser { - override fun parse(fillRequest: FillRequest): AutofillRequest = + override fun parse( + autofillAppInfo: AutofillAppInfo, + fillRequest: FillRequest, + ): AutofillRequest = // Attempt to get the most recent autofill context. fillRequest .fillContexts @@ -24,6 +30,8 @@ class AutofillParserImpl : AutofillParser { ?.let { assistStructure -> parseInternal( assistStructure = assistStructure, + autofillAppInfo = autofillAppInfo, + fillRequest = fillRequest, ) } ?: AutofillRequest.Unfillable @@ -33,6 +41,8 @@ class AutofillParserImpl : AutofillParser { */ private fun parseInternal( assistStructure: AssistStructure, + autofillAppInfo: AutofillAppInfo, + fillRequest: FillRequest, ): AutofillRequest { // Parse the `assistStructure` into internal models. val traversalDataList = assistStructure.traverse() @@ -69,8 +79,18 @@ class AutofillParserImpl : AutofillParser { .map { it.ignoreAutofillIds } .flatten() + val maxInlineSuggestionsCount = fillRequest.getMaxInlineSuggestionsCount( + autofillAppInfo = autofillAppInfo, + ) + + val inlinePresentationSpecs = fillRequest.getInlinePresentationSpecs( + autofillAppInfo = autofillAppInfo, + ) + return AutofillRequest.Fillable( + inlinePresentationSpecs = inlinePresentationSpecs, ignoreAutofillIds = ignoreAutofillIds, + maxInlineSuggestionsCount = maxInlineSuggestionsCount, partition = partition, uri = uri, ) 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 index 5fa62c732e..5ffa8ad5fe 100644 --- 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 @@ -54,7 +54,11 @@ class AutofillProcessorImpl( fillRequest: FillRequest, ) { // Parse the OS data into an [AutofillRequest] for easier processing. - when (val autofillRequest = parser.parse(fillRequest)) { + val autofillRequest = parser.parse( + autofillAppInfo = autofillAppInfo, + fillRequest = fillRequest, + ) + when (autofillRequest) { is AutofillRequest.Fillable -> { scope.launch { // Fulfill the [autofillRequest]. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FillRequestExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FillRequestExtensions.kt new file mode 100644 index 0000000000..27812ffe6b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FillRequestExtensions.kt @@ -0,0 +1,34 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.annotation.SuppressLint +import android.os.Build +import android.service.autofill.FillRequest +import android.widget.inline.InlinePresentationSpec +import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo + +/** + * Extract the list of [InlinePresentationSpec]s. If it fails, return an empty list. + */ +@SuppressLint("NewApi") +fun FillRequest.getInlinePresentationSpecs( + autofillAppInfo: AutofillAppInfo, +): List = + if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.R) { + inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList() + } else { + emptyList() + } + +/** + * Extract the max inline suggestions count. If the OS is below Android R, this will always + * return 0. + */ +@SuppressLint("NewApi") +fun FillRequest.getMaxInlineSuggestionsCount( + autofillAppInfo: AutofillAppInfo, +): Int = + if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.R) { + inlineSuggestionsRequest?.maxSuggestionCount ?: 0 + } else { + 0 + } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensions.kt index 86bf04014c..e12f00ce74 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensions.kt @@ -5,14 +5,17 @@ import android.os.Build import android.service.autofill.Dataset import android.service.autofill.Presentations import android.widget.RemoteViews +import androidx.annotation.RequiresApi import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.FilledPartition import com.x8bit.bitwarden.ui.autofill.buildAutofillRemoteViews +import com.x8bit.bitwarden.ui.autofill.util.createCipherInlinePresentationOrNull /** * Build a [Dataset] to represent the [FilledPartition]. This dataset includes an overlay UI * presentation for each filled item. */ +@SuppressLint("NewApi") fun FilledPartition.buildDataset( autofillAppInfo: AutofillAppInfo, ): Dataset { @@ -24,11 +27,13 @@ fun FilledPartition.buildDataset( if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.TIRAMISU) { applyToDatasetPostTiramisu( + autofillAppInfo = autofillAppInfo, datasetBuilder = datasetBuilder, remoteViews = remoteViewsPlaceholder, ) } else { buildDatasetPreTiramisu( + autofillAppInfo = autofillAppInfo, datasetBuilder = datasetBuilder, remoteViews = remoteViewsPlaceholder, ) @@ -41,12 +46,23 @@ fun FilledPartition.buildDataset( * Apply this [FilledPartition] to the [datasetBuilder] on devices running OS version Tiramisu or * greater. */ -@SuppressLint("NewApi") +@RequiresApi(Build.VERSION_CODES.TIRAMISU) private fun FilledPartition.applyToDatasetPostTiramisu( + autofillAppInfo: AutofillAppInfo, datasetBuilder: Dataset.Builder, remoteViews: RemoteViews, ) { - val presentation = Presentations.Builder() + val presentationBuilder = Presentations.Builder() + inlinePresentationSpec + ?.createCipherInlinePresentationOrNull( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + ?.let { inlinePresentation -> + presentationBuilder.setInlinePresentation(inlinePresentation) + } + + val presentation = presentationBuilder .setMenuPresentation(remoteViews) .build() @@ -62,10 +78,24 @@ private fun FilledPartition.applyToDatasetPostTiramisu( * Apply this [FilledPartition] to the [datasetBuilder] on devices running OS versions that predate * Tiramisu. */ +@Suppress("DEPRECATION") +@SuppressLint("NewApi") private fun FilledPartition.buildDatasetPreTiramisu( + autofillAppInfo: AutofillAppInfo, datasetBuilder: Dataset.Builder, remoteViews: RemoteViews, ) { + if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.R) { + inlinePresentationSpec + ?.createCipherInlinePresentationOrNull( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + ?.let { inlinePresentation -> + datasetBuilder.setInlinePresentation(inlinePresentation) + } + } + filledItems.forEach { filledItem -> filledItem.applyToDatasetPreTiramisu( datasetBuilder = datasetBuilder, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/ContextExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/ContextExtensions.kt new file mode 100644 index 0000000000..77e3cd7e66 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/ContextExtensions.kt @@ -0,0 +1,11 @@ +package com.x8bit.bitwarden.ui.autofill.util + +import android.content.Context +import android.content.res.Configuration + +/** + * Whether or not dark mode is currently active at the system level. + */ +val Context.isSystemDarkMode: Boolean + get() = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensions.kt new file mode 100644 index 0000000000..b035918c8f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensions.kt @@ -0,0 +1,74 @@ +package com.x8bit.bitwarden.ui.autofill.util + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Build +import android.service.autofill.InlinePresentation +import android.widget.inline.InlinePresentationSpec +import androidx.annotation.RequiresApi +import androidx.autofill.inline.UiVersions +import androidx.autofill.inline.v1.InlineSuggestionUi +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo +import com.x8bit.bitwarden.data.autofill.model.AutofillCipher + +/** + * Try creating an [InlinePresentation] for [autofillCipher] with this [InlinePresentationSpec]. If + * it fails, return null. + */ +@RequiresApi(Build.VERSION_CODES.R) +@SuppressLint("RestrictedApi") +fun InlinePresentationSpec.createCipherInlinePresentationOrNull( + autofillAppInfo: AutofillAppInfo, + autofillCipher: AutofillCipher, +): InlinePresentation? { + val isInlineCompatible = UiVersions + .getVersions(style) + .contains(UiVersions.INLINE_UI_VERSION_1) + + if (!isInlineCompatible) return null + + val pendingIntent = PendingIntent.getService( + autofillAppInfo.context, + 0, + Intent(), + PendingIntent.FLAG_ONE_SHOT or + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_IMMUTABLE, + ) + val icon = Icon + .createWithResource( + autofillAppInfo.context, + autofillCipher.iconRes, + ) + .setTint(autofillAppInfo.contentColor) + val slice = InlineSuggestionUi + .newContentBuilder(pendingIntent) + .setTitle(autofillCipher.name) + .setSubtitle(autofillCipher.subtitle) + .setStartIcon(icon) + .build() + .slice + + return InlinePresentation( + slice, + this, + false, + ) +} + +/** + * Get the content color for the inline presentation. + */ +private val AutofillAppInfo.contentColor: Int + get() { + val colorRes = if (context.isSystemDarkMode) { + R.color.dark_on_surface + } else { + R.color.on_surface + } + + return context.getColor(colorRes) + } diff --git a/app/src/main/res/xml-v30/autofill_service_configuration.xml b/app/src/main/res/xml-v30/autofill_service_configuration.xml new file mode 100644 index 0000000000..55cfd6821e --- /dev/null +++ b/app/src/main/res/xml-v30/autofill_service_configuration.xml @@ -0,0 +1,3 @@ + + diff --git a/app/src/main/res/xml/autofill_service_configuration.xml b/app/src/main/res/xml/autofill_service_configuration.xml new file mode 100644 index 0000000000..f9c2255297 --- /dev/null +++ b/app/src/main/res/xml/autofill_service_configuration.xml @@ -0,0 +1,2 @@ + + 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 index 402f8fb129..8ad696c68a 100644 --- 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 @@ -60,6 +60,7 @@ class FillResponseBuilderTest { val filledData = FilledData( filledPartitions = emptyList(), ignoreAutofillIds = emptyList(), + vaultItemInlinePresentationSpec = null, ) val actual = fillResponseBuilder.build( autofillAppInfo = appInfo, @@ -76,12 +77,14 @@ class FillResponseBuilderTest { val filledPartitions = FilledPartition( autofillCipher = mockk(), filledItems = emptyList(), + inlinePresentationSpec = null, ) val filledData = FilledData( filledPartitions = listOf( filledPartitions, ), ignoreAutofillIds = emptyList(), + vaultItemInlinePresentationSpec = null, ) val actual = fillResponseBuilder.build( autofillAppInfo = appInfo, @@ -108,6 +111,7 @@ class FillResponseBuilderTest { val filledData = FilledData( filledPartitions = filledPartitions, ignoreAutofillIds = ignoreAutofillIds, + vaultItemInlinePresentationSpec = null, ) every { filledPartitionOne.buildDataset( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt index 2c62b49c33..f1e2999e0b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.autofill.builder import android.view.autofill.AutofillId import android.view.autofill.AutofillValue +import android.widget.inline.InlinePresentationSpec import com.x8bit.bitwarden.data.autofill.model.AutofillCipher import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillRequest @@ -83,6 +84,8 @@ class FilledDataBuilderTest { val ignoreAutofillIds: List = mockk() val autofillRequest = AutofillRequest.Fillable( ignoreAutofillIds = ignoreAutofillIds, + inlinePresentationSpecs = emptyList(), + maxInlineSuggestionsCount = 0, partition = autofillPartition, uri = URI, ) @@ -96,12 +99,14 @@ class FilledDataBuilderTest { filledItemPassword, filledItemUsername, ), + inlinePresentationSpec = null, ) val expected = FilledData( filledPartitions = listOf( filledPartition, ), ignoreAutofillIds = ignoreAutofillIds, + vaultItemInlinePresentationSpec = null, ) coEvery { autofillCipherProvider.getLoginAutofillCiphers( @@ -154,12 +159,15 @@ class FilledDataBuilderTest { val ignoreAutofillIds: List = mockk() val autofillRequest = AutofillRequest.Fillable( ignoreAutofillIds = ignoreAutofillIds, + inlinePresentationSpecs = emptyList(), + maxInlineSuggestionsCount = 0, partition = autofillPartition, uri = null, ) val expected = FilledData( filledPartitions = emptyList(), ignoreAutofillIds = ignoreAutofillIds, + vaultItemInlinePresentationSpec = null, ) // Test @@ -210,6 +218,8 @@ class FilledDataBuilderTest { val ignoreAutofillIds: List = mockk() val autofillRequest = AutofillRequest.Fillable( ignoreAutofillIds = ignoreAutofillIds, + inlinePresentationSpecs = emptyList(), + maxInlineSuggestionsCount = 0, partition = autofillPartition, uri = URI, ) @@ -225,12 +235,14 @@ class FilledDataBuilderTest { filledItemExpirationYear, filledItemNumber, ), + inlinePresentationSpec = null, ) val expected = FilledData( filledPartitions = listOf( filledPartition, ), ignoreAutofillIds = ignoreAutofillIds, + vaultItemInlinePresentationSpec = null, ) coEvery { autofillCipherProvider.getCardAutofillCiphers() } returns listOf(autofillCipher) every { autofillViewCode.buildFilledItemOrNull(code) } returns filledItemCode @@ -258,6 +270,107 @@ class FilledDataBuilderTest { } } + @Test + fun `build should return filled data with max count of inline specs with one spec repeated`() = + runTest { + // Setup + val password = "Password" + val username = "johnDoe" + val autofillCipher = AutofillCipher.Login( + name = "Cipher One", + password = password, + username = username, + subtitle = "Subtitle", + ) + val autofillViewEmail = AutofillView.Login.EmailAddress( + data = autofillViewData, + ) + val autofillViewPassword = AutofillView.Login.Password( + data = autofillViewData, + ) + val autofillViewUsername = AutofillView.Login.Username( + data = autofillViewData, + ) + val autofillPartition = AutofillPartition.Login( + views = listOf( + autofillViewEmail, + autofillViewPassword, + autofillViewUsername, + ), + ) + val inlinePresentationSpec: InlinePresentationSpec = mockk() + val autofillRequest = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = listOf( + inlinePresentationSpec, + ), + maxInlineSuggestionsCount = 3, + partition = autofillPartition, + uri = URI, + ) + val filledItemEmail: FilledItem = mockk() + val filledItemPassword: FilledItem = mockk() + val filledItemUsername: FilledItem = mockk() + val filledPartitionOne = FilledPartition( + autofillCipher = autofillCipher, + filledItems = listOf( + filledItemEmail, + filledItemPassword, + filledItemUsername, + ), + inlinePresentationSpec = inlinePresentationSpec, + ) + val filledPartitionTwo = filledPartitionOne.copy() + val filledPartitionThree = FilledPartition( + autofillCipher = autofillCipher, + filledItems = listOf( + filledItemEmail, + filledItemPassword, + filledItemUsername, + ), + inlinePresentationSpec = null, + ) + val expected = FilledData( + filledPartitions = listOf( + filledPartitionOne, + filledPartitionTwo, + filledPartitionThree, + ), + ignoreAutofillIds = emptyList(), + vaultItemInlinePresentationSpec = inlinePresentationSpec, + ) + coEvery { + autofillCipherProvider.getLoginAutofillCiphers( + uri = URI, + ) + } returns listOf(autofillCipher, autofillCipher, autofillCipher) + every { autofillViewEmail.buildFilledItemOrNull(username) } returns filledItemEmail + every { + autofillViewPassword.buildFilledItemOrNull(password) + } returns filledItemPassword + every { + autofillViewUsername.buildFilledItemOrNull(username) + } returns filledItemUsername + + // Test + val actual = filledDataBuilder.build( + autofillRequest = autofillRequest, + ) + + // Verify + assertEquals(expected, actual) + coVerify(exactly = 1) { + autofillCipherProvider.getLoginAutofillCiphers( + uri = URI, + ) + } + verify(exactly = 3) { + autofillViewEmail.buildFilledItemOrNull(username) + autofillViewPassword.buildFilledItemOrNull(password) + autofillViewUsername.buildFilledItemOrNull(username) + } + } + companion object { private const val URI: String = "androidapp://com.x8bit.bitwarden" } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt index 94be6dc486..b00be6c2dd 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt @@ -5,11 +5,15 @@ import android.service.autofill.FillContext import android.service.autofill.FillRequest import android.view.View import android.view.autofill.AutofillId +import android.widget.inline.InlinePresentationSpec +import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillView import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull +import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs +import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount import com.x8bit.bitwarden.data.autofill.util.toAutofillView import io.mockk.every import io.mockk.mockk @@ -24,6 +28,7 @@ import org.junit.jupiter.api.Test class AutofillParserTests { private lateinit var parser: AutofillParser + private val autofillAppInfo: AutofillAppInfo = mockk() private val autofillViewData = AutofillView.Data( autofillId = mockk(), isFocused = true, @@ -58,11 +63,26 @@ class AutofillParserTests { private val fillRequest: FillRequest = mockk { every { this@mockk.fillContexts } returns listOf(fillContext) } + private val inlinePresentationSpecs: List = mockk() @BeforeEach fun setup() { mockkStatic(AssistStructure.ViewNode::toAutofillView) + mockkStatic( + FillRequest::getMaxInlineSuggestionsCount, + FillRequest::getInlinePresentationSpecs, + ) mockkStatic(List::buildUriOrNull) + every { + fillRequest.getInlinePresentationSpecs( + autofillAppInfo = autofillAppInfo, + ) + } returns inlinePresentationSpecs + every { + fillRequest.getMaxInlineSuggestionsCount( + autofillAppInfo = autofillAppInfo, + ) + } returns MAX_INLINE_SUGGESTION_COUNT every { any>().buildUriOrNull(assistStructure) } returns URI parser = AutofillParserImpl() } @@ -70,6 +90,10 @@ class AutofillParserTests { @AfterEach fun teardown() { unmockkStatic(AssistStructure.ViewNode::toAutofillView) + unmockkStatic( + FillRequest::getMaxInlineSuggestionsCount, + FillRequest::getInlinePresentationSpecs, + ) unmockkStatic(List::buildUriOrNull) } @@ -80,7 +104,10 @@ class AutofillParserTests { every { fillRequest.fillContexts } returns emptyList() // Test - val actual = parser.parse(fillRequest) + val actual = parser.parse( + autofillAppInfo = autofillAppInfo, + fillRequest = fillRequest, + ) // Verify assertEquals(expected, actual) @@ -93,7 +120,10 @@ class AutofillParserTests { every { assistStructure.windowNodeCount } returns 0 // Test - val actual = parser.parse(fillRequest) + val actual = parser.parse( + autofillAppInfo = autofillAppInfo, + fillRequest = fillRequest, + ) // Verify assertEquals(expected, actual) @@ -134,6 +164,8 @@ class AutofillParserTests { ) val expected = AutofillRequest.Fillable( ignoreAutofillIds = listOf(childAutofillId), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, partition = autofillPartition, uri = URI, ) @@ -141,11 +173,20 @@ class AutofillParserTests { every { assistStructure.getWindowNodeAt(0) } returns windowNode // Test - val actual = parser.parse(fillRequest) + val actual = parser.parse( + autofillAppInfo = autofillAppInfo, + fillRequest = fillRequest, + ) // Verify assertEquals(expected, actual) verify(exactly = 1) { + fillRequest.getInlinePresentationSpecs( + autofillAppInfo = autofillAppInfo, + ) + fillRequest.getMaxInlineSuggestionsCount( + autofillAppInfo = autofillAppInfo, + ) any>().buildUriOrNull(assistStructure) } } @@ -171,6 +212,8 @@ class AutofillParserTests { ) val expected = AutofillRequest.Fillable( ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, partition = autofillPartition, uri = URI, ) @@ -178,11 +221,20 @@ class AutofillParserTests { every { loginViewNode.toAutofillView() } returns loginAutofillView // Test - val actual = parser.parse(fillRequest) + val actual = parser.parse( + autofillAppInfo = autofillAppInfo, + fillRequest = fillRequest, + ) // Verify assertEquals(expected, actual) verify(exactly = 1) { + fillRequest.getInlinePresentationSpecs( + autofillAppInfo = autofillAppInfo, + ) + fillRequest.getMaxInlineSuggestionsCount( + autofillAppInfo = autofillAppInfo, + ) any>().buildUriOrNull(assistStructure) } } @@ -208,6 +260,8 @@ class AutofillParserTests { ) val expected = AutofillRequest.Fillable( ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, partition = autofillPartition, uri = URI, ) @@ -215,11 +269,20 @@ class AutofillParserTests { every { loginViewNode.toAutofillView() } returns loginAutofillView // Test - val actual = parser.parse(fillRequest) + val actual = parser.parse( + autofillAppInfo = autofillAppInfo, + fillRequest = fillRequest, + ) // Verify assertEquals(expected, actual) verify(exactly = 1) { + fillRequest.getInlinePresentationSpecs( + autofillAppInfo = autofillAppInfo, + ) + fillRequest.getMaxInlineSuggestionsCount( + autofillAppInfo = autofillAppInfo, + ) any>().buildUriOrNull(assistStructure) } } @@ -245,6 +308,8 @@ class AutofillParserTests { ) val expected = AutofillRequest.Fillable( ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, partition = autofillPartition, uri = URI, ) @@ -252,11 +317,20 @@ class AutofillParserTests { every { loginViewNode.toAutofillView() } returns loginAutofillView // Test - val actual = parser.parse(fillRequest) + val actual = parser.parse( + autofillAppInfo = autofillAppInfo, + fillRequest = fillRequest, + ) // Verify assertEquals(expected, actual) verify(exactly = 1) { + fillRequest.getInlinePresentationSpecs( + autofillAppInfo = autofillAppInfo, + ) + fillRequest.getMaxInlineSuggestionsCount( + autofillAppInfo = autofillAppInfo, + ) any>().buildUriOrNull(assistStructure) } } @@ -270,8 +344,7 @@ class AutofillParserTests { every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode every { assistStructure.getWindowNodeAt(1) } returns loginWindowNode } - - companion object { - private const val URI: String = "androidapp://com.x8bit.bitwarden" - } } + +private const val MAX_INLINE_SUGGESTION_COUNT: Int = 42 +private const val URI: String = "androidapp://com.x8bit.bitwarden" 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 index b6f0871d1d..9660c6616c 100644 --- 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 @@ -76,7 +76,12 @@ class AutofillProcessorTest { val autofillRequest = AutofillRequest.Unfillable val fillRequest: FillRequest = mockk() every { cancellationSignal.setOnCancelListener(any()) } just runs - every { parser.parse(fillRequest) } returns autofillRequest + every { + parser.parse( + autofillAppInfo = appInfo, + fillRequest = fillRequest, + ) + } returns autofillRequest every { fillCallback.onSuccess(null) } just runs // Test @@ -90,7 +95,10 @@ class AutofillProcessorTest { // Verify verify(exactly = 1) { cancellationSignal.setOnCancelListener(any()) - parser.parse(fillRequest) + parser.parse( + autofillAppInfo = appInfo, + fillRequest = fillRequest, + ) fillCallback.onSuccess(null) } } @@ -103,6 +111,7 @@ class AutofillProcessorTest { val filledData = FilledData( filledPartitions = listOf(mockk()), ignoreAutofillIds = emptyList(), + vaultItemInlinePresentationSpec = null, ) val fillResponse: FillResponse = mockk() val autofillRequest: AutofillRequest.Fillable = mockk() @@ -112,7 +121,12 @@ class AutofillProcessorTest { ) } returns filledData every { cancellationSignal.setOnCancelListener(any()) } just runs - every { parser.parse(fillRequest) } returns autofillRequest + every { + parser.parse( + autofillAppInfo = appInfo, + fillRequest = fillRequest, + ) + } returns autofillRequest every { fillResponseBuilder.build( autofillAppInfo = appInfo, @@ -137,7 +151,10 @@ class AutofillProcessorTest { } verify(exactly = 1) { cancellationSignal.setOnCancelListener(any()) - parser.parse(fillRequest) + parser.parse( + autofillAppInfo = appInfo, + fillRequest = fillRequest, + ) fillResponseBuilder.build( autofillAppInfo = appInfo, filledData = filledData, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FillRequestExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FillRequestExtensionsTest.kt new file mode 100644 index 0000000000..f878e10bdd --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FillRequestExtensionsTest.kt @@ -0,0 +1,99 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.service.autofill.FillRequest +import android.view.inputmethod.InlineSuggestionsRequest +import android.widget.inline.InlinePresentationSpec +import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class FillRequestExtensionsTest { + private val expectedInlinePresentationSpecs: List = mockk() + private val expectedInlineSuggestionsRequest: InlineSuggestionsRequest = mockk { + every { this@mockk.inlinePresentationSpecs } returns expectedInlinePresentationSpecs + every { this@mockk.maxSuggestionCount } returns MAX_INLINE_SUGGESTIONS_COUNT + } + private val fillRequest: FillRequest = mockk { + every { this@mockk.inlineSuggestionsRequest } returns expectedInlineSuggestionsRequest + } + + @Test + fun `getInlinePresentationSpecs should return empty list when pre-R`() { + // Setup + val autofillAppInfo = AutofillAppInfo( + context = mockk(), + packageName = "com.x8bit.bitwarden", + sdkInt = 17, + ) + val expected: List = emptyList() + + // Test + val actual = fillRequest.getInlinePresentationSpecs( + autofillAppInfo = autofillAppInfo, + ) + + // Verify + assertEquals(expected, actual) + } + + @Test + fun `getInlinePresentationSpecs should return populated list when post-R`() { + // Setup + val autofillAppInfo = AutofillAppInfo( + context = mockk(), + packageName = "com.x8bit.bitwarden", + sdkInt = 34, + ) + val expected = expectedInlinePresentationSpecs + + // Test + val actual = fillRequest.getInlinePresentationSpecs( + autofillAppInfo = autofillAppInfo, + ) + + // Verify + assertEquals(expected, actual) + } + + @Test + fun `getMaxInlineSuggestionsCount should return 0 when pre-R`() { + // Setup + val autofillAppInfo = AutofillAppInfo( + context = mockk(), + packageName = "com.x8bit.bitwarden", + sdkInt = 17, + ) + val expected = 0 + + // Test + val actual = fillRequest.getMaxInlineSuggestionsCount( + autofillAppInfo = autofillAppInfo, + ) + + // Verify + assertEquals(expected, actual) + } + + @Test + fun `getMaxInlineSuggestionsCount should return the max count when post-R`() { + // Setup + val autofillAppInfo = AutofillAppInfo( + context = mockk(), + packageName = "com.x8bit.bitwarden", + sdkInt = 34, + ) + val expected = MAX_INLINE_SUGGESTIONS_COUNT + + // Test + val actual = fillRequest.getMaxInlineSuggestionsCount( + autofillAppInfo = autofillAppInfo, + ) + + // Verify + assertEquals(expected, actual) + } +} + +private const val MAX_INLINE_SUGGESTIONS_COUNT: Int = 42 diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensionsTest.kt index 54eb4d5bea..c693f9dac9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensionsTest.kt @@ -3,14 +3,17 @@ package com.x8bit.bitwarden.data.autofill.util import android.content.Context import android.content.res.Resources import android.service.autofill.Dataset +import android.service.autofill.InlinePresentation import android.service.autofill.Presentations import android.widget.RemoteViews +import android.widget.inline.InlinePresentationSpec import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillCipher import com.x8bit.bitwarden.data.autofill.model.FilledItem import com.x8bit.bitwarden.data.autofill.model.FilledPartition import com.x8bit.bitwarden.data.util.mockBuilder import com.x8bit.bitwarden.ui.autofill.buildAutofillRemoteViews +import com.x8bit.bitwarden.ui.autofill.util.createCipherInlinePresentationOrNull import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -35,11 +38,13 @@ class FilledPartitionExtensionsTest { } private val dataset: Dataset = mockk() private val filledItem: FilledItem = mockk() + private val inlinePresentationSpec: InlinePresentationSpec = mockk() private val filledPartition = FilledPartition( autofillCipher = autofillCipher, filledItems = listOf( filledItem, ), + inlinePresentationSpec = inlinePresentationSpec, ) private val presentations: Presentations = mockk() private val remoteViews: RemoteViews = mockk() @@ -51,6 +56,7 @@ class FilledPartitionExtensionsTest { mockkStatic(::buildAutofillRemoteViews) mockkStatic(FilledItem::applyToDatasetPostTiramisu) mockkStatic(FilledItem::applyToDatasetPreTiramisu) + mockkStatic(InlinePresentationSpec::createCipherInlinePresentationOrNull) every { anyConstructed().build() } returns dataset } @@ -61,6 +67,7 @@ class FilledPartitionExtensionsTest { unmockkStatic(::buildAutofillRemoteViews) unmockkStatic(FilledItem::applyToDatasetPostTiramisu) unmockkStatic(FilledItem::applyToDatasetPreTiramisu) + unmockkStatic(InlinePresentationSpec::createCipherInlinePresentationOrNull) } @Test @@ -71,12 +78,20 @@ class FilledPartitionExtensionsTest { packageName = PACKAGE_NAME, sdkInt = 34, ) + val inlinePresentation: InlinePresentation = mockk() every { buildAutofillRemoteViews( packageName = PACKAGE_NAME, title = CIPHER_NAME, ) } returns remoteViews + every { + inlinePresentationSpec.createCipherInlinePresentationOrNull( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + } returns inlinePresentation + mockBuilder { it.setInlinePresentation(inlinePresentation) } mockBuilder { it.setMenuPresentation(remoteViews) } every { filledItem.applyToDatasetPostTiramisu( @@ -98,6 +113,11 @@ class FilledPartitionExtensionsTest { packageName = PACKAGE_NAME, title = CIPHER_NAME, ) + inlinePresentationSpec.createCipherInlinePresentationOrNull( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + anyConstructed().setInlinePresentation(inlinePresentation) anyConstructed().setMenuPresentation(remoteViews) anyConstructed().build() filledItem.applyToDatasetPostTiramisu( @@ -108,14 +128,16 @@ class FilledPartitionExtensionsTest { } } + @Suppress("MaxLineLength") @Test - fun `buildDataset should applyToDatasetPreTiramisu when sdkInt is less than 33`() { + fun `buildDataset should skip inline and applyToDatasetPreTiramisu when sdkInt is less than 30`() { // Setup val autofillAppInfo = AutofillAppInfo( context = context, packageName = PACKAGE_NAME, sdkInt = 18, ) + val inlinePresentation: InlinePresentation = mockk() every { buildAutofillRemoteViews( packageName = PACKAGE_NAME, @@ -149,6 +171,61 @@ class FilledPartitionExtensionsTest { } } + @Suppress("Deprecation", "MaxLineLength") + @Test + fun `buildDataset should skip inline and applyToDatasetPreTiramisu when sdkInt is less than 33 but more than 29`() { + // Setup + val autofillAppInfo = AutofillAppInfo( + context = context, + packageName = PACKAGE_NAME, + sdkInt = 30, + ) + val inlinePresentation: InlinePresentation = mockk() + every { + buildAutofillRemoteViews( + packageName = PACKAGE_NAME, + title = CIPHER_NAME, + ) + } returns remoteViews + every { + inlinePresentationSpec.createCipherInlinePresentationOrNull( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + } returns inlinePresentation + mockBuilder { it.setInlinePresentation(inlinePresentation) } + every { + filledItem.applyToDatasetPreTiramisu( + datasetBuilder = any(), + remoteViews = remoteViews, + ) + } just runs + + // Test + val actual = filledPartition.buildDataset( + autofillAppInfo = autofillAppInfo, + ) + + // Verify + assertEquals(dataset, actual) + verify(exactly = 1) { + buildAutofillRemoteViews( + packageName = PACKAGE_NAME, + title = CIPHER_NAME, + ) + inlinePresentationSpec.createCipherInlinePresentationOrNull( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + anyConstructed().setInlinePresentation(inlinePresentation) + filledItem.applyToDatasetPreTiramisu( + datasetBuilder = any(), + remoteViews = remoteViews, + ) + anyConstructed().build() + } + } + companion object { private const val CIPHER_NAME: String = "Autofill Cipher" private const val PACKAGE_NAME: String = "com.x8bit.bitwarden" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/ContextExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/ContextExtensionsTest.kt new file mode 100644 index 0000000000..581d5a6588 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/ContextExtensionsTest.kt @@ -0,0 +1,38 @@ +package com.x8bit.bitwarden.ui.autofill.util + +import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ContextExtensionsTest { + private val testConfiguration: Configuration = mockk() + private val testResources: Resources = mockk { + every { this@mockk.configuration } returns testConfiguration + } + private val context: Context = mockk { + every { this@mockk.resources } returns testResources + } + + @Test + fun `isSystemDarkMode should return true when UI_MODE_NIGHT_YES`() { + // Setup + testConfiguration.uiMode = Configuration.UI_MODE_NIGHT_YES + + // Test & Verify + assertTrue(context.isSystemDarkMode) + } + + @Test + fun `isSystemDarkMode should return false when UI_MODE_NIGHT_NO`() { + // Setup + testConfiguration.uiMode = Configuration.UI_MODE_NIGHT_NO + + // Test & Verify + assertFalse(context.isSystemDarkMode) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensionsTest.kt new file mode 100644 index 0000000000..1145f5167d --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensionsTest.kt @@ -0,0 +1,225 @@ +package com.x8bit.bitwarden.ui.autofill.util + +import android.app.PendingIntent +import android.app.slice.Slice +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Bundle +import android.widget.inline.InlinePresentationSpec +import androidx.autofill.inline.UiVersions +import androidx.autofill.inline.v1.InlineSuggestionUi +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo +import com.x8bit.bitwarden.data.autofill.model.AutofillCipher +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class InlinePresentationSpecExtensionsTest { + private val testContext: Context = mockk() + private val autofillAppInfo: AutofillAppInfo = mockk { + every { this@mockk.context } returns testContext + } + private val testStyle: Bundle = mockk() + private val inlinePresentationSpec: InlinePresentationSpec = mockk { + every { this@mockk.style } returns testStyle + } + + @BeforeEach + fun setup() { + mockkStatic(Context::isSystemDarkMode) + mockkStatic(Icon::class) + mockkStatic(InlineSuggestionUi::class) + mockkStatic(PendingIntent::getService) + mockkStatic(UiVersions::getVersions) + } + + @AfterEach + fun teardown() { + mockkStatic(Context::isSystemDarkMode) + unmockkStatic(Icon::class) + unmockkStatic(InlineSuggestionUi::class) + unmockkStatic(PendingIntent::getService) + unmockkStatic(UiVersions::getVersions) + } + + @Test + fun `createCipherInlinePresentationOrNull should return null if incompatible`() { + // Setup + every { + UiVersions.getVersions(testStyle) + } returns emptyList() + + // Test + val actual = inlinePresentationSpec.createCipherInlinePresentationOrNull( + autofillAppInfo = autofillAppInfo, + autofillCipher = mockk(), + ) + + // Verify + Assertions.assertNull(actual) + verify(exactly = 1) { + UiVersions.getVersions(testStyle) + } + } + + @Suppress("MaxLineLength") + @Test + fun `createCipherInlinePresentationOrNull should return presentation with card icon when card cipher and compatible`() { + // Setup + val icon: Icon = mockk() + val autofillCipher: AutofillCipher.Card = mockk { + every { this@mockk.name } returns AUTOFILL_CIPHER_NAME + every { this@mockk.subtitle } returns AUTOFILL_CIPHER_SUBTITLE + every { this@mockk.iconRes } returns R.drawable.ic_card_item + } + val pendingIntent: PendingIntent = mockk() + val slice: Slice = mockk() + every { + UiVersions.getVersions(testStyle) + } returns listOf(UiVersions.INLINE_UI_VERSION_1) + every { + PendingIntent.getService( + testContext, + PENDING_INTENT_CODE, + any(), + PENDING_INTENT_FLAGS, + ) + } returns pendingIntent + every { testContext.isSystemDarkMode } returns true + every { testContext.getColor(R.color.dark_on_surface) } returns ICON_TINT + every { + Icon.createWithResource( + testContext, + R.drawable.ic_card_item, + ) + .setTint(ICON_TINT) + } returns icon + every { + InlineSuggestionUi.newContentBuilder(pendingIntent) + .setTitle(AUTOFILL_CIPHER_NAME) + .setSubtitle(AUTOFILL_CIPHER_SUBTITLE) + .setStartIcon(icon) + .build() + .slice + } returns slice + + // Test + val actual = inlinePresentationSpec.createCipherInlinePresentationOrNull( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + + // Verify not-null because we can't properly mock Intent constructors. + Assertions.assertNotNull(actual) + verify(exactly = 1) { + UiVersions.getVersions(testStyle) + PendingIntent.getService( + testContext, + PENDING_INTENT_CODE, + any(), + PENDING_INTENT_FLAGS, + ) + testContext.isSystemDarkMode + testContext.getColor(R.color.dark_on_surface) + Icon.createWithResource( + testContext, + R.drawable.ic_card_item, + ) + .setTint(ICON_TINT) + InlineSuggestionUi.newContentBuilder(pendingIntent) + .setTitle(AUTOFILL_CIPHER_NAME) + .setSubtitle(AUTOFILL_CIPHER_SUBTITLE) + .setStartIcon(icon) + .build() + .slice + } + } + + @Suppress("MaxLineLength") + @Test + fun `createCipherInlinePresentationOrNull should return presentation with login icon when login cipher and compatible`() { + // Setup + val icon: Icon = mockk() + val autofillCipher: AutofillCipher.Login = mockk { + every { this@mockk.name } returns AUTOFILL_CIPHER_NAME + every { this@mockk.subtitle } returns AUTOFILL_CIPHER_SUBTITLE + every { this@mockk.iconRes } returns R.drawable.ic_login_item + } + val pendingIntent: PendingIntent = mockk() + val slice: Slice = mockk() + every { + UiVersions.getVersions(testStyle) + } returns listOf(UiVersions.INLINE_UI_VERSION_1) + every { + PendingIntent.getService( + testContext, + PENDING_INTENT_CODE, + any(), + PENDING_INTENT_FLAGS, + ) + } returns pendingIntent + every { testContext.isSystemDarkMode } returns false + every { testContext.getColor(R.color.on_surface) } returns ICON_TINT + every { + Icon.createWithResource( + testContext, + R.drawable.ic_login_item, + ) + .setTint(ICON_TINT) + } returns icon + every { + InlineSuggestionUi.newContentBuilder(pendingIntent) + .setTitle(AUTOFILL_CIPHER_NAME) + .setSubtitle(AUTOFILL_CIPHER_SUBTITLE) + .setStartIcon(icon) + .build() + .slice + } returns slice + + // Test + val actual = inlinePresentationSpec.createCipherInlinePresentationOrNull( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + + // Verify not-null because we can't properly mock Intent constructors. + Assertions.assertNotNull(actual) + verify(exactly = 1) { + UiVersions.getVersions(testStyle) + PendingIntent.getService( + testContext, + PENDING_INTENT_CODE, + any(), + PENDING_INTENT_FLAGS, + ) + testContext.isSystemDarkMode + testContext.getColor(R.color.on_surface) + Icon.createWithResource( + testContext, + R.drawable.ic_login_item, + ) + .setTint(ICON_TINT) + InlineSuggestionUi.newContentBuilder(pendingIntent) + .setTitle(AUTOFILL_CIPHER_NAME) + .setSubtitle(AUTOFILL_CIPHER_SUBTITLE) + .setStartIcon(icon) + .build() + .slice + } + } +} + +private const val AUTOFILL_CIPHER_NAME = "Cipher1" +private const val AUTOFILL_CIPHER_SUBTITLE = "Subtitle" +private const val ICON_TINT: Int = 6123751 +private const val PENDING_INTENT_CODE: Int = 0 +private const val PENDING_INTENT_FLAGS: Int = + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c372c0c369..c300c8cd45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ androidxRoom = "2.6.1" androidXSecurityCrypto = "1.1.0-alpha06" androidxSplash = "1.1.0-alpha02" androidXAppCompat = "1.6.1" +androdixAutofill = "1.1.0" # Once the app and SDK reach a critical point of completeness we should begin fixing the version # here (BIT-311). bitwardenSdk = "0.4.0-20240115.154650-43" @@ -55,6 +56,7 @@ zxing = "3.5.2" # Format: - androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidXAppCompat" } +androidx-autofill = { group = "androidx.autofill", name = "autofill", version.ref = "androdixAutofill" } androidx-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidxCamera" }