mirror of
https://github.com/bitwarden/android.git
synced 2024-11-24 10:25:57 +03:00
BIT-1320: Add inline UI (#624)
This commit is contained in:
parent
fd8293ba55
commit
224395004f
27 changed files with 928 additions and 22 deletions
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -51,6 +51,9 @@
|
|||
android:exported="true"
|
||||
android:label="Bitwarden"
|
||||
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
|
||||
<meta-data
|
||||
android:name="android.autofill"
|
||||
android:resource="@xml/autofill_service_configuration" />
|
||||
<intent-filter>
|
||||
<action android:name="android.service.autofill.AutofillService" />
|
||||
</intent-filter>
|
||||
|
|
|
@ -34,6 +34,9 @@ class FillResponseBuilderImpl : FillResponseBuilder {
|
|||
fillResponseBuilder.addDataset(dataset)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: add vault item dataset (BIT-1296)
|
||||
|
||||
fillResponseBuilder
|
||||
.setIgnoredIds(*filledData.ignoreAutofillIds.toTypedArray())
|
||||
.build()
|
||||
|
|
|
@ -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<AutofillView.Card>,
|
||||
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<AutofillView.Login>,
|
||||
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 <T> List<T>.getOrLastOrNull(index: Int): T? =
|
||||
getOrNull(index)
|
||||
?: lastOrNull()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<AutofillId>,
|
||||
val inlinePresentationSpecs: List<InlinePresentationSpec>,
|
||||
val maxInlineSuggestionsCount: Int,
|
||||
val partition: AutofillPartition,
|
||||
val uri: String?,
|
||||
) : AutofillRequest()
|
||||
|
|
|
@ -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<FilledPartition>,
|
||||
val ignoreAutofillIds: List<AutofillId>,
|
||||
val vaultItemInlinePresentationSpec: InlinePresentationSpec?,
|
||||
)
|
||||
|
|
|
@ -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<FilledItem>,
|
||||
val inlinePresentationSpec: InlinePresentationSpec?,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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].
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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" />
|
2
app/src/main/res/xml/autofill_service_configuration.xml
Normal file
2
app/src/main/res/xml/autofill_service_configuration.xml
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<autofill-service />
|
|
@ -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(
|
||||
|
|
|
@ -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<AutofillId> = 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<AutofillId> = 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<AutofillId> = 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"
|
||||
}
|
||||
|
|
|
@ -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<InlinePresentationSpec> = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(AssistStructure.ViewNode::toAutofillView)
|
||||
mockkStatic(
|
||||
FillRequest::getMaxInlineSuggestionsCount,
|
||||
FillRequest::getInlinePresentationSpecs,
|
||||
)
|
||||
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
|
||||
parser = AutofillParserImpl()
|
||||
}
|
||||
|
@ -70,6 +90,10 @@ class AutofillParserTests {
|
|||
@AfterEach
|
||||
fun teardown() {
|
||||
unmockkStatic(AssistStructure.ViewNode::toAutofillView)
|
||||
unmockkStatic(
|
||||
FillRequest::getMaxInlineSuggestionsCount,
|
||||
FillRequest::getInlinePresentationSpecs,
|
||||
)
|
||||
unmockkStatic(List<ViewNodeTraversalData>::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<List<ViewNodeTraversalData>>().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<List<ViewNodeTraversalData>>().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<List<ViewNodeTraversalData>>().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<List<ViewNodeTraversalData>>().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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
|
@ -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<Dataset.Builder>().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<Presentations.Builder> { it.setInlinePresentation(inlinePresentation) }
|
||||
mockBuilder<Presentations.Builder> { 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<Presentations.Builder>().setInlinePresentation(inlinePresentation)
|
||||
anyConstructed<Presentations.Builder>().setMenuPresentation(remoteViews)
|
||||
anyConstructed<Presentations.Builder>().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<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 {
|
||||
private const val CIPHER_NAME: String = "Autofill Cipher"
|
||||
private const val PACKAGE_NAME: String = "com.x8bit.bitwarden"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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: <maintainer>-<artifact-name>
|
||||
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" }
|
||||
|
|
Loading…
Reference in a new issue