BIT-1320: Add inline UI (#624)

This commit is contained in:
Lucas Kivi 2024-01-15 22:37:50 -06:00 committed by Álison Fernandes
parent fd8293ba55
commit 224395004f
27 changed files with 928 additions and 22 deletions

View file

@ -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. - Purpose: Allows access to new APIs on older API versions.
- License: Apache 2.0 - 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** - **AndroidX Browser**
- https://developer.android.com/jetpack/androidx/releases/browser - https://developer.android.com/jetpack/androidx/releases/browser
- Purpose: Displays webpages with the user's default browser. - Purpose: Displays webpages with the user's default browser.

View file

@ -149,6 +149,7 @@ dependencies {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.androidx.autofill)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.lifecycle)

View file

@ -51,6 +51,9 @@
android:exported="true" android:exported="true"
android:label="Bitwarden" android:label="Bitwarden"
android:permission="android.permission.BIND_AUTOFILL_SERVICE"> android:permission="android.permission.BIND_AUTOFILL_SERVICE">
<meta-data
android:name="android.autofill"
android:resource="@xml/autofill_service_configuration" />
<intent-filter> <intent-filter>
<action android:name="android.service.autofill.AutofillService" /> <action android:name="android.service.autofill.AutofillService" />
</intent-filter> </intent-filter>

View file

