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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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?,
)

View file

@ -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?,
)

View file

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

View file

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

View file

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

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.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,

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

View file

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

View file

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

View file

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

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

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