From 8002794c59614555f86a0debcce7b664ee6afbbf Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Wed, 20 May 2026 11:15:20 -0400 Subject: [PATCH] [PM-37294] fix: Announce external-link affordance on premium upgrade CTAs (#6951) --- .../feature/premium/plan/PlanScreen.kt | 2 + .../feature/settings/SettingsScreen.kt | 1 + .../feature/generator/GeneratorScreen.kt | 1 + .../ui/tools/feature/send/SendContent.kt | 1 + .../ui/vault/feature/vault/VaultContent.kt | 1 + .../feature/premium/plan/PlanScreenTest.kt | 22 +++++++++ .../feature/settings/SettingsScreenTest.kt | 3 ++ .../feature/generator/GeneratorScreenTest.kt | 3 ++ .../ui/tools/feature/send/SendScreenTest.kt | 3 ++ .../ui/vault/feature/vault/VaultScreenTest.kt | 3 ++ .../components/card/BitwardenActionCard.kt | 4 ++ .../card/BitwardenActionCardTest.kt | 47 +++++++++++++++++++ 12 files changed, 91 insertions(+) create mode 100644 ui/src/test/kotlin/com/bitwarden/ui/platform/components/card/BitwardenActionCardTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt index fe87799d32..da5d6cf537 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt @@ -403,6 +403,7 @@ private fun PremiumContent( label = stringResource(id = BitwardenString.manage_plan), onClick = handlers.onManagePlanClick, icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link), + isExternalLink = true, modifier = Modifier .standardHorizontalMargin() .fillMaxWidth() @@ -415,6 +416,7 @@ private fun PremiumContent( label = stringResource(id = BitwardenString.cancel_premium), onClick = handlers.onCancelPremiumClick, icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link), + isExternalLink = true, modifier = Modifier .standardHorizontalMargin() .fillMaxWidth() diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt index 8afebea693..1c07295ba3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt @@ -108,6 +108,7 @@ fun SettingsScreen( id = BitwardenString.you_now_have_access_to_all_advanced_security_features, ), actionText = stringResource(id = BitwardenString.learn_more), + isExternalLink = true, leadingContent = { Icon( painter = rememberVectorPainter(id = BitwardenDrawable.ic_star), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt index bed0eb86e2..f311955728 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt @@ -398,6 +398,7 @@ private fun CoachMarkScope.ScrollContent( id = BitwardenString.you_now_have_access_to_all_advanced_security_features, ), actionText = stringResource(id = BitwardenString.learn_more), + isExternalLink = true, leadingContent = { Icon( painter = rememberVectorPainter(id = BitwardenDrawable.ic_star), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendContent.kt index c22a7c7191..139ce26c85 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendContent.kt @@ -55,6 +55,7 @@ fun SendContent( id = BitwardenString.you_now_have_access_to_all_advanced_security_features, ), actionText = stringResource(id = BitwardenString.learn_more), + isExternalLink = true, leadingContent = { Icon( painter = rememberVectorPainter(id = BitwardenDrawable.ic_star), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index c30e114ad6..e9ee0d2f72 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -576,6 +576,7 @@ private fun ActionCard( id = BitwardenString.you_now_have_access_to_all_advanced_security_features, ), actionText = stringResource(id = BitwardenString.learn_more), + isExternalLink = true, leadingContent = { Icon( painter = rememberVectorPainter(id = BitwardenDrawable.ic_star), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt index b6330633f6..188e716d64 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt @@ -748,6 +748,15 @@ class PlanScreenTest : BitwardenComposeTest() { verify { viewModel.trySendAction(PlanAction.ManagePlanClick) } } + @Test + fun `manage plan button should announce external-link affordance`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) } + composeTestRule + .onNodeWithContentDescription(label = "Manage plan, External link") + .performScrollTo() + .assertIsDisplayed() + } + @Test fun `cancel premium button should render when showCancelButton is true`() { mutableStateFlow.update { @@ -760,6 +769,19 @@ class PlanScreenTest : BitwardenComposeTest() { .assertExists() } + @Test + fun `cancel premium button should announce external-link affordance`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(showCancelButton = true), + ) + } + composeTestRule + .onNodeWithContentDescription(label = "Cancel Premium, External link") + .performScrollTo() + .assertIsDisplayed() + } + @Test fun `cancel premium button should not render when showCancelButton is false`() { mutableStateFlow.update { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt index f204787afd..8b0566385d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt @@ -254,6 +254,9 @@ class SettingsScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText(text = "Learn more") .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(label = "Learn more, External link") + .assertIsDisplayed() } @Test diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt index 01b82bf354..5ab5259fdd 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt @@ -1848,6 +1848,9 @@ class GeneratorScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText(text = "Learn more") .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(label = "Learn more, External link") + .assertIsDisplayed() } @Test diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt index 6ecaf0acc4..f07cd1e739 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt @@ -960,6 +960,9 @@ class SendScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText(text = "Learn more") .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(label = "Learn more, External link") + .assertIsDisplayed() } @Test diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 583f95bc87..5b928b96fb 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -1706,6 +1706,9 @@ class VaultScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText(text = "Learn more") .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(label = "Learn more, External link") + .assertIsDisplayed() } @Test diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/card/BitwardenActionCard.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/card/BitwardenActionCard.kt index 4c807ce7f0..97acb4c5cd 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/card/BitwardenActionCard.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/card/BitwardenActionCard.kt @@ -50,6 +50,8 @@ import com.bitwarden.ui.util.asText * @param cardSubtitle The subtitle of the card. * @param secondaryButton The optional data for a secondary button. * @param leadingContent Optional content to display on the leading side of the [cardTitle] [Text]. + * @param isExternalLink Whether the CTA launches an external link, which announces the + * external-link affordance to screen readers via the shared `external_link_format` string. */ @Suppress("LongMethod") @Composable @@ -62,6 +64,7 @@ fun BitwardenActionCard( cardSubtitle: String? = null, secondaryButton: BitwardenButtonData? = null, leadingContent: @Composable (() -> Unit)? = null, + isExternalLink: Boolean = false, ) { Card( modifier = modifier, @@ -133,6 +136,7 @@ fun BitwardenActionCard( BitwardenFilledButton( label = actionText, onClick = onActionClick, + isExternalLink = isExternalLink, modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/card/BitwardenActionCardTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/card/BitwardenActionCardTest.kt new file mode 100644 index 0000000000..c0935744bd --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/card/BitwardenActionCardTest.kt @@ -0,0 +1,47 @@ +package com.bitwarden.ui.platform.components.card + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import com.bitwarden.ui.platform.base.BaseComposeTest +import com.bitwarden.ui.platform.theme.BitwardenTheme +import org.junit.Test + +class BitwardenActionCardTest : BaseComposeTest() { + + @Test + fun `action button content description defaults to actionText`() { + setTestContent { + BitwardenTheme { + BitwardenActionCard( + cardTitle = "Title", + actionText = "Learn more", + onActionClick = {}, + ) + } + } + composeTestRule + .onNodeWithContentDescription(label = "Learn more") + .assertIsDisplayed() + } + + @Test + fun `action button announces external-link affordance when isExternalLink is true`() { + setTestContent { + BitwardenTheme { + BitwardenActionCard( + cardTitle = "Title", + actionText = "Learn more", + isExternalLink = true, + onActionClick = {}, + ) + } + } + composeTestRule + .onNodeWithContentDescription(label = "Learn more, External link") + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "Learn more") + .assertIsDisplayed() + } +}