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