From 1a12a91a74243accc8f7b10bdb5a0aaff36cb86c Mon Sep 17 00:00:00 2001 From: Ramsey Smith <142836716+ramsey-livefront@users.noreply.github.com> Date: Thu, 4 Apr 2024 19:36:59 -0600 Subject: [PATCH] BIT-1469: Autofill talkback (#1226) --- .../ui/autofill/BitwardenRemoteViews.kt | 14 ++ .../ui/autofill/util/AutofillUtils.kt | 41 ++++++ .../util/InlinePresentationSpecExtensions.kt | 13 ++ .../main/res/values/strings_non_localized.xml | 1 + .../ui/autofill/BitwardenRemoteViewsTest.kt | 21 +++ .../ui/autofill/util/AutofillUtilsTest.kt | 121 ++++++++++++++++++ .../InlinePresentationSpecExtensionsTest.kt | 44 ++++++- 7 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/AutofillUtils.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/AutofillUtilsTest.kt 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 135a376f6..8d30a279a 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 @@ -6,6 +6,7 @@ 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 +import com.x8bit.bitwarden.ui.autofill.util.getAutofillSuggestionContentDescription import com.x8bit.bitwarden.ui.autofill.util.isSystemDarkMode /** @@ -21,6 +22,10 @@ fun buildAutofillRemoteViews( subtitle = autofillCipher.subtitle, iconRes = autofillCipher.iconRes, shouldTintIcon = true, + autofillContentDescription = getAutofillSuggestionContentDescription( + autofillCipher = autofillCipher, + autofillAppInfo = autofillAppInfo, + ), ) /** @@ -42,10 +47,13 @@ fun buildVaultItemAutofillRemoteViews( }, iconRes = R.drawable.icon, shouldTintIcon = false, + autofillContentDescription = null, ) +@Suppress("LongParameterList") private fun buildAutofillRemoteViews( autofillAppInfo: AutofillAppInfo, + autofillContentDescription: String?, name: String, subtitle: String, @DrawableRes iconRes: Int, @@ -56,6 +64,12 @@ private fun buildAutofillRemoteViews( R.layout.autofill_remote_view, ) .apply { + autofillContentDescription?.let { + setContentDescription( + R.id.container, + it, + ) + } setTextViewText( R.id.title, name, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/AutofillUtils.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/AutofillUtils.kt new file mode 100644 index 000000000..df37afe3c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/util/AutofillUtils.kt @@ -0,0 +1,41 @@ +package com.x8bit.bitwarden.ui.autofill.util + +import androidx.core.content.ContextCompat +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo +import com.x8bit.bitwarden.data.autofill.model.AutofillCipher + +/** + * Creates content description for an autofill suggestion given an [AutofillCipher] and + * [AutofillAppInfo]. + */ +fun getAutofillSuggestionContentDescription( + autofillCipher: AutofillCipher, + autofillAppInfo: AutofillAppInfo, +): String = + String.format( + "%s, %s, %s, %s", + ContextCompat.getString(autofillAppInfo.context, R.string.autofill_suggestion), + getAutofillSuggestionCipherType( + autofillCipher = autofillCipher, + autofillAppInfo = autofillAppInfo, + ), + autofillCipher.name, + autofillCipher.subtitle, + ) + +private fun getAutofillSuggestionCipherType( + autofillCipher: AutofillCipher, + autofillAppInfo: AutofillAppInfo, +): String = + when (autofillCipher) { + is AutofillCipher.Card -> ContextCompat.getString( + autofillAppInfo.context, + R.string.type_card, + ) + + is AutofillCipher.Login -> ContextCompat.getString( + autofillAppInfo.context, + R.string.type_login, + ) + } 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 6817282f9..c3735615f 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 @@ -35,6 +35,7 @@ fun InlinePresentationSpec.createCipherInlinePresentationOrNull( PendingIntent.FLAG_IMMUTABLE, ), autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, title = autofillCipher.name, subtitle = autofillCipher.subtitle, iconRes = autofillCipher.iconRes, @@ -54,6 +55,7 @@ fun InlinePresentationSpec.createVaultItemInlinePresentationOrNull( createInlinePresentationOrNull( pendingIntent = pendingIntent, autofillAppInfo = autofillAppInfo, + autofillCipher = null, title = autofillAppInfo.context.getString(R.string.app_name), subtitle = if (isLocked) { autofillAppInfo.context.getString(R.string.vault_is_locked) @@ -70,6 +72,7 @@ fun InlinePresentationSpec.createVaultItemInlinePresentationOrNull( private fun InlinePresentationSpec.createInlinePresentationOrNull( pendingIntent: PendingIntent, autofillAppInfo: AutofillAppInfo, + autofillCipher: AutofillCipher?, title: String, subtitle: String, @DrawableRes iconRes: Int, @@ -96,6 +99,16 @@ private fun InlinePresentationSpec.createInlinePresentationOrNull( } val slice = InlineSuggestionUi .newContentBuilder(pendingIntent) + .also { contentBuilder -> + autofillCipher?.let { + contentBuilder.setContentDescription( + getAutofillSuggestionContentDescription( + autofillAppInfo = autofillAppInfo, + autofillCipher = it, + ), + ) + } + } .setTitle(title) .setSubtitle(subtitle) .setStartIcon(icon) diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml index 01220718c..88e54f210 100644 --- a/app/src/main/res/values/strings_non_localized.xml +++ b/app/src/main/res/values/strings_non_localized.xml @@ -14,4 +14,5 @@ Give Feedback Password Protected This password will be used to export and import this file + Autofill suggestion 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 29ef80a69..83c59f6aa 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 @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.autofill import android.content.Context import android.widget.RemoteViews +import androidx.core.content.ContextCompat import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillCipher @@ -45,12 +46,14 @@ class BitwardenRemoteViewsTest { @BeforeEach fun setup() { + mockkStatic(ContextCompat::getString) mockkStatic(Context::isSystemDarkMode) mockkConstructor(RemoteViews::class) } @AfterEach fun teardown() { + unmockkStatic(ContextCompat::getString) unmockkStatic(Context::isSystemDarkMode) unmockkConstructor(RemoteViews::class) } @@ -59,6 +62,15 @@ class BitwardenRemoteViewsTest { fun `buildAutofillRemoteViews should set values and light mode colors when not night mode`() { // Setup every { testContext.isSystemDarkMode } returns false + every { + ContextCompat.getString(testContext, R.string.autofill_suggestion) + } returns "Autofill suggestion" + every { + ContextCompat.getString(testContext, R.string.type_card) + } returns "Card" + every { + ContextCompat.getString(testContext, R.string.type_login) + } returns "Login" prepareRemoteViews( name = NAME, subtitle = SUBTITLE, @@ -123,6 +135,15 @@ class BitwardenRemoteViewsTest { fun `buildAutofillRemoteViews should set values and dark mode colors when night mode`() { // Setup every { testContext.isSystemDarkMode } returns true + every { + ContextCompat.getString(testContext, R.string.autofill_suggestion) + } returns "Autofill suggestion" + every { + ContextCompat.getString(testContext, R.string.type_card) + } returns "Card" + every { + ContextCompat.getString(testContext, R.string.type_login) + } returns "Login" prepareRemoteViews( name = NAME, subtitle = SUBTITLE, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/AutofillUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/AutofillUtilsTest.kt new file mode 100644 index 000000000..9450c447b --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/util/AutofillUtilsTest.kt @@ -0,0 +1,121 @@ +package com.x8bit.bitwarden.ui.autofill.util + +import android.content.Context +import androidx.core.content.ContextCompat +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo +import com.x8bit.bitwarden.data.autofill.model.AutofillCipher +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import 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 AutofillUtilsTest { + private val context: Context = mockk() + + @BeforeEach + fun setup() { + mockkStatic(ContextCompat::getString) + every { + ContextCompat.getString(context, R.string.autofill_suggestion) + } returns "Autofill suggestion" + every { + ContextCompat.getString(context, R.string.type_card) + } returns "Card" + every { + ContextCompat.getString(context, R.string.type_login) + } returns "Login" + } + + @AfterEach + fun tearDown() { + unmockkStatic( + ContextCompat::getString, + ) + } + + @Test + fun `getAutofillSuggestionContentDescription should return correct content description`() { + listOf( + Triple( + first = AutofillCipher.Card( + cardholderName = "John", + cipherId = null, + code = "code", + expirationMonth = "expirationMonth", + expirationYear = "expirationYear", + name = "Cipher One", + number = "number", + subtitle = "Subtitle", + ), + second = AutofillAppInfo( + context = context, + packageName = "com.x8bit.bitwarden", + sdkInt = 34, + ), + third = "Autofill suggestion, Card, Cipher One, Subtitle", + ), + Triple( + first = AutofillCipher.Card( + cardholderName = "John", + cipherId = null, + code = "code", + expirationMonth = "expirationMonth", + expirationYear = "expirationYear", + name = "Capital One", + number = "number", + subtitle = "JohnCardName", + ), + second = AutofillAppInfo( + context = context, + packageName = "com.x8bit.bitwarden", + sdkInt = 34, + ), + third = "Autofill suggestion, Card, Capital One, JohnCardName", + ), + Triple( + first = AutofillCipher.Login( + cipherId = null, + isTotpEnabled = false, + name = "Cipher One", + password = "password", + username = "username", + subtitle = "Subtitle", + ), + second = AutofillAppInfo( + context = context, + packageName = "com.x8bit.bitwarden", + sdkInt = 34, + ), + third = "Autofill suggestion, Login, Cipher One, Subtitle", + ), + Triple( + first = AutofillCipher.Login( + cipherId = null, + isTotpEnabled = false, + name = "Amazon", + password = "password", + username = "username", + subtitle = "AmazonSubtitle", + ), + second = AutofillAppInfo( + context = context, + packageName = "com.x8bit.bitwarden", + sdkInt = 34, + ), + third = "Autofill suggestion, Login, Amazon, AmazonSubtitle", + ), + ) + .forEach { + val result = getAutofillSuggestionContentDescription( + autofillCipher = it.first, + autofillAppInfo = it.second, + ) + assertEquals(it.third, result) + } + } +} 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 69c2fcae6..910dc17f4 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 @@ -10,6 +10,7 @@ import android.os.Bundle import android.widget.inline.InlinePresentationSpec import androidx.autofill.inline.UiVersions import androidx.autofill.inline.v1.InlineSuggestionUi +import androidx.core.content.ContextCompat import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillCipher @@ -36,6 +37,7 @@ class InlinePresentationSpecExtensionsTest { @BeforeEach fun setup() { + mockkStatic(ContextCompat::getString) mockkStatic(Context::isSystemDarkMode) mockkStatic(Icon::class) mockkStatic(InlineSuggestionUi::class) @@ -45,7 +47,8 @@ class InlinePresentationSpecExtensionsTest { @AfterEach fun teardown() { - mockkStatic(Context::isSystemDarkMode) + unmockkStatic(ContextCompat::getString) + unmockkStatic(Context::isSystemDarkMode) unmockkStatic(Icon::class) unmockkStatic(InlineSuggestionUi::class) unmockkStatic(PendingIntent::getService) @@ -103,6 +106,7 @@ class InlinePresentationSpecExtensionsTest { icon = icon, pendingIntent = pendingIntent, isSystemDarkMode = true, + cipherType = CARD, ) // Test @@ -141,6 +145,15 @@ class InlinePresentationSpecExtensionsTest { @Test fun `createCipherInlinePresentationOrNull should return presentation with login icon when login cipher and compatible`() { // Setup + every { + ContextCompat.getString(testContext, R.string.autofill_suggestion) + } returns AUTOFILL_SUGGESTION + every { + ContextCompat.getString(testContext, R.string.type_card) + } returns CARD + every { + ContextCompat.getString(testContext, R.string.type_login) + } returns LOGIN val icon: Icon = mockk() val iconRes = R.drawable.ic_login_item val autofillCipher: AutofillCipher.Login = mockk { @@ -297,7 +310,17 @@ class InlinePresentationSpecExtensionsTest { icon: Icon, pendingIntent: PendingIntent, isSystemDarkMode: Boolean, + cipherType: String = LOGIN, ) { + every { + ContextCompat.getString(testContext, R.string.autofill_suggestion) + } returns AUTOFILL_SUGGESTION + every { + ContextCompat.getString(testContext, R.string.type_card) + } returns CARD + every { + ContextCompat.getString(testContext, R.string.type_login) + } returns LOGIN val slice: Slice = mockk() every { UiVersions.getVersions(testStyle) @@ -320,6 +343,19 @@ class InlinePresentationSpecExtensionsTest { ) .setTint(ICON_TINT) } returns icon + every { + InlineSuggestionUi + .newContentBuilder(pendingIntent) + .setContentDescription( + createMockContentDescription(cipherType), + ) + .setTitle(AUTOFILL_CIPHER_NAME) + .setSubtitle(AUTOFILL_CIPHER_SUBTITLE) + .setStartIcon(icon) + .build() + .slice + } returns slice + every { InlineSuggestionUi .newContentBuilder(pendingIntent) @@ -371,6 +407,12 @@ class InlinePresentationSpecExtensionsTest { } } +private fun createMockContentDescription(cipherType: String): String = + "${AUTOFILL_SUGGESTION}, $cipherType, ${AUTOFILL_CIPHER_NAME}, ${AUTOFILL_CIPHER_SUBTITLE}" + +private const val AUTOFILL_SUGGESTION = "Autofill suggestion" +private const val CARD = "Card" +private const val LOGIN = "Login" private const val APP_NAME = "Bitwarden" private const val AUTOFILL_CIPHER_NAME = "Cipher1" private const val AUTOFILL_CIPHER_SUBTITLE = "Subtitle"