@ -34,6 +34,9 @@ class FillResponseBuilderImpl : FillResponseBuilder {
fillResponseBuilder.addDataset(dataset) fillResponseBuilder.addDataset(dataset)
} }
} }
// TODO: add vault item dataset (BIT-1296)
fillResponseBuilder fillResponseBuilder
.setIgnoredIds(*filledData.ignoreAutofillIds.toTypedArray()) .setIgnoredIds(*filledData.ignoreAutofillIds.toTypedArray())
.build() .build()

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.autofill.builder 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.AutofillCipher
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
@ -19,6 +20,23 @@ class FilledDataBuilderImpl(
override suspend fun build(autofillRequest: AutofillRequest.Fillable): FilledData { override suspend fun build(autofillRequest: AutofillRequest.Fillable): FilledData {
// TODO: determine whether or not the vault is locked (BIT-1296) // 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) { val filledPartitions = when (autofillRequest.partition) {
is AutofillPartition.Card -> { is AutofillPartition.Card -> {
autofillCipherProvider autofillCipherProvider
@ -27,6 +45,7 @@ class FilledDataBuilderImpl(
fillCardPartition( fillCardPartition(
autofillCipher = autofillCipher, autofillCipher = autofillCipher,
autofillViews = autofillRequest.partition.views, autofillViews = autofillRequest.partition.views,
inlinePresentationSpec = getCipherInlinePresentationOrNull(),
) )
} }
} }
@ -43,6 +62,7 @@ class FilledDataBuilderImpl(
fillLoginPartition( fillLoginPartition(
autofillCipher = autofillCipher, autofillCipher = autofillCipher,
autofillViews = autofillRequest.partition.views, 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( return FilledData(
filledPartitions = filledPartitions, filledPartitions = filledPartitions,
ignoreAutofillIds = autofillRequest.ignoreAutofillIds, ignoreAutofillIds = autofillRequest.ignoreAutofillIds,
vaultItemInlinePresentationSpec = vaultItemInlinePresentationSpec,
) )
} }
@ -63,6 +89,7 @@ class FilledDataBuilderImpl(
private fun fillCardPartition( private fun fillCardPartition(
autofillCipher: AutofillCipher.Card, autofillCipher: AutofillCipher.Card,
autofillViews: List<AutofillView.Card>, autofillViews: List<AutofillView.Card>,
inlinePresentationSpec: InlinePresentationSpec?,
): FilledPartition { ): FilledPartition {
val filledItems = autofillViews val filledItems = autofillViews
.map { autofillView -> .map { autofillView ->
@ -80,6 +107,7 @@ class FilledDataBuilderImpl(
return FilledPartition( return FilledPartition(
autofillCipher = autofillCipher, autofillCipher = autofillCipher,
filledItems = filledItems, filledItems = filledItems,
inlinePresentationSpec = inlinePresentationSpec,
) )
} }
@ -90,6 +118,7 @@ class FilledDataBuilderImpl(
private fun fillLoginPartition( private fun fillLoginPartition(
autofillCipher: AutofillCipher.Login, autofillCipher: AutofillCipher.Login,
autofillViews: List<AutofillView.Login>, autofillViews: List<AutofillView.Login>,
inlinePresentationSpec: InlinePresentationSpec?,
): FilledPartition { ): FilledPartition {
val filledItems = autofillViews val filledItems = autofillViews
.map { autofillView -> .map { autofillView ->
@ -108,6 +137,15 @@ class FilledDataBuilderImpl(
return FilledPartition( return FilledPartition(
autofillCipher = autofillCipher, autofillCipher = autofillCipher,
filledItems = filledItems, 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 <T> List<T>.getOrLastOrNull(index: Int): T? =
getOrNull(index)
?: lastOrNull()

View file

@ -1,9 +1,17 @@
package com.x8bit.bitwarden.data.autofill.model 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. * A paired down model of the CipherView for use within the autofill feature.
*/ */
sealed class AutofillCipher { sealed class AutofillCipher {
/**
* The icon res to represent this [AutofillCipher].
*/
abstract val iconRes: Int
/** /**
* The name of the cipher. * The name of the cipher.
*/ */
@ -26,7 +34,10 @@ sealed class AutofillCipher {
val expirationMonth: String, val expirationMonth: String,
val expirationYear: String, val expirationYear: String,
val number: 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 * 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, override val subtitle: String,
val password: String, val password: String,
val username: String, val username: String,
) : AutofillCipher() ) : AutofillCipher() {
override val iconRes: Int
@DrawableRes get() = R.drawable.ic_login_item
}
} }

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.autofill.model package com.x8bit.bitwarden.data.autofill.model
import android.view.autofill.AutofillId import android.view.autofill.AutofillId
import android.widget.inline.InlinePresentationSpec
/** /**
* The parsed autofill request. * The parsed autofill request.
@ -12,6 +13,8 @@ sealed class AutofillRequest {
*/ */
data class Fillable( data class Fillable(
val ignoreAutofillIds: List<AutofillId>, val ignoreAutofillIds: List<AutofillId>,
val inlinePresentationSpecs: List<InlinePresentationSpec>,
val maxInlineSuggestionsCount: Int,
val partition: AutofillPartition, val partition: AutofillPartition,
val uri: String?, val uri: String?,
) : AutofillRequest() ) : AutofillRequest()

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.autofill.model package com.x8bit.bitwarden.data.autofill.model
import android.view.autofill.AutofillId 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 * 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( data class FilledData(
val filledPartitions: List<FilledPartition>, val filledPartitions: List<FilledPartition>,
val ignoreAutofillIds: List<AutofillId>, val ignoreAutofillIds: List<AutofillId>,
val vaultItemInlinePresentationSpec: InlinePresentationSpec?,
) )

View file

@ -1,13 +1,17 @@
package com.x8bit.bitwarden.data.autofill.model 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 * All of the data required to build a `Dataset` for fulfilling a partition of data based on an
* [AutofillCipher]. * [AutofillCipher].
* *
* @param autofillCipher The cipher used to fulfill these [filledItems]. * @param autofillCipher The cipher used to fulfill these [filledItems].
* @param filledItems A filled copy of each view from this partition. * @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( data class FilledPartition(
val autofillCipher: AutofillCipher, val autofillCipher: AutofillCipher,
val filledItems: List<FilledItem>, val filledItems: List<FilledItem>,
val inlinePresentationSpec: InlinePresentationSpec?,
) )

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.autofill.parser package com.x8bit.bitwarden.data.autofill.parser
import android.service.autofill.FillRequest import android.service.autofill.FillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
/** /**
@ -10,6 +11,12 @@ interface AutofillParser {
/** /**
* Parse the useful information from [fillRequest] into an [AutofillRequest]. * 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
} }

View file

@ -3,11 +3,14 @@ package com.x8bit.bitwarden.data.autofill.parser
import android.app.assist.AssistStructure import android.app.assist.AssistStructure
import android.service.autofill.FillRequest import android.service.autofill.FillRequest
import android.view.autofill.AutofillId 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.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillView import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData
import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull 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 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. * from the OS into domain models.
*/ */
class AutofillParserImpl : AutofillParser { 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. // Attempt to get the most recent autofill context.
fillRequest fillRequest
.fillContexts .fillContexts
@ -24,6 +30,8 @@ class AutofillParserImpl : AutofillParser {
?.let { assistStructure -> ?.let { assistStructure ->
parseInternal( parseInternal(
assistStructure = assistStructure, assistStructure = assistStructure,
autofillAppInfo = autofillAppInfo,
fillRequest = fillRequest,
) )
} }
?: AutofillRequest.Unfillable ?: AutofillRequest.Unfillable
@ -33,6 +41,8 @@ class AutofillParserImpl : AutofillParser {
*/ */
private fun parseInternal( private fun parseInternal(
assistStructure: AssistStructure, assistStructure: AssistStructure,
autofillAppInfo: AutofillAppInfo,
fillRequest: FillRequest,
): AutofillRequest { ): AutofillRequest {
// Parse the `assistStructure` into internal models. // Parse the `assistStructure` into internal models.
val traversalDataList = assistStructure.traverse() val traversalDataList = assistStructure.traverse()
@ -69,8 +79,18 @@ class AutofillParserImpl : AutofillParser {
.map { it.ignoreAutofillIds } .map { it.ignoreAutofillIds }
.flatten() .flatten()
val maxInlineSuggestionsCount = fillRequest.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
)
val inlinePresentationSpecs = fillRequest.getInlinePresentationSpecs(
autofillAppInfo = autofillAppInfo,
)
return AutofillRequest.Fillable( return AutofillRequest.Fillable(
inlinePresentationSpecs = inlinePresentationSpecs,
ignoreAutofillIds = ignoreAutofillIds, ignoreAutofillIds = ignoreAutofillIds,
maxInlineSuggestionsCount = maxInlineSuggestionsCount,
partition = partition, partition = partition,
uri = uri, uri = uri,
) )

View file

@ -54,7 +54,11 @@ class AutofillProcessorImpl(
fillRequest: FillRequest, fillRequest: FillRequest,
) { ) {
// Parse the OS data into an [AutofillRequest] for easier processing. // 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 -> { is AutofillRequest.Fillable -> {
scope.launch { scope.launch {
// Fulfill the [autofillRequest]. // Fulfill the [autofillRequest].

View file

@ -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<InlinePresentationSpec> =
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
}

View file

@ -5,14 +5,17 @@ import android.os.Build
import android.service.autofill.Dataset import android.service.autofill.Dataset
import android.service.autofill.Presentations import android.service.autofill.Presentations
import android.widget.RemoteViews import android.widget.RemoteViews
import androidx.annotation.RequiresApi
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.FilledPartition import com.x8bit.bitwarden.data.autofill.model.FilledPartition
import com.x8bit.bitwarden.ui.autofill.buildAutofillRemoteViews 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 * Build a [Dataset] to represent the [FilledPartition]. This dataset includes an overlay UI
* presentation for each filled item. * presentation for each filled item.
*/ */
@SuppressLint("NewApi")
fun FilledPartition.buildDataset( fun FilledPartition.buildDataset(
autofillAppInfo: AutofillAppInfo, autofillAppInfo: AutofillAppInfo,
): Dataset { ): Dataset {
@ -24,11 +27,13 @@ fun FilledPartition.buildDataset(
if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.TIRAMISU) { if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.TIRAMISU) {
applyToDatasetPostTiramisu( applyToDatasetPostTiramisu(
autofillAppInfo = autofillAppInfo,
datasetBuilder = datasetBuilder, datasetBuilder = datasetBuilder,
remoteViews = remoteViewsPlaceholder, remoteViews = remoteViewsPlaceholder,
) )
} else { } else {
buildDatasetPreTiramisu( buildDatasetPreTiramisu(
autofillAppInfo = autofillAppInfo,
datasetBuilder = datasetBuilder, datasetBuilder = datasetBuilder,
remoteViews = remoteViewsPlaceholder, remoteViews = remoteViewsPlaceholder,
) )
@ -41,12 +46,23 @@ fun FilledPartition.buildDataset(
* Apply this [FilledPartition] to the [datasetBuilder] on devices running OS version Tiramisu or * Apply this [FilledPartition] to the [datasetBuilder] on devices running OS version Tiramisu or
* greater. * greater.
*/ */
@SuppressLint("NewApi") @RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun FilledPartition.applyToDatasetPostTiramisu( private fun FilledPartition.applyToDatasetPostTiramisu(
autofillAppInfo: AutofillAppInfo,
datasetBuilder: Dataset.Builder, datasetBuilder: Dataset.Builder,
remoteViews: RemoteViews, 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) .setMenuPresentation(remoteViews)
.build() .build()
@ -62,10 +78,24 @@ private fun FilledPartition.applyToDatasetPostTiramisu(
* Apply this [FilledPartition] to the [datasetBuilder] on devices running OS versions that predate * Apply this [FilledPartition] to the [datasetBuilder] on devices running OS versions that predate
* Tiramisu. * Tiramisu.
*/ */
@Suppress("DEPRECATION")
@SuppressLint("NewApi")
private fun FilledPartition.buildDatasetPreTiramisu( private fun FilledPartition.buildDatasetPreTiramisu(
autofillAppInfo: AutofillAppInfo,
datasetBuilder: Dataset.Builder, datasetBuilder: Dataset.Builder,
remoteViews: RemoteViews, remoteViews: RemoteViews,
) { ) {
if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.R) {
inlinePresentationSpec
?.createCipherInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
autofillCipher = autofillCipher,
)
?.let { inlinePresentation ->
datasetBuilder.setInlinePresentation(inlinePresentation)
}
}
filledItems.forEach { filledItem -> filledItems.forEach { filledItem ->
filledItem.applyToDatasetPreTiramisu( filledItem.applyToDatasetPreTiramisu(
datasetBuilder = datasetBuilder, datasetBuilder = datasetBuilder,

View file

@ -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

View file

@ -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)
}

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8" ?>
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android"
android:supportsInlineSuggestions="true" />

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8" ?>
<autofill-service />

View file

@ -60,6 +60,7 @@ class FillResponseBuilderTest {
val filledData = FilledData( val filledData = FilledData(
filledPartitions = emptyList(), filledPartitions = emptyList(),
ignoreAutofillIds = emptyList(), ignoreAutofillIds = emptyList(),
vaultItemInlinePresentationSpec = null,
) )
val actual = fillResponseBuilder.build( val actual = fillResponseBuilder.build(
autofillAppInfo = appInfo, autofillAppInfo = appInfo,
@ -76,12 +77,14 @@ class FillResponseBuilderTest {
val filledPartitions = FilledPartition( val filledPartitions = FilledPartition(
autofillCipher = mockk(), autofillCipher = mockk(),
filledItems = emptyList(), filledItems = emptyList(),
inlinePresentationSpec = null,
) )
val filledData = FilledData( val filledData = FilledData(
filledPartitions = listOf( filledPartitions = listOf(
filledPartitions, filledPartitions,
), ),
ignoreAutofillIds = emptyList(), ignoreAutofillIds = emptyList(),
vaultItemInlinePresentationSpec = null,
) )
val actual = fillResponseBuilder.build( val actual = fillResponseBuilder.build(
autofillAppInfo = appInfo, autofillAppInfo = appInfo,
@ -108,6 +111,7 @@ class FillResponseBuilderTest {
val filledData = FilledData( val filledData = FilledData(
filledPartitions = filledPartitions, filledPartitions = filledPartitions,
ignoreAutofillIds = ignoreAutofillIds, ignoreAutofillIds = ignoreAutofillIds,
vaultItemInlinePresentationSpec = null,
) )
every { every {
filledPartitionOne.buildDataset( filledPartitionOne.buildDataset(

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.autofill.builder
import android.view.autofill.AutofillId import android.view.autofill.AutofillId
import android.view.autofill.AutofillValue 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.AutofillCipher
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
@ -83,6 +84,8 @@ class FilledDataBuilderTest {
val ignoreAutofillIds: List<AutofillId> = mockk() val ignoreAutofillIds: List<AutofillId> = mockk()
val autofillRequest = AutofillRequest.Fillable( val autofillRequest = AutofillRequest.Fillable(
ignoreAutofillIds = ignoreAutofillIds, ignoreAutofillIds = ignoreAutofillIds,
inlinePresentationSpecs = emptyList(),
maxInlineSuggestionsCount = 0,
partition = autofillPartition, partition = autofillPartition,
uri = URI, uri = URI,
) )
@ -96,12 +99,14 @@ class FilledDataBuilderTest {
filledItemPassword, filledItemPassword,
filledItemUsername, filledItemUsername,
), ),
inlinePresentationSpec = null,
) )
val expected = FilledData( val expected = FilledData(
filledPartitions = listOf( filledPartitions = listOf(
filledPartition, filledPartition,
), ),
ignoreAutofillIds = ignoreAutofillIds, ignoreAutofillIds = ignoreAutofillIds,
vaultItemInlinePresentationSpec = null,
) )
coEvery { coEvery {
autofillCipherProvider.getLoginAutofillCiphers( autofillCipherProvider.getLoginAutofillCiphers(
@ -154,12 +159,15 @@ class FilledDataBuilderTest {
val ignoreAutofillIds: List<AutofillId> = mockk() val ignoreAutofillIds: List<AutofillId> = mockk()
val autofillRequest = AutofillRequest.Fillable( val autofillRequest = AutofillRequest.Fillable(
ignoreAutofillIds = ignoreAutofillIds, ignoreAutofillIds = ignoreAutofillIds,
inlinePresentationSpecs = emptyList(),
maxInlineSuggestionsCount = 0,
partition = autofillPartition, partition = autofillPartition,
uri = null, uri = null,
) )
val expected = FilledData( val expected = FilledData(
filledPartitions = emptyList(), filledPartitions = emptyList(),
ignoreAutofillIds = ignoreAutofillIds, ignoreAutofillIds = ignoreAutofillIds,
vaultItemInlinePresentationSpec = null,
) )
// Test // Test
@ -210,6 +218,8 @@ class FilledDataBuilderTest {
val ignoreAutofillIds: List<AutofillId> = mockk() val ignoreAutofillIds: List<AutofillId> = mockk()
val autofillRequest = AutofillRequest.Fillable( val autofillRequest = AutofillRequest.Fillable(
ignoreAutofillIds = ignoreAutofillIds, ignoreAutofillIds = ignoreAutofillIds,
inlinePresentationSpecs = emptyList(),
maxInlineSuggestionsCount = 0,
partition = autofillPartition, partition = autofillPartition,
uri = URI, uri = URI,
) )
@ -225,12 +235,14 @@ class FilledDataBuilderTest {
filledItemExpirationYear, filledItemExpirationYear,
filledItemNumber, filledItemNumber,
), ),
inlinePresentationSpec = null,
) )
val expected = FilledData( val expected = FilledData(
filledPartitions = listOf( filledPartitions = listOf(
filledPartition, filledPartition,
), ),
ignoreAutofillIds = ignoreAutofillIds, ignoreAutofillIds = ignoreAutofillIds,
vaultItemInlinePresentationSpec = null,
) )
coEvery { autofillCipherProvider.getCardAutofillCiphers() } returns listOf(autofillCipher) coEvery { autofillCipherProvider.getCardAutofillCiphers() } returns listOf(autofillCipher)
every { autofillViewCode.buildFilledItemOrNull(code) } returns filledItemCode 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 { companion object {
private const val URI: String = "androidapp://com.x8bit.bitwarden" private const val URI: String = "androidapp://com.x8bit.bitwarden"
} }

View file

@ -5,11 +5,15 @@ import android.service.autofill.FillContext
import android.service.autofill.FillRequest import android.service.autofill.FillRequest
import android.view.View import android.view.View
import android.view.autofill.AutofillId 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.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillView import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData
import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull 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 com.x8bit.bitwarden.data.autofill.util.toAutofillView
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -24,6 +28,7 @@ import org.junit.jupiter.api.Test
class AutofillParserTests { class AutofillParserTests {
private lateinit var parser: AutofillParser private lateinit var parser: AutofillParser
private val autofillAppInfo: AutofillAppInfo = mockk()
private val autofillViewData = AutofillView.Data( private val autofillViewData = AutofillView.Data(
autofillId = mockk(), autofillId = mockk(),
isFocused = true, isFocused = true,
@ -58,11 +63,26 @@ class AutofillParserTests {
private val fillRequest: FillRequest = mockk { private val fillRequest: FillRequest = mockk {
every { this@mockk.fillContexts } returns listOf(fillContext) every { this@mockk.fillContexts } returns listOf(fillContext)
} }
private val inlinePresentationSpecs: List<InlinePresentationSpec> = mockk()
@BeforeEach @BeforeEach
fun setup() { fun setup() {
mockkStatic(AssistStructure.ViewNode::toAutofillView) mockkStatic(AssistStructure.ViewNode::toAutofillView)
mockkStatic(
FillRequest::getMaxInlineSuggestionsCount,
FillRequest::getInlinePresentationSpecs,
)
mockkStatic(List<ViewNodeTraversalData>::buildUriOrNull) mockkStatic(List<ViewNodeTraversalData>::buildUriOrNull)
every {
fillRequest.getInlinePresentationSpecs(
autofillAppInfo = autofillAppInfo,
)
} returns inlinePresentationSpecs
every {
fillRequest.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
)
} returns MAX_INLINE_SUGGESTION_COUNT
every { any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure) } returns URI every { any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure) } returns URI
parser = AutofillParserImpl() parser = AutofillParserImpl()
} }
@ -70,6 +90,10 @@ class AutofillParserTests {
@AfterEach @AfterEach
fun teardown() { fun teardown() {
unmockkStatic(AssistStructure.ViewNode::toAutofillView) unmockkStatic(AssistStructure.ViewNode::toAutofillView)
unmockkStatic(
FillRequest::getMaxInlineSuggestionsCount,
FillRequest::getInlinePresentationSpecs,
)
unmockkStatic(List<ViewNodeTraversalData>::buildUriOrNull) unmockkStatic(List<ViewNodeTraversalData>::buildUriOrNull)
} }
@ -80,7 +104,10 @@ class AutofillParserTests {
every { fillRequest.fillContexts } returns emptyList() every { fillRequest.fillContexts } returns emptyList()
// Test // Test
val actual = parser.parse(fillRequest) val actual = parser.parse(
autofillAppInfo = autofillAppInfo,
fillRequest = fillRequest,
)
// Verify // Verify
assertEquals(expected, actual) assertEquals(expected, actual)
@ -93,7 +120,10 @@ class AutofillParserTests {
every { assistStructure.windowNodeCount } returns 0 every { assistStructure.windowNodeCount } returns 0
// Test // Test
val actual = parser.parse(fillRequest) val actual = parser.parse(
autofillAppInfo = autofillAppInfo,
fillRequest = fillRequest,
)
// Verify // Verify
assertEquals(expected, actual) assertEquals(expected, actual)
@ -134,6 +164,8 @@ class AutofillParserTests {
) )
val expected = AutofillRequest.Fillable( val expected = AutofillRequest.Fillable(
ignoreAutofillIds = listOf(childAutofillId), ignoreAutofillIds = listOf(childAutofillId),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
partition = autofillPartition, partition = autofillPartition,
uri = URI, uri = URI,
) )
@ -141,11 +173,20 @@ class AutofillParserTests {
every { assistStructure.getWindowNodeAt(0) } returns windowNode every { assistStructure.getWindowNodeAt(0) } returns windowNode
// Test // Test
val actual = parser.parse(fillRequest) val actual = parser.parse(
autofillAppInfo = autofillAppInfo,
fillRequest = fillRequest,
)
// Verify // Verify
assertEquals(expected, actual) assertEquals(expected, actual)
verify(exactly = 1) { verify(exactly = 1) {
fillRequest.getInlinePresentationSpecs(
autofillAppInfo = autofillAppInfo,
)
fillRequest.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
)
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure) any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
} }
} }
@ -171,6 +212,8 @@ class AutofillParserTests {
) )
val expected = AutofillRequest.Fillable( val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(), ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
partition = autofillPartition, partition = autofillPartition,
uri = URI, uri = URI,
) )
@ -178,11 +221,20 @@ class AutofillParserTests {
every { loginViewNode.toAutofillView() } returns loginAutofillView every { loginViewNode.toAutofillView() } returns loginAutofillView
// Test // Test
val actual = parser.parse(fillRequest) val actual = parser.parse(
autofillAppInfo = autofillAppInfo,
fillRequest = fillRequest,
)
// Verify // Verify
assertEquals(expected, actual) assertEquals(expected, actual)
verify(exactly = 1) { verify(exactly = 1) {
fillRequest.getInlinePresentationSpecs(
autofillAppInfo = autofillAppInfo,
)
fillRequest.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
)
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure) any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
} }
} }
@ -208,6 +260,8 @@ class AutofillParserTests {
) )
val expected = AutofillRequest.Fillable( val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(), ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
partition = autofillPartition, partition = autofillPartition,
uri = URI, uri = URI,
) )
@ -215,11 +269,20 @@ class AutofillParserTests {
every { loginViewNode.toAutofillView() } returns loginAutofillView every { loginViewNode.toAutofillView() } returns loginAutofillView
// Test // Test
val actual = parser.parse(fillRequest) val actual = parser.parse(
autofillAppInfo = autofillAppInfo,
fillRequest = fillRequest,
)
// Verify // Verify
assertEquals(expected, actual) assertEquals(expected, actual)
verify(exactly = 1) { verify(exactly = 1) {
fillRequest.getInlinePresentationSpecs(
autofillAppInfo = autofillAppInfo,
)
fillRequest.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
)
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure) any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
} }
} }
@ -245,6 +308,8 @@ class AutofillParserTests {
) )
val expected = AutofillRequest.Fillable( val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(), ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
partition = autofillPartition, partition = autofillPartition,
uri = URI, uri = URI,
) )
@ -252,11 +317,20 @@ class AutofillParserTests {
every { loginViewNode.toAutofillView() } returns loginAutofillView every { loginViewNode.toAutofillView() } returns loginAutofillView
// Test // Test
val actual = parser.parse(fillRequest) val actual = parser.parse(
autofillAppInfo = autofillAppInfo,
fillRequest = fillRequest,
)
// Verify // Verify
assertEquals(expected, actual) assertEquals(expected, actual)
verify(exactly = 1) { verify(exactly = 1) {
fillRequest.getInlinePresentationSpecs(
autofillAppInfo = autofillAppInfo,
)
fillRequest.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
)
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure) any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
} }
} }
@ -270,8 +344,7 @@ class AutofillParserTests {
every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode
every { assistStructure.getWindowNodeAt(1) } returns loginWindowNode 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"

View file

@ -76,7 +76,12 @@ class AutofillProcessorTest {
val autofillRequest = AutofillRequest.Unfillable val autofillRequest = AutofillRequest.Unfillable
val fillRequest: FillRequest = mockk() val fillRequest: FillRequest = mockk()
every { cancellationSignal.setOnCancelListener(any()) } just runs 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 every { fillCallback.onSuccess(null) } just runs
// Test // Test
@ -90,7 +95,10 @@ class AutofillProcessorTest {
// Verify // Verify
verify(exactly = 1) { verify(exactly = 1) {
cancellationSignal.setOnCancelListener(any()) cancellationSignal.setOnCancelListener(any())
parser.parse(fillRequest) parser.parse(
autofillAppInfo = appInfo,
fillRequest = fillRequest,
)
fillCallback.onSuccess(null) fillCallback.onSuccess(null)
} }
} }
@ -103,6 +111,7 @@ class AutofillProcessorTest {
val filledData = FilledData( val filledData = FilledData(
filledPartitions = listOf(mockk()), filledPartitions = listOf(mockk()),
ignoreAutofillIds = emptyList(), ignoreAutofillIds = emptyList(),
vaultItemInlinePresentationSpec = null,
) )
val fillResponse: FillResponse = mockk() val fillResponse: FillResponse = mockk()
val autofillRequest: AutofillRequest.Fillable = mockk() val autofillRequest: AutofillRequest.Fillable = mockk()
@ -112,7 +121,12 @@ class AutofillProcessorTest {
) )
} returns filledData } returns filledData
every { cancellationSignal.setOnCancelListener(any()) } just runs every { cancellationSignal.setOnCancelListener(any()) } just runs
every { parser.parse(fillRequest) } returns autofillRequest every {
parser.parse(
autofillAppInfo = appInfo,
fillRequest = fillRequest,
)
} returns autofillRequest
every { every {
fillResponseBuilder.build( fillResponseBuilder.build(
autofillAppInfo = appInfo, autofillAppInfo = appInfo,
@ -137,7 +151,10 @@ class AutofillProcessorTest {
} }
verify(exactly = 1) { verify(exactly = 1) {
cancellationSignal.setOnCancelListener(any()) cancellationSignal.setOnCancelListener(any())
parser.parse(fillRequest) parser.parse(
autofillAppInfo = appInfo,
fillRequest = fillRequest,
)
fillResponseBuilder.build( fillResponseBuilder.build(
autofillAppInfo = appInfo, autofillAppInfo = appInfo,
filledData = filledData, filledData = filledData,

View file

@ -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<InlinePresentationSpec> = 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<InlinePresentationSpec> = 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

View file

@ -3,14 +3,17 @@ package com.x8bit.bitwarden.data.autofill.util
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.service.autofill.Dataset import android.service.autofill.Dataset
import android.service.autofill.InlinePresentation
import android.service.autofill.Presentations import android.service.autofill.Presentations
import android.widget.RemoteViews import android.widget.RemoteViews
import android.widget.inline.InlinePresentationSpec
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.autofill.model.FilledItem import com.x8bit.bitwarden.data.autofill.model.FilledItem
import com.x8bit.bitwarden.data.autofill.model.FilledPartition import com.x8bit.bitwarden.data.autofill.model.FilledPartition
import com.x8bit.bitwarden.data.util.mockBuilder import com.x8bit.bitwarden.data.util.mockBuilder
import com.x8bit.bitwarden.ui.autofill.buildAutofillRemoteViews import com.x8bit.bitwarden.ui.autofill.buildAutofillRemoteViews
import com.x8bit.bitwarden.ui.autofill.util.createCipherInlinePresentationOrNull
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
@ -35,11 +38,13 @@ class FilledPartitionExtensionsTest {
} }
private val dataset: Dataset = mockk() private val dataset: Dataset = mockk()
private val filledItem: FilledItem = mockk() private val filledItem: FilledItem = mockk()
private val inlinePresentationSpec: InlinePresentationSpec = mockk()
private val filledPartition = FilledPartition( private val filledPartition = FilledPartition(
autofillCipher = autofillCipher, autofillCipher = autofillCipher,
filledItems = listOf( filledItems = listOf(
filledItem, filledItem,
), ),
inlinePresentationSpec = inlinePresentationSpec,
) )
private val presentations: Presentations = mockk() private val presentations: Presentations = mockk()
private val remoteViews: RemoteViews = mockk() private val remoteViews: RemoteViews = mockk()
@ -51,6 +56,7 @@ class FilledPartitionExtensionsTest {
mockkStatic(::buildAutofillRemoteViews) mockkStatic(::buildAutofillRemoteViews)
mockkStatic(FilledItem::applyToDatasetPostTiramisu) mockkStatic(FilledItem::applyToDatasetPostTiramisu)
mockkStatic(FilledItem::applyToDatasetPreTiramisu) mockkStatic(FilledItem::applyToDatasetPreTiramisu)
mockkStatic(InlinePresentationSpec::createCipherInlinePresentationOrNull)
every { anyConstructed<Dataset.Builder>().build() } returns dataset every { anyConstructed<Dataset.Builder>().build() } returns dataset
} }
@ -61,6 +67,7 @@ class FilledPartitionExtensionsTest {
unmockkStatic(::buildAutofillRemoteViews) unmockkStatic(::buildAutofillRemoteViews)
unmockkStatic(FilledItem::applyToDatasetPostTiramisu) unmockkStatic(FilledItem::applyToDatasetPostTiramisu)
unmockkStatic(FilledItem::applyToDatasetPreTiramisu) unmockkStatic(FilledItem::applyToDatasetPreTiramisu)
unmockkStatic(InlinePresentationSpec::createCipherInlinePresentationOrNull)
} }
@Test @Test
@ -71,12 +78,20 @@ class FilledPartitionExtensionsTest {
packageName = PACKAGE_NAME, packageName = PACKAGE_NAME,
sdkInt = 34, sdkInt = 34,
) )
val inlinePresentation: InlinePresentation = mockk()
every { every {
buildAutofillRemoteViews( buildAutofillRemoteViews(
packageName = PACKAGE_NAME, packageName = PACKAGE_NAME,
title = CIPHER_NAME, title = CIPHER_NAME,
) )
} returns remoteViews } returns remoteViews
every {
inlinePresentationSpec.createCipherInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
autofillCipher = autofillCipher,
)
} returns inlinePresentation
mockBuilder<Presentations.Builder> { it.setInlinePresentation(inlinePresentation) }
mockBuilder<Presentations.Builder> { it.setMenuPresentation(remoteViews) } mockBuilder<Presentations.Builder> { it.setMenuPresentation(remoteViews) }
every { every {
filledItem.applyToDatasetPostTiramisu( filledItem.applyToDatasetPostTiramisu(
@ -98,6 +113,11 @@ class FilledPartitionExtensionsTest {
packageName = PACKAGE_NAME, packageName = PACKAGE_NAME,
title = CIPHER_NAME, title = CIPHER_NAME,
) )
inlinePresentationSpec.createCipherInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
autofillCipher = autofillCipher,
)
anyConstructed<Presentations.Builder>().setInlinePresentation(inlinePresentation)
anyConstructed<Presentations.Builder>().setMenuPresentation(remoteViews) anyConstructed<Presentations.Builder>().setMenuPresentation(remoteViews)
anyConstructed<Presentations.Builder>().build() anyConstructed<Presentations.Builder>().build()
filledItem.applyToDatasetPostTiramisu( filledItem.applyToDatasetPostTiramisu(
@ -108,14 +128,16 @@ class FilledPartitionExtensionsTest {
} }
} }
@Suppress("MaxLineLength")
@Test @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 // Setup
val autofillAppInfo = AutofillAppInfo( val autofillAppInfo = AutofillAppInfo(
context = context, context = context,
packageName = PACKAGE_NAME, packageName = PACKAGE_NAME,
sdkInt = 18, sdkInt = 18,
) )
val inlinePresentation: InlinePresentation = mockk()
every { every {
buildAutofillRemoteViews( buildAutofillRemoteViews(
packageName = PACKAGE_NAME, 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<Dataset.Builder> { 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<Dataset.Builder>().setInlinePresentation(inlinePresentation)
filledItem.applyToDatasetPreTiramisu(
datasetBuilder = any(),
remoteViews = remoteViews,
)
anyConstructed<Dataset.Builder>().build()
}
}
companion object { companion object {
private const val CIPHER_NAME: String = "Autofill Cipher" private const val CIPHER_NAME: String = "Autofill Cipher"
private const val PACKAGE_NAME: String = "com.x8bit.bitwarden" private const val PACKAGE_NAME: String = "com.x8bit.bitwarden"

View file

@ -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)
}
}

View file

@ -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<Intent>(),
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<Intent>(),
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<Intent>(),
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<Intent>(),
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

View file

@ -24,6 +24,7 @@ androidxRoom = "2.6.1"
androidXSecurityCrypto = "1.1.0-alpha06" androidXSecurityCrypto = "1.1.0-alpha06"
androidxSplash = "1.1.0-alpha02" androidxSplash = "1.1.0-alpha02"
androidXAppCompat = "1.6.1" 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 # Once the app and SDK reach a critical point of completeness we should begin fixing the version
# here (BIT-311). # here (BIT-311).
bitwardenSdk = "0.4.0-20240115.154650-43" bitwardenSdk = "0.4.0-20240115.154650-43"
@ -55,6 +56,7 @@ zxing = "3.5.2"
# Format: <maintainer>-<artifact-name> # Format: <maintainer>-<artifact-name>
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidXAppCompat" } 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-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidxCamera" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidxCamera" }