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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleInstancePerTask" android:launchMode="@integer/launchModeAPIlevel"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <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.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.FilledData import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.util.buildDataset 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 * The default implementation for [FillResponseBuilder]. This is a component for compiling fulfilled
@ -14,7 +16,7 @@ class FillResponseBuilderImpl : FillResponseBuilder {
autofillAppInfo: AutofillAppInfo, autofillAppInfo: AutofillAppInfo,
filledData: FilledData, filledData: FilledData,
): FillResponse? = ): FillResponse? =
if (filledData.filledPartitions.any { it.filledItems.isNotEmpty() }) { if (filledData.fillableAutofillIds.isNotEmpty()) {
val fillResponseBuilder = FillResponse.Builder() val fillResponseBuilder = FillResponse.Builder()
filledData filledData
@ -35,9 +37,14 @@ class FillResponseBuilderImpl : FillResponseBuilder {
} }
} }
// TODO: add vault item dataset (BIT-1296)
fillResponseBuilder fillResponseBuilder
// Add the Vault Item
.addDataset(
filledData
.buildVaultItemDataset(
autofillAppInfo = autofillAppInfo,
),
)
.setIgnoredIds(*filledData.ignoreAutofillIds.toTypedArray()) .setIgnoredIds(*filledData.ignoreAutofillIds.toTypedArray())
.build() .build()
} else { } else {

View file

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

View file

@ -10,5 +10,8 @@ import android.widget.inline.InlinePresentationSpec
data class FilledData( data class FilledData(
val filledPartitions: List<FilledPartition>, val filledPartitions: List<FilledPartition>,
val ignoreAutofillIds: List<AutofillId>, val ignoreAutofillIds: List<AutofillId>,
val originalPartition: AutofillPartition,
val uri: String?,
val vaultItemInlinePresentationSpec: InlinePresentationSpec?, 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.content.Context
import android.widget.RemoteViews import android.widget.RemoteViews
import androidx.annotation.DrawableRes
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
@ -13,6 +14,42 @@ import com.x8bit.bitwarden.ui.autofill.util.isSystemDarkMode
fun buildAutofillRemoteViews( fun buildAutofillRemoteViews(
autofillAppInfo: AutofillAppInfo, autofillAppInfo: AutofillAppInfo,
autofillCipher: AutofillCipher, 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 =
RemoteViews( RemoteViews(
autofillAppInfo.packageName, autofillAppInfo.packageName,
@ -21,27 +58,21 @@ fun buildAutofillRemoteViews(
.apply { .apply {
setTextViewText( setTextViewText(
R.id.title, R.id.title,
autofillCipher.name, name,
) )
setTextViewText( setTextViewText(
R.id.subtitle, R.id.subtitle,
autofillCipher.subtitle, subtitle,
) )
setImageViewResource( setImageViewResource(
R.id.icon, R.id.icon,
autofillCipher.iconRes, iconRes,
) )
setInt( setInt(
R.id.container, R.id.container,
"setBackgroundColor", "setBackgroundColor",
autofillAppInfo.context.surface, autofillAppInfo.context.surface,
) )
setInt(
R.id.icon,
"setColorFilter",
autofillAppInfo.context.onSurface,
)
setInt( setInt(
R.id.title, R.id.title,
"setTextColor", "setTextColor",
@ -52,6 +83,13 @@ fun buildAutofillRemoteViews(
"setTextColor", "setTextColor",
autofillAppInfo.context.onSurfaceVariant, autofillAppInfo.context.onSurfaceVariant,
) )
if (shouldTintIcon) {
setInt(
R.id.icon,
"setColorFilter",
autofillAppInfo.context.onSurface,
)
}
} }
private val Context.onSurface: Int private val Context.onSurface: Int

View file

@ -3,10 +3,12 @@ package com.x8bit.bitwarden.ui.autofill.util
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.graphics.BlendMode
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.os.Build import android.os.Build
import android.service.autofill.InlinePresentation import android.service.autofill.InlinePresentation
import android.widget.inline.InlinePresentationSpec import android.widget.inline.InlinePresentationSpec
import androidx.annotation.DrawableRes
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.autofill.inline.UiVersions import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.v1.InlineSuggestionUi import androidx.autofill.inline.v1.InlineSuggestionUi
@ -19,10 +21,59 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
* it fails, return null. * it fails, return null.
*/ */
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
@SuppressLint("RestrictedApi")
fun InlinePresentationSpec.createCipherInlinePresentationOrNull( fun InlinePresentationSpec.createCipherInlinePresentationOrNull(
autofillAppInfo: AutofillAppInfo, autofillAppInfo: AutofillAppInfo,
autofillCipher: AutofillCipher, 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? { ): InlinePresentation? {
val isInlineCompatible = UiVersions val isInlineCompatible = UiVersions
.getVersions(style) .getVersions(style)
@ -30,24 +81,23 @@ fun InlinePresentationSpec.createCipherInlinePresentationOrNull(
if (!isInlineCompatible) return null 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 val icon = Icon
.createWithResource( .createWithResource(
autofillAppInfo.context, autofillAppInfo.context,
autofillCipher.iconRes, iconRes,
) )
.setTint(autofillAppInfo.contentColor) .run {
if (shouldTintIcon) {
setTint(autofillAppInfo.contentColor)
} else {
// Remove tinting
setTintBlendMode(BlendMode.DST)
}
}
val slice = InlineSuggestionUi val slice = InlineSuggestionUi
.newContentBuilder(pendingIntent) .newContentBuilder(pendingIntent)
.setTitle(autofillCipher.name) .setTitle(title)
.setSubtitle(autofillCipher.subtitle) .setSubtitle(subtitle)
.setStartIcon(icon) .setStartIcon(icon)
.build() .build()
.slice .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.service.autofill.FillResponse
import android.view.autofill.AutofillId import android.view.autofill.AutofillId
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.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.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledPartition import com.x8bit.bitwarden.data.autofill.model.FilledPartition
import com.x8bit.bitwarden.data.autofill.util.buildDataset import com.x8bit.bitwarden.data.autofill.util.buildDataset
import com.x8bit.bitwarden.data.autofill.util.buildVaultItemDataset
import com.x8bit.bitwarden.data.util.mockBuilder import com.x8bit.bitwarden.data.util.mockBuilder
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkConstructor import io.mockk.mockkConstructor
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkConstructor import io.mockk.unmockkConstructor
import io.mockk.unmockkStatic
import io.mockk.verify import io.mockk.verify
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
@ -26,6 +30,7 @@ class FillResponseBuilderTest {
private val context: Context = mockk() private val context: Context = mockk()
private val dataset: Dataset = mockk() private val dataset: Dataset = mockk()
private val vaultItemDataSet: Dataset = mockk()
private val fillResponse: FillResponse = mockk() private val fillResponse: FillResponse = mockk()
private val appInfo: AutofillAppInfo = AutofillAppInfo( private val appInfo: AutofillAppInfo = AutofillAppInfo(
context = context, context = context,
@ -42,6 +47,7 @@ class FillResponseBuilderTest {
@BeforeEach @BeforeEach
fun setup() { fun setup() {
mockkConstructor(FillResponse.Builder::class) mockkConstructor(FillResponse.Builder::class)
mockkStatic(FilledData::buildVaultItemDataset)
mockkStatic(FilledPartition::buildDataset) mockkStatic(FilledPartition::buildDataset)
every { anyConstructed<FillResponse.Builder>().build() } returns fillResponse every { anyConstructed<FillResponse.Builder>().build() } returns fillResponse
@ -51,28 +57,12 @@ class FillResponseBuilderTest {
@AfterEach @AfterEach
fun teardown() { fun teardown() {
unmockkConstructor(FillResponse.Builder::class) unmockkConstructor(FillResponse.Builder::class)
mockkStatic(FilledPartition::buildDataset) unmockkStatic(FilledData::buildVaultItemDataset)
unmockkStatic(FilledPartition::buildDataset)
} }
@Test @Test
fun `build should return null when filledPartitions is empty`() { fun `build should return null when original partition contains no views`() {
// 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`() {
// Test // Test
val filledPartitions = FilledPartition( val filledPartitions = FilledPartition(
autofillCipher = mockk(), autofillCipher = mockk(),
@ -84,7 +74,12 @@ class FillResponseBuilderTest {
filledPartitions, filledPartitions,
), ),
ignoreAutofillIds = emptyList(), ignoreAutofillIds = emptyList(),
originalPartition = AutofillPartition.Login(
views = emptyList(),
),
uri = null,
vaultItemInlinePresentationSpec = null, vaultItemInlinePresentationSpec = null,
isVaultLocked = false,
) )
val actual = fillResponseBuilder.build( val actual = fillResponseBuilder.build(
autofillAppInfo = appInfo, autofillAppInfo = appInfo,
@ -111,14 +106,37 @@ class FillResponseBuilderTest {
val filledData = FilledData( val filledData = FilledData(
filledPartitions = filledPartitions, filledPartitions = filledPartitions,
ignoreAutofillIds = ignoreAutofillIds, 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, vaultItemInlinePresentationSpec = null,
isVaultLocked = false,
) )
every { every {
filledPartitionOne.buildDataset( filledPartitionOne.buildDataset(
autofillAppInfo = appInfo, autofillAppInfo = appInfo,
) )
} returns dataset } 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> { mockBuilder<FillResponse.Builder> {
it.setIgnoredIds( it.setIgnoredIds(
ignoredAutofillIdOne, ignoredAutofillIdOne,
@ -139,7 +157,11 @@ class FillResponseBuilderTest {
filledPartitionOne.buildDataset( filledPartitionOne.buildDataset(
autofillAppInfo = appInfo, autofillAppInfo = appInfo,
) )
filledData.buildVaultItemDataset(
autofillAppInfo = appInfo,
)
anyConstructed<FillResponse.Builder>().addDataset(dataset) anyConstructed<FillResponse.Builder>().addDataset(dataset)
anyConstructed<FillResponse.Builder>().addDataset(vaultItemDataSet)
anyConstructed<FillResponse.Builder>().setIgnoredIds( anyConstructed<FillResponse.Builder>().setIgnoredIds(
ignoredAutofillIdOne, ignoredAutofillIdOne,
ignoredAutofillIdTwo, ignoredAutofillIdTwo,

View file

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

View file

@ -111,7 +111,10 @@ class AutofillProcessorTest {
val filledData = FilledData( val filledData = FilledData(
filledPartitions = listOf(mockk()), filledPartitions = listOf(mockk()),
ignoreAutofillIds = emptyList(), ignoreAutofillIds = emptyList(),
originalPartition = mockk(),
uri = null,
vaultItemInlinePresentationSpec = null, vaultItemInlinePresentationSpec = null,
isVaultLocked = false,
) )
val fillResponse: FillResponse = mockk() val fillResponse: FillResponse = mockk()
val autofillRequest: AutofillRequest.Fillable = 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) } returns ON_SURFACE_COLOR
every { this@mockk.getColor(R.color.on_surface_variant) } returns ON_SURFACE_VARIANT_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.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 { private val autofillAppInfo: AutofillAppInfo = mockk {
every { this@mockk.context } returns testContext 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`() { fun `buildAutofillRemoteViews should set values and light mode colors when not night mode`() {
// Setup // Setup
every { testContext.isSystemDarkMode } returns false every { testContext.isSystemDarkMode } returns false
every { prepareRemoteViews(
anyConstructed<RemoteViews>() name = NAME,
.setTextViewText( subtitle = SUBTITLE,
R.id.title, iconRes = ICON_RES,
NAME,
) )
} 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 // Test
buildAutofillRemoteViews( buildAutofillRemoteViews(
@ -168,59 +123,11 @@ class BitwardenRemoteViewsTest {
fun `buildAutofillRemoteViews should set values and dark mode colors when night mode`() { fun `buildAutofillRemoteViews should set values and dark mode colors when night mode`() {
// Setup // Setup
every { testContext.isSystemDarkMode } returns true every { testContext.isSystemDarkMode } returns true
every { prepareRemoteViews(
anyConstructed<RemoteViews>() name = NAME,
.setTextViewText( subtitle = SUBTITLE,
R.id.title, iconRes = ICON_RES,
NAME,
) )
} 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 // Test
buildAutofillRemoteViews( 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_COLOR: Int = 321
private const val DARK_ON_SURFACE_VARIANT_COLOR: Int = 654 private const val DARK_ON_SURFACE_VARIANT_COLOR: Int = 654
private const val DARK_SURFACE_COLOR: Int = 987 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 ICON_RES: Int = 41421421
private const val NAME: String = "NAME" private const val NAME: String = "NAME"
private const val ON_SURFACE_COLOR: Int = 123 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 PACKAGE_NAME: String = "com.x8bit.bitwarden"
private const val SUBTITLE: String = "SUBTITLE" private const val SUBTITLE: String = "SUBTITLE"
private const val SURFACE_COLOR: Int = 789 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.app.slice.Slice
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BlendMode
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.os.Bundle import android.os.Bundle
import android.widget.inline.InlinePresentationSpec import android.widget.inline.InlinePresentationSpec
@ -18,7 +19,8 @@ import io.mockk.mockkStatic
import io.mockk.unmockkStatic import io.mockk.unmockkStatic
import io.mockk.verify import io.mockk.verify
import org.junit.jupiter.api.AfterEach 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.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -53,6 +55,20 @@ class InlinePresentationSpecExtensionsTest {
@Test @Test
fun `createCipherInlinePresentationOrNull should return null if incompatible`() { fun `createCipherInlinePresentationOrNull should return null if incompatible`() {
// Setup // 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 { every {
UiVersions.getVersions(testStyle) UiVersions.getVersions(testStyle)
} returns emptyList() } returns emptyList()
@ -60,11 +76,11 @@ class InlinePresentationSpecExtensionsTest {
// Test // Test
val actual = inlinePresentationSpec.createCipherInlinePresentationOrNull( val actual = inlinePresentationSpec.createCipherInlinePresentationOrNull(
autofillAppInfo = autofillAppInfo, autofillAppInfo = autofillAppInfo,
autofillCipher = mockk(), autofillCipher = autofillCipher,
) )
// Verify // Verify
Assertions.assertNull(actual) assertNull(actual)
verify(exactly = 1) { verify(exactly = 1) {
UiVersions.getVersions(testStyle) UiVersions.getVersions(testStyle)
} }
@ -75,41 +91,19 @@ class InlinePresentationSpecExtensionsTest {
fun `createCipherInlinePresentationOrNull should return presentation with card icon when card cipher and compatible`() { fun `createCipherInlinePresentationOrNull should return presentation with card icon when card cipher and compatible`() {
// Setup // Setup
val icon: Icon = mockk() val icon: Icon = mockk()
val iconRes = R.drawable.ic_card_item
val autofillCipher: AutofillCipher.Card = mockk { val autofillCipher: AutofillCipher.Card = mockk {
every { this@mockk.name } returns AUTOFILL_CIPHER_NAME every { this@mockk.name } returns AUTOFILL_CIPHER_NAME
every { this@mockk.subtitle } returns AUTOFILL_CIPHER_SUBTITLE 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 pendingIntent: PendingIntent = mockk()
val slice: Slice = mockk() prepareForCompatibleCipherInlinePresentation(
every { iconRes = iconRes,
UiVersions.getVersions(testStyle) icon = icon,
} returns listOf(UiVersions.INLINE_UI_VERSION_1) pendingIntent = pendingIntent,
every { isSystemDarkMode = true,
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 // Test
val actual = inlinePresentationSpec.createCipherInlinePresentationOrNull( val actual = inlinePresentationSpec.createCipherInlinePresentationOrNull(
@ -118,7 +112,7 @@ class InlinePresentationSpecExtensionsTest {
) )
// Verify not-null because we can't properly mock Intent constructors. // Verify not-null because we can't properly mock Intent constructors.
Assertions.assertNotNull(actual) assertNotNull(actual)
verify(exactly = 1) { verify(exactly = 1) {
UiVersions.getVersions(testStyle) UiVersions.getVersions(testStyle)
PendingIntent.getService( PendingIntent.getService(
@ -131,7 +125,7 @@ class InlinePresentationSpecExtensionsTest {
testContext.getColor(R.color.dark_on_surface) testContext.getColor(R.color.dark_on_surface)
Icon.createWithResource( Icon.createWithResource(
testContext, testContext,
R.drawable.ic_card_item, iconRes,
) )
.setTint(ICON_TINT) .setTint(ICON_TINT)
InlineSuggestionUi.newContentBuilder(pendingIntent) InlineSuggestionUi.newContentBuilder(pendingIntent)
@ -148,41 +142,19 @@ class InlinePresentationSpecExtensionsTest {
fun `createCipherInlinePresentationOrNull should return presentation with login icon when login cipher and compatible`() { fun `createCipherInlinePresentationOrNull should return presentation with login icon when login cipher and compatible`() {
// Setup // Setup
val icon: Icon = mockk() val icon: Icon = mockk()
val iconRes = R.drawable.ic_login_item
val autofillCipher: AutofillCipher.Login = mockk { val autofillCipher: AutofillCipher.Login = mockk {
every { this@mockk.name } returns AUTOFILL_CIPHER_NAME every { this@mockk.name } returns AUTOFILL_CIPHER_NAME
every { this@mockk.subtitle } returns AUTOFILL_CIPHER_SUBTITLE every { this@mockk.subtitle } returns AUTOFILL_CIPHER_SUBTITLE
every { this@mockk.iconRes } returns R.drawable.ic_login_item every { this@mockk.iconRes } returns R.drawable.ic_login_item
} }
val pendingIntent: PendingIntent = mockk() val pendingIntent: PendingIntent = mockk()
val slice: Slice = mockk() prepareForCompatibleCipherInlinePresentation(
every { iconRes = iconRes,
UiVersions.getVersions(testStyle) icon = icon,
} returns listOf(UiVersions.INLINE_UI_VERSION_1) pendingIntent = pendingIntent,
every { isSystemDarkMode = false,
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 // Test
val actual = inlinePresentationSpec.createCipherInlinePresentationOrNull( val actual = inlinePresentationSpec.createCipherInlinePresentationOrNull(
@ -191,7 +163,7 @@ class InlinePresentationSpecExtensionsTest {
) )
// Verify not-null because we can't properly mock Intent constructors. // Verify not-null because we can't properly mock Intent constructors.
Assertions.assertNotNull(actual) assertNotNull(actual)
verify(exactly = 1) { verify(exactly = 1) {
UiVersions.getVersions(testStyle) UiVersions.getVersions(testStyle)
PendingIntent.getService( PendingIntent.getService(
@ -204,7 +176,7 @@ class InlinePresentationSpecExtensionsTest {
testContext.getColor(R.color.on_surface) testContext.getColor(R.color.on_surface)
Icon.createWithResource( Icon.createWithResource(
testContext, testContext,
R.drawable.ic_login_item, iconRes,
) )
.setTint(ICON_TINT) .setTint(ICON_TINT)
InlineSuggestionUi.newContentBuilder(pendingIntent) InlineSuggestionUi.newContentBuilder(pendingIntent)
@ -215,11 +187,196 @@ class InlinePresentationSpecExtensionsTest {
.slice .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_NAME = "Cipher1"
private const val AUTOFILL_CIPHER_SUBTITLE = "Subtitle" private const val AUTOFILL_CIPHER_SUBTITLE = "Subtitle"
private const val ICON_TINT: Int = 6123751 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_CODE: Int = 0
private const val PENDING_INTENT_FLAGS: Int = private const val PENDING_INTENT_FLAGS: Int =
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
private const val VAULT_IS_LOCKED = "Vault is locked"