diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8cf63df22..2d2519c10 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,7 +26,7 @@ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt index 79aa111f9..5cc5d23a3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt @@ -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 { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt index dccc29c62..fb7f8d0bd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt @@ -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, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledData.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledData.kt index faddbc171..06329183d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledData.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/FilledData.kt @@ -10,5 +10,8 @@ import android.widget.inline.InlinePresentationSpec data class FilledData( val filledPartitions: List, val ignoreAutofillIds: List, + val originalPartition: AutofillPartition, + val uri: String?, val vaultItemInlinePresentationSpec: InlinePresentationSpec?, + val isVaultLocked: Boolean, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensions.kt new file mode 100644 index 000000000..f614a6276 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensions.kt @@ -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 + 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, + 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, + 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 + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViews.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViews.kt index cecdde36d..135a376f6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViews.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViews.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensions.kt index b035918c8..6817282f9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensions.kt @@ -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 diff --git a/app/src/main/res/drawable/icon.xml b/app/src/main/res/drawable/icon.xml new file mode 100644 index 000000000..92f426800 --- /dev/null +++ b/app/src/main/res/drawable/icon.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/values-v30/manifest.xml b/app/src/main/res/values-v30/manifest.xml new file mode 100644 index 000000000..a4719c27c --- /dev/null +++ b/app/src/main/res/values-v30/manifest.xml @@ -0,0 +1,5 @@ + + + + 0 + diff --git a/app/src/main/res/values/manifest.xml b/app/src/main/res/values/manifest.xml new file mode 100644 index 000000000..b866c4aa5 --- /dev/null +++ b/app/src/main/res/values/manifest.xml @@ -0,0 +1,5 @@ + + + + 2 + diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt index 8ad696c68..a2132190c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt @@ -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().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 { it.addDataset(dataset) } + every { + filledData.buildVaultItemDataset( + autofillAppInfo = appInfo, + ) + } returns vaultItemDataSet + mockBuilder { + it.addDataset(dataset) + it.addDataset(vaultItemDataSet) + } mockBuilder { it.setIgnoredIds( ignoredAutofillIdOne, @@ -139,7 +157,11 @@ class FillResponseBuilderTest { filledPartitionOne.buildDataset( autofillAppInfo = appInfo, ) + filledData.buildVaultItemDataset( + autofillAppInfo = appInfo, + ) anyConstructed().addDataset(dataset) + anyConstructed().addDataset(vaultItemDataSet) anyConstructed().setIgnoredIds( ignoredAutofillIdOne, ignoredAutofillIdTwo, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt index f1e2999e0..784c070ce 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt @@ -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( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt index 9660c6616..e448f1057 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt @@ -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() diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensionsTest.kt new file mode 100644 index 000000000..0a369ab88 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensionsTest.kt @@ -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().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 { it.setAuthentication(mockIntentSender) } + mockBuilder { it.setInlinePresentation(inlinePresentation) } + mockBuilder { it.setMenuPresentation(remoteViews) } + every { + filledItemPlaceholder.applyToDatasetPostTiramisu( + datasetBuilder = any(), + presentations = presentations, + ) + } just runs + every { anyConstructed().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().setAuthentication(mockIntentSender) + anyConstructed().setInlinePresentation(inlinePresentation) + anyConstructed().setMenuPresentation(remoteViews) + anyConstructed().build() + filledItemPlaceholder.applyToDatasetPostTiramisu( + datasetBuilder = any(), + presentations = presentations, + ) + anyConstructed().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 { 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().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 { it.setAuthentication(mockIntentSender) } + mockBuilder { 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().setInlinePresentation(inlinePresentation) + filledItemPlaceholder.applyToDatasetPreTiramisu( + datasetBuilder = any(), + remoteViews = remoteViews, + ) + anyConstructed().build() + } + } + + companion object { + private const val CIPHER_NAME: String = "Autofill Cipher" + private const val PACKAGE_NAME: String = "com.x8bit.bitwarden" + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViewsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViewsTest.kt index 068b90186..29ef80a69 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViewsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/BitwardenRemoteViewsTest.kt @@ -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() - .setTextViewText( - R.id.title, - NAME, - ) - } just runs - every { - anyConstructed() - .setTextViewText( - R.id.subtitle, - SUBTITLE, - ) - } just runs - every { - anyConstructed() - .setImageViewResource( - R.id.icon, - ICON_RES, - ) - } just runs - every { - anyConstructed() - .setInt( - R.id.container, - "setBackgroundColor", - SURFACE_COLOR, - ) - } just runs - every { - anyConstructed() - .setInt( - R.id.icon, - "setColorFilter", - ON_SURFACE_COLOR, - ) - } just runs - every { - anyConstructed() - .setInt( - R.id.title, - "setTextColor", - ON_SURFACE_COLOR, - ) - } just runs - every { - anyConstructed() - .setInt( - R.id.subtitle, - "setTextColor", - ON_SURFACE_VARIANT_COLOR, - ) - } just runs + prepareRemoteViews( + name = NAME, + subtitle = SUBTITLE, + iconRes = ICON_RES, + ) // 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() - .setTextViewText( - R.id.title, - NAME, - ) - } just runs - every { - anyConstructed() - .setTextViewText( - R.id.subtitle, - SUBTITLE, - ) - } just runs - every { - anyConstructed() - .setImageViewResource( - R.id.icon, - ICON_RES, - ) - } just runs - every { - anyConstructed() - .setInt( - R.id.container, - "setBackgroundColor", - DARK_SURFACE_COLOR, - ) - } just runs - every { - anyConstructed() - .setInt( - R.id.icon, - "setColorFilter", - DARK_ON_SURFACE_COLOR, - ) - } just runs - every { - anyConstructed() - .setInt( - R.id.title, - "setTextColor", - DARK_ON_SURFACE_COLOR, - ) - } just runs - every { - anyConstructed() - .setInt( - R.id.subtitle, - "setTextColor", - DARK_ON_SURFACE_VARIANT_COLOR, - ) - } just runs + prepareRemoteViews( + name = NAME, + subtitle = SUBTITLE, + iconRes = ICON_RES, + ) // 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() + .setTextViewText( + R.id.title, + APP_NAME, + ) + anyConstructed() + .setTextViewText( + R.id.subtitle, + VAULT_IS_LOCKED, + ) + anyConstructed() + .setImageViewResource( + R.id.icon, + R.drawable.icon, + ) + anyConstructed() + .setInt( + R.id.container, + "setBackgroundColor", + SURFACE_COLOR, + ) + anyConstructed() + .setInt( + R.id.title, + "setTextColor", + ON_SURFACE_COLOR, + ) + anyConstructed() + .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() + .setTextViewText( + R.id.title, + APP_NAME, + ) + anyConstructed() + .setTextViewText( + R.id.subtitle, + GO_TO_MY_VAULT, + ) + anyConstructed() + .setImageViewResource( + R.id.icon, + R.drawable.icon, + ) + anyConstructed() + .setInt( + R.id.container, + "setBackgroundColor", + DARK_SURFACE_COLOR, + ) + anyConstructed() + .setInt( + R.id.title, + "setTextColor", + DARK_ON_SURFACE_COLOR, + ) + anyConstructed() + .setInt( + R.id.subtitle, + "setTextColor", + DARK_ON_SURFACE_VARIANT_COLOR, + ) + } + } + + private fun prepareRemoteViews( + name: String, + subtitle: String, + iconRes: Int, + ) { + every { + anyConstructed() + .setTextViewText( + R.id.title, + name, + ) + } just runs + every { + anyConstructed() + .setTextViewText( + R.id.subtitle, + subtitle, + ) + } just runs + every { + anyConstructed() + .setImageViewResource( + R.id.icon, + iconRes, + ) + } just runs + every { + anyConstructed() + .setInt( + R.id.container, + "setBackgroundColor", + DARK_SURFACE_COLOR, + ) + } just runs + every { + anyConstructed() + .setInt( + R.id.icon, + "setColorFilter", + DARK_ON_SURFACE_COLOR, + ) + } just runs + every { + anyConstructed() + .setInt( + R.id.icon, + "setColorFilter", + ON_SURFACE_COLOR, + ) + } just runs + every { + anyConstructed() + .setInt( + R.id.title, + "setTextColor", + DARK_ON_SURFACE_COLOR, + ) + } just runs + every { + anyConstructed() + .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" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensionsTest.kt index 1145f5167..69c2fcae6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/InlinePresentationSpecExtensionsTest.kt @@ -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(), + 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(), - 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 + prepareForCompatibleCipherInlinePresentation( + iconRes = iconRes, + icon = icon, + pendingIntent = pendingIntent, + isSystemDarkMode = true, + ) // 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(), - 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 + prepareForCompatibleCipherInlinePresentation( + iconRes = iconRes, + icon = icon, + pendingIntent = pendingIntent, + isSystemDarkMode = false, + ) // 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(), + 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"