BIT-1462: Add Vault suggestion to autofill (#699)

This commit is contained in:
Brian Yencho 2024-01-22 16:13:05 -06:00 committed by Álison Fernandes
parent a760127711
commit 2f918650a1
16 changed files with 1122 additions and 218 deletions

View file

@ -26,7 +26,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleInstancePerTask"
android:launchMode="@integer/launchModeAPIlevel"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>

View file

@ -4,6 +4,8 @@ import android.service.autofill.FillResponse
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.util.buildDataset
import com.x8bit.bitwarden.data.autofill.util.buildVaultItemDataset
import com.x8bit.bitwarden.data.autofill.util.fillableAutofillIds
/**
* The default implementation for [FillResponseBuilder]. This is a component for compiling fulfilled
@ -14,7 +16,7 @@ class FillResponseBuilderImpl : FillResponseBuilder {
autofillAppInfo: AutofillAppInfo,
filledData: FilledData,
): FillResponse? =
if (filledData.filledPartitions.any { it.filledItems.isNotEmpty() }) {
if (filledData.fillableAutofillIds.isNotEmpty()) {
val fillResponseBuilder = FillResponse.Builder()
filledData
@ -35,9 +37,14 @@ class FillResponseBuilderImpl : FillResponseBuilder {
}
}
// TODO: add vault item dataset (BIT-1296)
fillResponseBuilder
// Add the Vault Item
.addDataset(
filledData
.buildVaultItemDataset(
autofillAppInfo = autofillAppInfo,
),
)
.setIgnoredIds(*filledData.ignoreAutofillIds.toTypedArray())
.build()
} else {

View file

@ -19,6 +19,7 @@ class FilledDataBuilderImpl(
) : FilledDataBuilder {
override suspend fun build(autofillRequest: AutofillRequest.Fillable): FilledData {
// TODO: determine whether or not the vault is locked (BIT-1296)
val isVaultLocked = false
// Subtract one to make sure there is space for the vault item.
val maxCipherInlineSuggestionsCount = autofillRequest.maxInlineSuggestionsCount - 1
@ -78,7 +79,10 @@ class FilledDataBuilderImpl(
return FilledData(
filledPartitions = filledPartitions,
ignoreAutofillIds = autofillRequest.ignoreAutofillIds,
originalPartition = autofillRequest.partition,
uri = autofillRequest.uri,
vaultItemInlinePresentationSpec = vaultItemInlinePresentationSpec,
isVaultLocked = isVaultLocked,
)
}

View file

@ -10,5 +10,8 @@ import android.widget.inline.InlinePresentationSpec
data class FilledData(
val filledPartitions: List<FilledPartition>,
val ignoreAutofillIds: List<AutofillId>,
val originalPartition: AutofillPartition,
val uri: String?,
val vaultItemInlinePresentationSpec: InlinePresentationSpec?,
val isVaultLocked: Boolean,
)

View file

@ -0,0 +1,171 @@
package com.x8bit.bitwarden.data.autofill.util
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.service.autofill.Dataset
import android.service.autofill.Presentations
import android.view.autofill.AutofillId
import android.view.autofill.AutofillValue
import android.widget.RemoteViews
import android.widget.inline.InlinePresentationSpec
import androidx.annotation.RequiresApi
import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledItem
import com.x8bit.bitwarden.ui.autofill.buildVaultItemAutofillRemoteViews
import com.x8bit.bitwarden.ui.autofill.util.createVaultItemInlinePresentationOrNull
/**
* Returns all the possible [AutofillId]s that were potentially fillable for the given [FilledData].
*/
val FilledData.fillableAutofillIds: List<AutofillId>
get() = this.originalPartition.views.map { it.data.autofillId }
/**
* Builds a [Dataset] for the Vault item.
*/
@SuppressLint("NewApi")
fun FilledData.buildVaultItemDataset(
autofillAppInfo: AutofillAppInfo,
): Dataset {
val intent = Intent(
autofillAppInfo.context,
MainActivity::class.java,
)
// TODO: Add additional data to the Intent to be pulled out in the app (BIT-1296)
val pendingIntent = PendingIntent
.getActivity(
autofillAppInfo.context,
0,
intent,
PendingIntent.FLAG_CANCEL_CURRENT.toPendingIntentMutabilityFlag(),
)
val remoteViewsForOverlay = buildVaultItemAutofillRemoteViews(
autofillAppInfo = autofillAppInfo,
isLocked = this.isVaultLocked,
)
val filledItems = this
.fillableAutofillIds
.map {
FilledItem(
autofillId = it,
// A placeholder value must be used for now, but this are temporary. It will get
// reset when a real value is chosen by the user in the "authentication activity"
// that is launched as part of the PendingIntent.
value = AutofillValue.forText("PLACEHOLDER"),
)
}
val inlinePresentationSpec = this.vaultItemInlinePresentationSpec
return Dataset.Builder()
.setAuthentication(pendingIntent.intentSender)
.apply {
if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.TIRAMISU) {
addVaultItemDataPostTiramisu(
autofillAppInfo = autofillAppInfo,
pendingIntent = pendingIntent,
remoteViews = remoteViewsForOverlay,
filledItems = filledItems,
inlinePresentationSpec = inlinePresentationSpec,
isLocked = isVaultLocked,
)
} else {
addVaultItemDataPreTiramisu(
autofillAppInfo = autofillAppInfo,
pendingIntent = pendingIntent,
remoteViews = remoteViewsForOverlay,
filledItems = filledItems,
inlinePresentationSpec = inlinePresentationSpec,
isLocked = isVaultLocked,
)
}
}
.build()
}
/**
* Adds the Vault data to the given [Dataset.Builder] for post-Tiramisu versions.
*/
@Suppress("LongParameterList")
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun Dataset.Builder.addVaultItemDataPostTiramisu(
autofillAppInfo: AutofillAppInfo,
pendingIntent: PendingIntent,
remoteViews: RemoteViews,
filledItems: List<FilledItem>,
inlinePresentationSpec: InlinePresentationSpec?,
isLocked: Boolean,
): Dataset.Builder {
val presentationBuilder = Presentations.Builder()
inlinePresentationSpec
?.createVaultItemInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
pendingIntent = pendingIntent,
isLocked = isLocked,
)
?.let { inlinePresentation ->
presentationBuilder.setInlinePresentation(inlinePresentation)
}
val presentation = presentationBuilder
.setMenuPresentation(remoteViews)
.build()
filledItems
.forEach {
it.applyToDatasetPostTiramisu(
datasetBuilder = this,
presentations = presentation,
)
}
return this
}
/**
* Adds the Vault data to the given [Dataset.Builder] for pre-Tiramisu versions.
*/
@Suppress("DEPRECATION", "LongParameterList")
@SuppressLint("NewApi")
private fun Dataset.Builder.addVaultItemDataPreTiramisu(
autofillAppInfo: AutofillAppInfo,
pendingIntent: PendingIntent,
remoteViews: RemoteViews,
filledItems: List<FilledItem>,
inlinePresentationSpec: InlinePresentationSpec?,
isLocked: Boolean,
): Dataset.Builder {
if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.R) {
inlinePresentationSpec
?.createVaultItemInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
pendingIntent = pendingIntent,
isLocked = isLocked,
)
?.let { inlinePresentation ->
this.setInlinePresentation(inlinePresentation)
}
}
filledItems
.forEach {
it.applyToDatasetPreTiramisu(
datasetBuilder = this,
remoteViews = remoteViews,
)
}
return this
}
/**
* Starting from an initial pending intent flag (ex: [PendingIntent.FLAG_CANCEL_CURRENT], derives
* a new flag with the correct mutability determined by [isMutable].
*/
private fun Int.toPendingIntentMutabilityFlag(): Int =
// Mutable flag was added on API level 31
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
this or PendingIntent.FLAG_MUTABLE
} else {
this
}

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.autofill
import android.content.Context
import android.widget.RemoteViews
import androidx.annotation.DrawableRes
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
@ -13,6 +14,42 @@ import com.x8bit.bitwarden.ui.autofill.util.isSystemDarkMode
fun buildAutofillRemoteViews(
autofillAppInfo: AutofillAppInfo,
autofillCipher: AutofillCipher,
): RemoteViews =
buildAutofillRemoteViews(
autofillAppInfo = autofillAppInfo,
name = autofillCipher.name,
subtitle = autofillCipher.subtitle,
iconRes = autofillCipher.iconRes,
shouldTintIcon = true,
)
/**
* Build [RemoteViews] to represent the Vault Item suggestion (for opening or unlocking the vault).
*/
fun buildVaultItemAutofillRemoteViews(
autofillAppInfo: AutofillAppInfo,
isLocked: Boolean,
): RemoteViews =
buildAutofillRemoteViews(
autofillAppInfo = autofillAppInfo,
name = autofillAppInfo.context.getString(R.string.app_name),
subtitle = autofillAppInfo.context.run {
if (isLocked) {
getString(R.string.vault_is_locked)
} else {
getString(R.string.go_to_my_vault)
}
},
iconRes = R.drawable.icon,
shouldTintIcon = false,
)
private fun buildAutofillRemoteViews(
autofillAppInfo: AutofillAppInfo,
name: String,
subtitle: String,
@DrawableRes iconRes: Int,
shouldTintIcon: Boolean,
): RemoteViews =
RemoteViews(
autofillAppInfo.packageName,
@ -21,27 +58,21 @@ fun buildAutofillRemoteViews(
.apply {
setTextViewText(
R.id.title,
autofillCipher.name,
name,
)
setTextViewText(
R.id.subtitle,
autofillCipher.subtitle,
subtitle,
)
setImageViewResource(
R.id.icon,
autofillCipher.iconRes,
iconRes,
)
setInt(
R.id.container,
"setBackgroundColor",
autofillAppInfo.context.surface,
)
setInt(
R.id.icon,
"setColorFilter",
autofillAppInfo.context.onSurface,
)
setInt(
R.id.title,
"setTextColor",
@ -52,6 +83,13 @@ fun buildAutofillRemoteViews(
"setTextColor",
autofillAppInfo.context.onSurfaceVariant,
)
if (shouldTintIcon) {
setInt(
R.id.icon,
"setColorFilter",
autofillAppInfo.context.onSurface,
)
}
}
private val Context.onSurface: Int

View file

@ -3,10 +3,12 @@ package com.x8bit.bitwarden.ui.autofill.util
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.graphics.BlendMode
import android.graphics.drawable.Icon
import android.os.Build
import android.service.autofill.InlinePresentation
import android.widget.inline.InlinePresentationSpec
import androidx.annotation.DrawableRes
import androidx.annotation.RequiresApi
import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.v1.InlineSuggestionUi
@ -19,10 +21,59 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
* it fails, return null.
*/
@RequiresApi(Build.VERSION_CODES.R)
@SuppressLint("RestrictedApi")
fun InlinePresentationSpec.createCipherInlinePresentationOrNull(
autofillAppInfo: AutofillAppInfo,
autofillCipher: AutofillCipher,
): InlinePresentation? =
createInlinePresentationOrNull(
pendingIntent = PendingIntent.getService(
autofillAppInfo.context,
0,
Intent(),
PendingIntent.FLAG_ONE_SHOT or
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE,
),
autofillAppInfo = autofillAppInfo,
title = autofillCipher.name,
subtitle = autofillCipher.subtitle,
iconRes = autofillCipher.iconRes,
shouldTintIcon = true,
)
/**
* Try creating an [InlinePresentation] for the Vault item with this [InlinePresentationSpec]. If
* it fails, return null.
*/
@RequiresApi(Build.VERSION_CODES.R)
fun InlinePresentationSpec.createVaultItemInlinePresentationOrNull(
autofillAppInfo: AutofillAppInfo,
pendingIntent: PendingIntent,
isLocked: Boolean,
): InlinePresentation? =
createInlinePresentationOrNull(
pendingIntent = pendingIntent,
autofillAppInfo = autofillAppInfo,
title = autofillAppInfo.context.getString(R.string.app_name),
subtitle = if (isLocked) {
autofillAppInfo.context.getString(R.string.vault_is_locked)
} else {
autofillAppInfo.context.getString(R.string.my_vault)
},
iconRes = R.drawable.icon,
shouldTintIcon = false,
)
@Suppress("LongParameterList")
@RequiresApi(Build.VERSION_CODES.R)
@SuppressLint("RestrictedApi")
private fun InlinePresentationSpec.createInlinePresentationOrNull(
pendingIntent: PendingIntent,
autofillAppInfo: AutofillAppInfo,
title: String,
subtitle: String,
@DrawableRes iconRes: Int,
shouldTintIcon: Boolean,
): InlinePresentation? {
val isInlineCompatible = UiVersions
.getVersions(style)
@ -30,24 +81,23 @@ fun InlinePresentationSpec.createCipherInlinePresentationOrNull(
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,
iconRes,
)
.setTint(autofillAppInfo.contentColor)
.run {
if (shouldTintIcon) {
setTint(autofillAppInfo.contentColor)
} else {
// Remove tinting
setTintBlendMode(BlendMode.DST)
}
}
val slice = InlineSuggestionUi
.newContentBuilder(pendingIntent)
.setTitle(autofillCipher.name)
.setSubtitle(autofillCipher.subtitle)
.setTitle(title)
.setSubtitle(subtitle)
.setStartIcon(icon)
.build()
.slice

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="1024dp"
android:height="1024dp"
android:viewportHeight="1024"
android:viewportWidth="1024">
<path
android:fillColor="#175DDC"
android:pathData="M1024,864c0,88.4 -71.6,160 -160,160H160C71.6,1024 0,952.4 0,864V160C0,71.6 71.6,0 160,0h704c88.4,0 160,71.6 160,160V864z" />
<path android:pathData="M803.2,195.8l668.8,668.8l-376,694.4l-792,-792l208,120l280,-280z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="922.23486"
android:endY="1353.2649"
android:startX="96.33417"
android:startY="389.33688"
android:type="linear">
<item
android:color="#3F000000"
android:offset="0" />
<item
android:color="#00000000"
android:offset="1" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#ffffff"
android:pathData="M802.24,194.52c-5.92,-5.92 -12.96,-8.96 -20.96,-8.96L242.72,185.56c-8.16,0 -15.04,3.04 -20.96,8.96C215.84,200.44 212.8,207.32 212.8,215.48l0,359.04c0,26.72 5.28,53.44 15.68,79.68c10.4,26.4 23.36,49.76 38.88,70.08c15.36,20.48 33.76,40.32 55.2,59.52c21.28,19.36 41.12,35.36 59.2,48.16c18.08,12.8 36.96,24.8 56.64,36.16c19.68,11.36 33.6,19.04 41.92,23.2c8.32,4 14.88,7.2 19.84,9.28c3.68,1.92 7.84,2.88 12.16,2.88c4.32,0 8.48,-0.96 12.16,-2.88c4.96,-2.24 11.68,-5.28 19.84,-9.28c8.32,-4 22.24,-11.84 41.92,-23.2c19.68,-11.36 38.56,-23.52 56.64,-36.16c18.08,-12.8 37.76,-28.8 59.2,-48.16c21.28,-19.36 39.68,-39.2 55.2,-59.52c15.36,-20.48 28.32,-43.84 38.72,-70.08c10.4,-26.4 15.68,-52.96 15.68,-79.68L811.68,215.48C811.2,207.32 808.16,200.44 802.24,194.52zM732.8,577.88C732.8,707.8 512,819.8 512,819.8L512,262.36l220.8,0C732.8,262.36 732.8,447.96 732.8,577.88z" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<resources>
<!-- standard -->
<integer name="launchModeAPIlevel">0</integer>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<resources>
<!-- singleTask -->
<integer name="launchModeAPIlevel">2</integer>
</resources>

View file

@ -5,15 +5,19 @@ import android.service.autofill.Dataset
import android.service.autofill.FillResponse
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.AutofillView
import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledPartition
import com.x8bit.bitwarden.data.autofill.util.buildDataset
import com.x8bit.bitwarden.data.autofill.util.buildVaultItemDataset
import com.x8bit.bitwarden.data.util.mockBuilder
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.unmockkConstructor
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@ -26,6 +30,7 @@ class FillResponseBuilderTest {
private val context: Context = mockk()
private val dataset: Dataset = mockk()
private val vaultItemDataSet: Dataset = mockk()
private val fillResponse: FillResponse = mockk()
private val appInfo: AutofillAppInfo = AutofillAppInfo(
context = context,
@ -42,6 +47,7 @@ class FillResponseBuilderTest {
@BeforeEach
fun setup() {
mockkConstructor(FillResponse.Builder::class)
mockkStatic(FilledData::buildVaultItemDataset)
mockkStatic(FilledPartition::buildDataset)
every { anyConstructed<FillResponse.Builder>().build() } returns fillResponse
@ -51,28 +57,12 @@ class FillResponseBuilderTest {
@AfterEach
fun teardown() {
unmockkConstructor(FillResponse.Builder::class)
mockkStatic(FilledPartition::buildDataset)
unmockkStatic(FilledData::buildVaultItemDataset)
unmockkStatic(FilledPartition::buildDataset)
}
@Test
fun `build should return null when filledPartitions is empty`() {
// Test
val filledData = FilledData(
filledPartitions = emptyList(),
ignoreAutofillIds = emptyList(),
vaultItemInlinePresentationSpec = null,
)
val actual = fillResponseBuilder.build(
autofillAppInfo = appInfo,
filledData = filledData,
)
// Verify
assertNull(actual)
}
@Test
fun `build should return null when filledPartitions contains no views`() {
fun `build should return null when original partition contains no views`() {
// Test
val filledPartitions = FilledPartition(
autofillCipher = mockk(),
@ -84,7 +74,12 @@ class FillResponseBuilderTest {
filledPartitions,
),
ignoreAutofillIds = emptyList(),
originalPartition = AutofillPartition.Login(
views = emptyList(),
),
uri = null,
vaultItemInlinePresentationSpec = null,
isVaultLocked = false,
)
val actual = fillResponseBuilder.build(
autofillAppInfo = appInfo,
@ -111,14 +106,37 @@ class FillResponseBuilderTest {
val filledData = FilledData(
filledPartitions = filledPartitions,
ignoreAutofillIds = ignoreAutofillIds,
originalPartition = AutofillPartition.Login(
views = listOf(
AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = mockk(),
idPackage = null,
isFocused = true,
webDomain = null,
webScheme = null,
),
),
),
),
uri = null,
vaultItemInlinePresentationSpec = null,
isVaultLocked = false,
)
every {
filledPartitionOne.buildDataset(
autofillAppInfo = appInfo,
)
} returns dataset
mockBuilder<FillResponse.Builder> { it.addDataset(dataset) }
every {
filledData.buildVaultItemDataset(
autofillAppInfo = appInfo,
)
} returns vaultItemDataSet
mockBuilder<FillResponse.Builder> {
it.addDataset(dataset)
it.addDataset(vaultItemDataSet)
}
mockBuilder<FillResponse.Builder> {
it.setIgnoredIds(
ignoredAutofillIdOne,
@ -139,7 +157,11 @@ class FillResponseBuilderTest {
filledPartitionOne.buildDataset(
autofillAppInfo = appInfo,
)
filledData.buildVaultItemDataset(
autofillAppInfo = appInfo,
)
anyConstructed<FillResponse.Builder>().addDataset(dataset)
anyConstructed<FillResponse.Builder>().addDataset(vaultItemDataSet)
anyConstructed<FillResponse.Builder>().setIgnoredIds(
ignoredAutofillIdOne,
ignoredAutofillIdTwo,

View file

@ -106,7 +106,10 @@ class FilledDataBuilderTest {
filledPartition,
),
ignoreAutofillIds = ignoreAutofillIds,
originalPartition = autofillPartition,
uri = URI,
vaultItemInlinePresentationSpec = null,
isVaultLocked = false,
)
coEvery {
autofillCipherProvider.getLoginAutofillCiphers(
@ -167,7 +170,10 @@ class FilledDataBuilderTest {
val expected = FilledData(
filledPartitions = emptyList(),
ignoreAutofillIds = ignoreAutofillIds,
originalPartition = autofillPartition,
uri = null,
vaultItemInlinePresentationSpec = null,
isVaultLocked = false,
)
// Test
@ -242,7 +248,10 @@ class FilledDataBuilderTest {
filledPartition,
),
ignoreAutofillIds = ignoreAutofillIds,
originalPartition = autofillPartition,
uri = URI,
vaultItemInlinePresentationSpec = null,
isVaultLocked = false,
)
coEvery { autofillCipherProvider.getCardAutofillCiphers() } returns listOf(autofillCipher)
every { autofillViewCode.buildFilledItemOrNull(code) } returns filledItemCode
@ -337,7 +346,10 @@ class FilledDataBuilderTest {
filledPartitionThree,
),
ignoreAutofillIds = emptyList(),
originalPartition = autofillPartition,
uri = URI,
vaultItemInlinePresentationSpec = inlinePresentationSpec,
isVaultLocked = false,
)
coEvery {
autofillCipherProvider.getLoginAutofillCiphers(

View file

@ -111,7 +111,10 @@ class AutofillProcessorTest {
val filledData = FilledData(
filledPartitions = listOf(mockk()),
ignoreAutofillIds = emptyList(),
originalPartition = mockk(),
uri = null,
vaultItemInlinePresentationSpec = null,
isVaultLocked = false,
)
val fillResponse: FillResponse = mockk()
val autofillRequest: AutofillRequest.Fillable = mockk()

View file

@ -0,0 +1,303 @@
package com.x8bit.bitwarden.data.autofill.util
import android.app.PendingIntent
import android.content.Context
import android.content.IntentSender
import android.content.res.Resources
import android.service.autofill.Dataset
import android.service.autofill.InlinePresentation
import android.service.autofill.Presentations
import android.view.autofill.AutofillId
import android.view.autofill.AutofillValue
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.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.autofill.model.FilledData
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.buildVaultItemAutofillRemoteViews
import com.x8bit.bitwarden.ui.autofill.util.createVaultItemInlinePresentationOrNull
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkConstructor
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class FilledDataExtensionsTest {
private val autofillCipher: AutofillCipher = mockk {
every { this@mockk.name } returns CIPHER_NAME
}
private val autofillId: AutofillId = mockk()
private val autofillValue: AutofillValue = mockk()
private val res: Resources = mockk()
private val context: Context = mockk {
every { this@mockk.resources } returns res
}
private val dataset: Dataset = mockk()
private val filledItem: FilledItem = mockk() {
every { autofillId } returns mockk()
}
private val filledItemPlaceholder = FilledItem(
autofillId = autofillId,
value = autofillValue,
)
private val inlinePresentationSpec: InlinePresentationSpec = mockk()
private val filledPartition = FilledPartition(
autofillCipher = autofillCipher,
filledItems = listOf(
filledItem,
),
inlinePresentationSpec = inlinePresentationSpec,
)
private val filledData = FilledData(
filledPartitions = listOf(filledPartition),
ignoreAutofillIds = emptyList(),
originalPartition = AutofillPartition.Login(
views = listOf(
AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = autofillId,
idPackage = null,
isFocused = true,
webDomain = null,
webScheme = null,
),
),
),
),
uri = "uri",
vaultItemInlinePresentationSpec = inlinePresentationSpec,
isVaultLocked = false,
)
private val mockIntentSender: IntentSender = mockk()
private val pendingIntent: PendingIntent = mockk() {
every { intentSender } returns mockIntentSender
}
private val presentations: Presentations = mockk()
private val remoteViews: RemoteViews = mockk()
@BeforeEach
fun setup() {
mockkConstructor(Dataset.Builder::class)
mockkConstructor(Presentations.Builder::class)
mockkStatic(::buildVaultItemAutofillRemoteViews)
mockkStatic(AutofillValue::forText)
mockkStatic(FilledItem::applyToDatasetPostTiramisu)
mockkStatic(FilledItem::applyToDatasetPreTiramisu)
mockkStatic(InlinePresentationSpec::createVaultItemInlinePresentationOrNull)
mockkStatic(PendingIntent::class)
every { anyConstructed<Dataset.Builder>().build() } returns dataset
}
@AfterEach
fun teardown() {
unmockkConstructor(Dataset.Builder::class)
unmockkConstructor(Presentations.Builder::class)
unmockkStatic(::buildVaultItemAutofillRemoteViews)
unmockkStatic(AutofillValue::forText)
unmockkStatic(FilledItem::applyToDatasetPostTiramisu)
unmockkStatic(FilledItem::applyToDatasetPreTiramisu)
unmockkStatic(InlinePresentationSpec::createVaultItemInlinePresentationOrNull)
unmockkStatic(PendingIntent::class)
}
@Test
fun `fillableAutofillIds should return a list derived from the original partition`() {
assertEquals(
listOf(autofillId),
filledData.fillableAutofillIds,
)
}
@Test
fun `buildVaultItemDataset should applyToDatasetPostTiramisu when sdkInt is at least 33`() {
// Setup
val autofillAppInfo = AutofillAppInfo(
context = context,
packageName = PACKAGE_NAME,
sdkInt = 34,
)
val inlinePresentation: InlinePresentation = mockk()
every { AutofillValue.forText(any()) } returns autofillValue
every { PendingIntent.getActivity(any(), any(), any(), any()) } returns pendingIntent
every {
buildVaultItemAutofillRemoteViews(
autofillAppInfo = autofillAppInfo,
isLocked = false,
)
} returns remoteViews
every {
inlinePresentationSpec.createVaultItemInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
pendingIntent = any(),
isLocked = false,
)
} returns inlinePresentation
mockBuilder<Dataset.Builder> { it.setAuthentication(mockIntentSender) }
mockBuilder<Presentations.Builder> { it.setInlinePresentation(inlinePresentation) }
mockBuilder<Presentations.Builder> { it.setMenuPresentation(remoteViews) }
every {
filledItemPlaceholder.applyToDatasetPostTiramisu(
datasetBuilder = any(),
presentations = presentations,
)
} just runs
every { anyConstructed<Presentations.Builder>().build() } returns presentations
// Test
val actual = filledData.buildVaultItemDataset(
autofillAppInfo = autofillAppInfo,
)
// Verify
assertEquals(dataset, actual)
verify(exactly = 1) {
AutofillValue.forText("PLACEHOLDER")
PendingIntent.getActivity(any(), any(), any(), any())
buildVaultItemAutofillRemoteViews(
autofillAppInfo = autofillAppInfo,
isLocked = false,
)
inlinePresentationSpec.createVaultItemInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
pendingIntent = any(),
isLocked = false,
)
anyConstructed<Dataset.Builder>().setAuthentication(mockIntentSender)
anyConstructed<Presentations.Builder>().setInlinePresentation(inlinePresentation)
anyConstructed<Presentations.Builder>().setMenuPresentation(remoteViews)
anyConstructed<Presentations.Builder>().build()
filledItemPlaceholder.applyToDatasetPostTiramisu(
datasetBuilder = any(),
presentations = presentations,
)
anyConstructed<Dataset.Builder>().build()
}
}
@Suppress("MaxLineLength")
@Test
fun `buildVaultItemDataset should skip inline and applyToDatasetPreTiramisu when sdkInt is less than 30`() {
// Setup
val autofillAppInfo = AutofillAppInfo(
context = context,
packageName = PACKAGE_NAME,
sdkInt = 18,
)
every { AutofillValue.forText(any()) } returns autofillValue
every { PendingIntent.getActivity(any(), any(), any(), any()) } returns pendingIntent
every {
buildVaultItemAutofillRemoteViews(
autofillAppInfo = autofillAppInfo,
isLocked = false,
)
} returns remoteViews
mockBuilder<Dataset.Builder> { it.setAuthentication(mockIntentSender) }
every {
filledItemPlaceholder.applyToDatasetPostTiramisu(
datasetBuilder = any(),
presentations = presentations,
)
} just runs
// Test
val actual = filledData.buildVaultItemDataset(
autofillAppInfo = autofillAppInfo,
)
// Verify
assertEquals(dataset, actual)
verify(exactly = 1) {
AutofillValue.forText("PLACEHOLDER")
PendingIntent.getActivity(any(), any(), any(), any())
buildVaultItemAutofillRemoteViews(
autofillAppInfo = autofillAppInfo,
isLocked = false,
)
filledItemPlaceholder.applyToDatasetPreTiramisu(
datasetBuilder = any(),
remoteViews = remoteViews,
)
anyConstructed<Dataset.Builder>().build()
}
}
@Suppress("Deprecation", "MaxLineLength")
@Test
fun `buildVaultItemDataset 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 { AutofillValue.forText(any()) } returns autofillValue
every { PendingIntent.getActivity(any(), any(), any(), any()) } returns pendingIntent
every {
buildVaultItemAutofillRemoteViews(
autofillAppInfo = autofillAppInfo,
isLocked = false,
)
} returns remoteViews
every {
inlinePresentationSpec.createVaultItemInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
pendingIntent = any(),
isLocked = false,
)
} returns inlinePresentation
mockBuilder<Dataset.Builder> { it.setAuthentication(mockIntentSender) }
mockBuilder<Dataset.Builder> { it.setInlinePresentation(inlinePresentation) }
every {
filledItemPlaceholder.applyToDatasetPostTiramisu(
datasetBuilder = any(),
presentations = presentations,
)
} just runs
// Test
val actual = filledData.buildVaultItemDataset(
autofillAppInfo = autofillAppInfo,
)
// Verify
assertEquals(dataset, actual)
verify(exactly = 1) {
AutofillValue.forText("PLACEHOLDER")
PendingIntent.getActivity(any(), any(), any(), any())
buildVaultItemAutofillRemoteViews(
autofillAppInfo = autofillAppInfo,
isLocked = false,
)
inlinePresentationSpec.createVaultItemInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
pendingIntent = any(),
isLocked = false,
)
anyConstructed<Dataset.Builder>().setInlinePresentation(inlinePresentation)
filledItemPlaceholder.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

@ -29,6 +29,9 @@ class BitwardenRemoteViewsTest {
every { this@mockk.getColor(R.color.on_surface) } returns ON_SURFACE_COLOR
every { this@mockk.getColor(R.color.on_surface_variant) } returns ON_SURFACE_VARIANT_COLOR
every { this@mockk.getColor(R.color.surface) } returns SURFACE_COLOR
every { this@mockk.getString(R.string.app_name) } returns APP_NAME
every { this@mockk.getString(R.string.go_to_my_vault) } returns GO_TO_MY_VAULT
every { this@mockk.getString(R.string.vault_is_locked) } returns VAULT_IS_LOCKED
}
private val autofillAppInfo: AutofillAppInfo = mockk {
every { this@mockk.context } returns testContext
@ -56,59 +59,11 @@ class BitwardenRemoteViewsTest {
fun `buildAutofillRemoteViews should set values and light mode colors when not night mode`() {
// Setup
every { testContext.isSystemDarkMode } returns false
every {
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.title,
NAME,
prepareRemoteViews(
name = NAME,
subtitle = SUBTITLE,
iconRes = ICON_RES,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.subtitle,
SUBTITLE,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setImageViewResource(
R.id.icon,
ICON_RES,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.container,
"setBackgroundColor",
SURFACE_COLOR,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.icon,
"setColorFilter",
ON_SURFACE_COLOR,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.title,
"setTextColor",
ON_SURFACE_COLOR,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.subtitle,
"setTextColor",
ON_SURFACE_VARIANT_COLOR,
)
} just runs
// Test
buildAutofillRemoteViews(
@ -168,59 +123,11 @@ class BitwardenRemoteViewsTest {
fun `buildAutofillRemoteViews should set values and dark mode colors when night mode`() {
// Setup
every { testContext.isSystemDarkMode } returns true
every {
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.title,
NAME,
prepareRemoteViews(
name = NAME,
subtitle = SUBTITLE,
iconRes = ICON_RES,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.subtitle,
SUBTITLE,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setImageViewResource(
R.id.icon,
ICON_RES,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.container,
"setBackgroundColor",
DARK_SURFACE_COLOR,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.icon,
"setColorFilter",
DARK_ON_SURFACE_COLOR,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.title,
"setTextColor",
DARK_ON_SURFACE_COLOR,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.subtitle,
"setTextColor",
DARK_ON_SURFACE_VARIANT_COLOR,
)
} just runs
// Test
buildAutofillRemoteViews(
@ -275,11 +182,197 @@ class BitwardenRemoteViewsTest {
)
}
}
@Test
fun `buildVaultItemAutofillRemoteViews should set values properly when vault is locked`() {
// Setup
every { testContext.isSystemDarkMode } returns false
prepareRemoteViews(
name = APP_NAME,
subtitle = VAULT_IS_LOCKED,
iconRes = R.drawable.icon,
)
// Test
buildVaultItemAutofillRemoteViews(
autofillAppInfo = autofillAppInfo,
isLocked = true,
)
// Note: impossible to do a useful test of the returned RemoteViews due to mockking
// constraints of the [RemoteViews] constructor. Our best bet is to make sure the correct
// operations are performed on the constructed [RemoteViews].
// Verify
verify(exactly = 1) {
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.title,
APP_NAME,
)
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.subtitle,
VAULT_IS_LOCKED,
)
anyConstructed<RemoteViews>()
.setImageViewResource(
R.id.icon,
R.drawable.icon,
)
anyConstructed<RemoteViews>()
.setInt(
R.id.container,
"setBackgroundColor",
SURFACE_COLOR,
)
anyConstructed<RemoteViews>()
.setInt(
R.id.title,
"setTextColor",
ON_SURFACE_COLOR,
)
anyConstructed<RemoteViews>()
.setInt(
R.id.subtitle,
"setTextColor",
ON_SURFACE_VARIANT_COLOR,
)
}
}
@Test
fun `buildVaultItemAutofillRemoteViews should set values properly when vault is unlocked`() {
// Setup
every { testContext.isSystemDarkMode } returns true
prepareRemoteViews(
name = APP_NAME,
subtitle = GO_TO_MY_VAULT,
iconRes = R.drawable.icon,
)
// Test
buildVaultItemAutofillRemoteViews(
autofillAppInfo = autofillAppInfo,
isLocked = false,
)
// Note: impossible to do a useful test of the returned RemoteViews due to mockking
// constraints of the [RemoteViews] constructor. Our best bet is to make sure the correct
// operations are performed on the constructed [RemoteViews].
// Verify
verify(exactly = 1) {
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.title,
APP_NAME,
)
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.subtitle,
GO_TO_MY_VAULT,
)
anyConstructed<RemoteViews>()
.setImageViewResource(
R.id.icon,
R.drawable.icon,
)
anyConstructed<RemoteViews>()
.setInt(
R.id.container,
"setBackgroundColor",
DARK_SURFACE_COLOR,
)
anyConstructed<RemoteViews>()
.setInt(
R.id.title,
"setTextColor",
DARK_ON_SURFACE_COLOR,
)
anyConstructed<RemoteViews>()
.setInt(
R.id.subtitle,
"setTextColor",
DARK_ON_SURFACE_VARIANT_COLOR,
)
}
}
private fun prepareRemoteViews(
name: String,
subtitle: String,
iconRes: Int,
) {
every {
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.title,
name,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setTextViewText(
R.id.subtitle,
subtitle,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setImageViewResource(
R.id.icon,
iconRes,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.container,
"setBackgroundColor",
DARK_SURFACE_COLOR,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.icon,
"setColorFilter",
DARK_ON_SURFACE_COLOR,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.icon,
"setColorFilter",
ON_SURFACE_COLOR,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.title,
"setTextColor",
DARK_ON_SURFACE_COLOR,
)
} just runs
every {
anyConstructed<RemoteViews>()
.setInt(
R.id.subtitle,
"setTextColor",
DARK_ON_SURFACE_VARIANT_COLOR,
)
} just runs
}
}
private const val APP_NAME = "Bitwarden"
private const val DARK_ON_SURFACE_COLOR: Int = 321
private const val DARK_ON_SURFACE_VARIANT_COLOR: Int = 654
private const val DARK_SURFACE_COLOR: Int = 987
private const val GO_TO_MY_VAULT = "Go to my vault"
private const val ICON_RES: Int = 41421421
private const val NAME: String = "NAME"
private const val ON_SURFACE_COLOR: Int = 123
@ -287,3 +380,4 @@ private const val ON_SURFACE_VARIANT_COLOR: Int = 456
private const val PACKAGE_NAME: String = "com.x8bit.bitwarden"
private const val SUBTITLE: String = "SUBTITLE"
private const val SURFACE_COLOR: Int = 789
private const val VAULT_IS_LOCKED = "Vault is locked"

View file

@ -4,6 +4,7 @@ import android.app.PendingIntent
import android.app.slice.Slice
import android.content.Context
import android.content.Intent
import android.graphics.BlendMode
import android.graphics.drawable.Icon
import android.os.Bundle
import android.widget.inline.InlinePresentationSpec
@ -18,7 +19,8 @@ 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.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -53,6 +55,20 @@ class InlinePresentationSpecExtensionsTest {
@Test
fun `createCipherInlinePresentationOrNull should return null if incompatible`() {
// Setup
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()
every {
PendingIntent.getService(
testContext,
PENDING_INTENT_CODE,
any<Intent>(),
PENDING_INTENT_FLAGS,
)
} returns pendingIntent
every {
UiVersions.getVersions(testStyle)
} returns emptyList()
@ -60,11 +76,11 @@ class InlinePresentationSpecExtensionsTest {
// Test
val actual = inlinePresentationSpec.createCipherInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
autofillCipher = mockk(),
autofillCipher = autofillCipher,
)
// Verify
Assertions.assertNull(actual)
assertNull(actual)
verify(exactly = 1) {
UiVersions.getVersions(testStyle)
}
@ -75,41 +91,19 @@ class InlinePresentationSpecExtensionsTest {
fun `createCipherInlinePresentationOrNull should return presentation with card icon when card cipher and compatible`() {
// Setup
val icon: Icon = mockk()
val iconRes = R.drawable.ic_card_item
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
every { this@mockk.iconRes } returns iconRes
}
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,
prepareForCompatibleCipherInlinePresentation(
iconRes = iconRes,
icon = icon,
pendingIntent = pendingIntent,
isSystemDarkMode = true,
)
} 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(
@ -118,7 +112,7 @@ class InlinePresentationSpecExtensionsTest {
)
// Verify not-null because we can't properly mock Intent constructors.
Assertions.assertNotNull(actual)
assertNotNull(actual)
verify(exactly = 1) {
UiVersions.getVersions(testStyle)
PendingIntent.getService(
@ -131,7 +125,7 @@ class InlinePresentationSpecExtensionsTest {
testContext.getColor(R.color.dark_on_surface)
Icon.createWithResource(
testContext,
R.drawable.ic_card_item,
iconRes,
)
.setTint(ICON_TINT)
InlineSuggestionUi.newContentBuilder(pendingIntent)
@ -148,41 +142,19 @@ class InlinePresentationSpecExtensionsTest {
fun `createCipherInlinePresentationOrNull should return presentation with login icon when login cipher and compatible`() {
// Setup
val icon: Icon = mockk()
val iconRes = R.drawable.ic_login_item
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,
prepareForCompatibleCipherInlinePresentation(
iconRes = iconRes,
icon = icon,
pendingIntent = pendingIntent,
isSystemDarkMode = false,
)
} 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(
@ -191,7 +163,7 @@ class InlinePresentationSpecExtensionsTest {
)
// Verify not-null because we can't properly mock Intent constructors.
Assertions.assertNotNull(actual)
assertNotNull(actual)
verify(exactly = 1) {
UiVersions.getVersions(testStyle)
PendingIntent.getService(
@ -204,7 +176,7 @@ class InlinePresentationSpecExtensionsTest {
testContext.getColor(R.color.on_surface)
Icon.createWithResource(
testContext,
R.drawable.ic_login_item,
iconRes,
)
.setTint(ICON_TINT)
InlineSuggestionUi.newContentBuilder(pendingIntent)
@ -215,11 +187,196 @@ class InlinePresentationSpecExtensionsTest {
.slice
}
}
@Test
fun `createVaultItemInlinePresentationOrNull should return null if incompatible`() {
// Setup
val pendingIntent: PendingIntent = mockk()
every {
UiVersions.getVersions(testStyle)
} returns emptyList()
every { testContext.getString(R.string.app_name) } returns APP_NAME
every { testContext.getString(R.string.vault_is_locked) } returns VAULT_IS_LOCKED
every { testContext.getString(R.string.my_vault) } returns MY_VAULT
// Test
val actual = inlinePresentationSpec.createVaultItemInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
pendingIntent = pendingIntent,
isLocked = true,
)
// Verify
assertNull(actual)
verify(exactly = 1) {
UiVersions.getVersions(testStyle)
}
}
@Suppress("MaxLineLength")
@Test
fun `createVaultItemInlinePresentationOrNull should return presentation with locked vault title when vault is locked and compatible`() {
// Setup
val icon: Icon = mockk()
val pendingIntent: PendingIntent = mockk()
prepareForCompatibleVaultItemInlinePresentation(
icon = icon,
pendingIntent = pendingIntent,
)
// Test
val actual = inlinePresentationSpec.createVaultItemInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
pendingIntent = pendingIntent,
isLocked = true,
)
// Verify not-null because we can't properly mock Intent constructors.
assertNotNull(actual)
verify(exactly = 1) {
UiVersions.getVersions(testStyle)
Icon
.createWithResource(
testContext,
R.drawable.icon,
)
.setTintBlendMode(BlendMode.DST)
testContext.getString(R.string.app_name)
testContext.getString(R.string.vault_is_locked)
InlineSuggestionUi.newContentBuilder(pendingIntent)
.setTitle(APP_NAME)
.setSubtitle(VAULT_IS_LOCKED)
.setStartIcon(icon)
.build()
.slice
}
}
@Suppress("MaxLineLength")
@Test
fun `createVaultItemInlinePresentationOrNull should return presentation with my vault title when vault is locked and compatible`() {
// Setup
val icon: Icon = mockk()
val pendingIntent: PendingIntent = mockk()
prepareForCompatibleVaultItemInlinePresentation(
icon = icon,
pendingIntent = pendingIntent,
)
// Test
val actual = inlinePresentationSpec.createVaultItemInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo,
pendingIntent = pendingIntent,
isLocked = false,
)
// Verify not-null because we can't properly mock Intent constructors.
assertNotNull(actual)
verify(exactly = 1) {
UiVersions.getVersions(testStyle)
Icon
.createWithResource(
testContext,
R.drawable.icon,
)
.setTintBlendMode(BlendMode.DST)
testContext.getString(R.string.app_name)
testContext.getString(R.string.my_vault)
InlineSuggestionUi.newContentBuilder(pendingIntent)
.setTitle(APP_NAME)
.setSubtitle(MY_VAULT)
.setStartIcon(icon)
.build()
.slice
}
}
private fun prepareForCompatibleCipherInlinePresentation(
iconRes: Int,
icon: Icon,
pendingIntent: PendingIntent,
isSystemDarkMode: Boolean,
) {
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 isSystemDarkMode
every { testContext.getColor(R.color.on_surface) } returns ICON_TINT
every { testContext.getColor(R.color.dark_on_surface) } returns ICON_TINT
every {
Icon.createWithResource(
testContext,
iconRes,
)
.setTint(ICON_TINT)
} returns icon
every {
InlineSuggestionUi
.newContentBuilder(pendingIntent)
.setTitle(AUTOFILL_CIPHER_NAME)
.setSubtitle(AUTOFILL_CIPHER_SUBTITLE)
.setStartIcon(icon)
.build()
.slice
} returns slice
}
private fun prepareForCompatibleVaultItemInlinePresentation(
icon: Icon,
pendingIntent: PendingIntent,
) {
val slice: Slice = mockk()
every {
UiVersions.getVersions(testStyle)
} returns listOf(UiVersions.INLINE_UI_VERSION_1)
every {
Icon
.createWithResource(
testContext,
R.drawable.icon,
)
.setTintBlendMode(BlendMode.DST)
} returns icon
every {
InlineSuggestionUi
.newContentBuilder(pendingIntent)
.setTitle(APP_NAME)
.setSubtitle(VAULT_IS_LOCKED)
.setStartIcon(icon)
.build()
.slice
} returns slice
every {
InlineSuggestionUi
.newContentBuilder(pendingIntent)
.setTitle(APP_NAME)
.setSubtitle(MY_VAULT)
.setStartIcon(icon)
.build()
.slice
} returns slice
every { testContext.getString(R.string.app_name) } returns APP_NAME
every { testContext.getString(R.string.vault_is_locked) } returns VAULT_IS_LOCKED
every { testContext.getString(R.string.my_vault) } returns MY_VAULT
}
}
private const val APP_NAME = "Bitwarden"
private const val AUTOFILL_CIPHER_NAME = "Cipher1"
private const val AUTOFILL_CIPHER_SUBTITLE = "Subtitle"
private const val ICON_TINT: Int = 6123751
private const val MY_VAULT = "My vault"
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
private const val VAULT_IS_LOCKED = "Vault is locked"