From 4756040c4af4490cef365a1027de109691019d88 Mon Sep 17 00:00:00 2001 From: Dave Severns <149429124+dseverns-livefront@users.noreply.github.com> Date: Mon, 14 Oct 2024 10:26:11 -0400 Subject: [PATCH] PM-13471 Remove instances deprecated ClickableText (#4076) --- .../feature/checkemail/CheckEmailScreen.kt | 60 +++----- .../StartRegistrationScreen.kt | 104 ++++---------- .../bitwarden/ui/platform/base/util/Text.kt | 95 +++++++++++- .../checkemail/CheckEmailScreenTest.kt | 34 +++-- .../base/util/ClickableAnnotatedStringTest.kt | 136 ++++++++++++++++++ .../bitwarden/ui/util/ComposeTestHelpers.kt | 33 +++++ 6 files changed, 325 insertions(+), 137 deletions(-) create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/ClickableAnnotatedStringTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt index 29e64e42d..17cd2e4e5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text @@ -27,10 +26,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.onClick -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -40,8 +36,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.auth.feature.checkemail.handlers.rememberCheckEmailHandler +import com.x8bit.bitwarden.ui.platform.base.util.ClickableTextHighlight import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString +import com.x8bit.bitwarden.ui.platform.base.util.createClickableAnnotatedString import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton @@ -52,8 +50,6 @@ import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme -private const val TAG_URL = "URL" - /** * Top level composable for the check email screen. */ @@ -282,54 +278,34 @@ private fun CheckEmailLegacyContent( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { - val goBackAnnotatedString = createAnnotatedString( + val goBackAnnotatedString = createClickableAnnotatedString( mainString = stringResource( id = R.string.no_email_go_back_to_edit_your_email_address, ), - highlights = listOf(stringResource(id = R.string.go_back)), - tag = TAG_URL, + highlights = listOf( + ClickableTextHighlight( + textToHighlight = stringResource(id = R.string.go_back), + onTextClick = onChangeEmailClick, + ), + ), ) - ClickableText( + Text( text = goBackAnnotatedString, - onClick = { - goBackAnnotatedString - .getStringAnnotations(TAG_URL, it, it) - .firstOrNull()?.let { - onChangeEmailClick() - } - }, - modifier = Modifier.semantics { - role = Role.Button - onClick { - onChangeEmailClick() - true - } - }, ) Spacer(modifier = Modifier.height(32.dp)) - val logInAnnotatedString = createAnnotatedString( + val logInAnnotatedString = createClickableAnnotatedString( mainString = stringResource( id = R.string.or_log_in_you_may_already_have_an_account, ), - highlights = listOf(stringResource(id = R.string.log_in)), - tag = TAG_URL, + highlights = listOf( + ClickableTextHighlight( + textToHighlight = stringResource(id = R.string.log_in), + onTextClick = onLoginClick, + ), + ), ) - ClickableText( + Text( text = logInAnnotatedString, - onClick = { - logInAnnotatedString - .getStringAnnotations(TAG_URL, it, it) - .firstOrNull()?.let { - onLoginClick() - } - }, - modifier = Modifier.semantics { - role = Role.Button - onClick { - onLoginClick() - true - } - }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt index 867e1188f..070cb86dd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt @@ -18,10 +18,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Switch +import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.ripple @@ -41,9 +41,6 @@ import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.toggleableState import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -59,9 +56,10 @@ import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEv import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToTerms import com.x8bit.bitwarden.ui.auth.feature.startregistration.handlers.StartRegistrationHandler import com.x8bit.bitwarden.ui.auth.feature.startregistration.handlers.rememberStartRegistrationHandler +import com.x8bit.bitwarden.ui.platform.base.util.ClickableTextHighlight import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.asText -import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString +import com.x8bit.bitwarden.ui.platform.base.util.createClickableAnnotatedString import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton @@ -313,54 +311,22 @@ private fun TermsAndPrivacyText( ) { val strTerms = stringResource(id = R.string.terms_of_service) val strPrivacy = stringResource(id = R.string.privacy_policy) - val annotatedLinkString: AnnotatedString = buildAnnotatedString { - val strTermsAndPrivacy = stringResource( - id = R.string.by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy, - ) - val startIndexTerms = strTermsAndPrivacy.indexOf(strTerms) - val endIndexTerms = startIndexTerms + strTerms.length - val startIndexPrivacy = strTermsAndPrivacy.indexOf(strPrivacy) - val endIndexPrivacy = startIndexPrivacy + strPrivacy.length - append(strTermsAndPrivacy) - addStyle( - style = SpanStyle( - color = BitwardenTheme.colorScheme.text.primary, - fontSize = BitwardenTheme.typography.bodyMedium.fontSize, + val strTermsAndPrivacy = stringResource( + id = R.string.by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy, + ) + val annotatedLinkString: AnnotatedString = createClickableAnnotatedString( + mainString = strTermsAndPrivacy, + highlights = listOf( + ClickableTextHighlight( + textToHighlight = strTerms, + onTextClick = onTermsClick, ), - start = 0, - end = strTermsAndPrivacy.length, - ) - addStyle( - style = SpanStyle( - color = BitwardenTheme.colorScheme.text.interaction, - fontSize = BitwardenTheme.typography.bodyMedium.fontSize, - fontWeight = FontWeight.Bold, + ClickableTextHighlight( + textToHighlight = strPrivacy, + onTextClick = onPrivacyPolicyClick, ), - start = startIndexTerms, - end = endIndexTerms, - ) - addStyle( - style = SpanStyle( - color = BitwardenTheme.colorScheme.text.interaction, - fontSize = BitwardenTheme.typography.bodyMedium.fontSize, - fontWeight = FontWeight.Bold, - ), - start = startIndexPrivacy, - end = endIndexPrivacy, - ) - addStringAnnotation( - tag = TAG_URL, - annotation = strTerms, - start = startIndexTerms, - end = endIndexTerms, - ) - addStringAnnotation( - tag = TAG_URL, - annotation = strPrivacy, - start = startIndexPrivacy, - end = endIndexPrivacy, - ) - } + ), + ) Row( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, @@ -387,23 +353,11 @@ private fun TermsAndPrivacyText( .padding(horizontal = 16.dp) .fillMaxWidth(), ) { - val termsUrl = stringResource(id = R.string.terms_of_service) - ClickableText( + Text( text = annotatedLinkString, style = BitwardenTheme.typography.bodyMedium.copy( textAlign = TextAlign.Center, ), - onClick = { - annotatedLinkString - .getStringAnnotations(TAG_URL, it, it) - .firstOrNull()?.let { stringAnnotation -> - if (stringAnnotation.item == termsUrl) { - onTermsClick() - } else { - onPrivacyPolicyClick() - } - } - }, ) } } @@ -419,14 +373,13 @@ private fun ReceiveMarketingEmailsSwitch( val unsubscribeString = stringResource(id = R.string.unsubscribe) @Suppress("MaxLineLength") - val annotatedLinkString = createAnnotatedString( + val annotatedLinkString = createClickableAnnotatedString( mainString = stringResource(id = R.string.get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time), - highlights = listOf(unsubscribeString), - tag = TAG_URL, - highlightStyle = SpanStyle( - color = BitwardenTheme.colorScheme.text.interaction, - fontSize = BitwardenTheme.typography.bodyMedium.fontSize, - fontWeight = FontWeight.Bold, + highlights = listOf( + ClickableTextHighlight( + textToHighlight = unsubscribeString, + onTextClick = onUnsubscribeClick, + ), ), ) Row( @@ -464,16 +417,9 @@ private fun ReceiveMarketingEmailsSwitch( colors = bitwardenSwitchColors(), ) Spacer(modifier = Modifier.width(16.dp)) - ClickableText( + Text( text = annotatedLinkString, style = BitwardenTheme.typography.bodyMedium, - onClick = { - annotatedLinkString - .getStringAnnotations(TAG_URL, it, it) - .firstOrNull()?.let { - onUnsubscribeClick() - } - }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt index 5eb97f78a..24cfc9fec 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt @@ -8,7 +8,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme @@ -151,19 +153,100 @@ fun createAnnotatedString( end = mainString.length, ) for (highlightString in highlights) { - val startIndexUnsubscribe = mainString.indexOf(highlightString, ignoreCase = true) - val endIndexUnsubscribe = startIndexUnsubscribe + highlightString.length + val startIndex = mainString.indexOf(highlightString, ignoreCase = true) + val endIndex = startIndex + highlightString.length addStyle( style = highlightStyle, - start = startIndexUnsubscribe, - end = endIndexUnsubscribe, + start = startIndex, + end = endIndex, ) addStringAnnotation( tag = tag, annotation = highlightString, - start = startIndexUnsubscribe, - end = endIndexUnsubscribe, + start = startIndex, + end = endIndex, ) } } } + +/** + * Create an [AnnotatedString] with highlighted parts that can be clicked. + * @param mainString the full string to be processed. + * @param highlights list of [ClickableTextHighlight]s to be annotated within the [mainString]. + * If a highlighted text is repeated in the [mainString], you must choose which instance to use + * by setting the [ClickableTextHighlight.instance] property. Only one instance of the text will + * be annotated. + */ +@Composable +fun createClickableAnnotatedString( + mainString: String, + highlights: List, + style: SpanStyle = SpanStyle( + color = BitwardenTheme.colorScheme.text.primary, + fontSize = BitwardenTheme.typography.bodyMedium.fontSize, + ), + highlightStyle: SpanStyle = SpanStyle( + color = BitwardenTheme.colorScheme.text.interaction, + fontSize = BitwardenTheme.typography.bodyMedium.fontSize, + fontWeight = FontWeight.Bold, + ), +): AnnotatedString { + return buildAnnotatedString { + append(mainString) + addStyle( + style = style, + start = 0, + end = mainString.length, + ) + for (highlight in highlights) { + val text = highlight.textToHighlight + val startIndex = when (highlight.instance) { + ClickableTextHighlight.Instance.FIRST -> { + mainString.indexOf(text, ignoreCase = true) + } + + ClickableTextHighlight.Instance.LAST -> { + mainString.lastIndexOf(text, ignoreCase = true) + } + } + val endIndex = startIndex + highlight.textToHighlight.length + val link = LinkAnnotation.Clickable( + tag = highlight.textToHighlight, + styles = TextLinkStyles( + style = highlightStyle, + ), + ) { + highlight.onTextClick.invoke() + } + addLink( + link, + start = startIndex, + end = endIndex, + ) + } + } +} + +/** + * Models text that should be highlighted with and associated with a click action. + * @property textToHighlight the text to highlight and associate with click action. + * @property onTextClick the click action to perform when the text is clicked. + * @property instance to denote if there are multiple instances of the [textToHighlight] in the + * [AnnotatedString] which should be highlighted. + */ +data class ClickableTextHighlight( + val textToHighlight: String, + val onTextClick: () -> Unit, + val instance: Instance = Instance.FIRST, +) { + /** + * To denote if a [ClickableTextHighlight.textToHighlight] should highlight the + * first instance of the text or the last instance. + * "If you ain't first, you're last" == true + */ + enum class Instance { + FIRST, + LAST, + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreenTest.kt index 238b65141..94a451941 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreenTest.kt @@ -1,14 +1,15 @@ package com.x8bit.bitwarden.ui.auth.feature.checkemail -import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo -import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.printToLog import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.util.assertLinkAnnotationIsAppliedAndInvokeClickAction import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -90,10 +91,17 @@ class CheckEmailScreenTest : BaseComposeTest() { @Test fun `go back and update email text click should send ChangeEmailClick action`() { mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = false) - composeTestRule - .onNodeWithText("No email? Go back to edit your email address.") - .performScrollTo() - .performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.onRoot().printToLog("oh shit") + val mainString = "No email? Go back to edit your email address." + val linkText = "Go back" + val expectedStart = mainString.indexOf(linkText) + val expectedEnd = expectedStart + linkText.length + composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction( + mainString = mainString, + highLightText = linkText, + expectedStart = expectedStart, + expectedEnd = expectedEnd, + ) verify { viewModel.trySendAction(CheckEmailAction.ChangeEmailClick) } } @@ -101,10 +109,16 @@ class CheckEmailScreenTest : BaseComposeTest() { @Test fun `already have account text click should send ChangeEmailClick action`() { mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = false) - composeTestRule - .onNodeWithText("Or log in, you may already have an account.") - .performScrollTo() - .performSemanticsAction(SemanticsActions.OnClick) + val mainString = "Or log in, you may already have an account." + val linkText = "log in" + val expectedStart = mainString.indexOf(linkText) + val expectedEnd = expectedStart + linkText.length + composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction( + mainString = mainString, + highLightText = linkText, + expectedStart = expectedStart, + expectedEnd = expectedEnd, + ) verify { viewModel.trySendAction(CheckEmailAction.LoginClick) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/ClickableAnnotatedStringTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/ClickableAnnotatedStringTest.kt new file mode 100644 index 000000000..eb4ded202 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/ClickableAnnotatedStringTest.kt @@ -0,0 +1,136 @@ +package com.x8bit.bitwarden.ui.platform.base.util + +import androidx.compose.material3.Text +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.util.assertLinkAnnotationIsAppliedAndInvokeClickAction +import org.junit.Assert.assertTrue +import org.junit.Test + +class ClickableAnnotatedStringTest : BaseComposeTest() { + @Suppress("MaxLineLength") + @Test + fun `clickable annotated string should add Clickable LinkAnnotation to highlighted string`() { + var textClickCalled = false + val mainString = "This is me testing the thing." + val highLightText = "testing" + composeTestRule.setContent { + val annotatedString = createClickableAnnotatedString( + mainString, + listOf( + ClickableTextHighlight( + textToHighlight = highLightText, + onTextClick = { textClickCalled = true }, + ), + ), + ) + Text(text = annotatedString) + } + val expectedStart = mainString.indexOf(highLightText) + val expectedEnd = expectedStart + highLightText.length + composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction( + mainString, + highLightText, + expectedStart, + expectedEnd, + ) + assertTrue(textClickCalled) + } + + @Suppress("MaxLineLength") + @Test + fun `clickable annotated string should add multiple Clickable LinkAnnotations to highlighted string`() { + val mainString = "This is me testing the thing." + val highLightText1 = "testing" + val highlightText2 = "thing" + composeTestRule.setContent { + val annotatedString = createClickableAnnotatedString( + mainString, + listOf( + ClickableTextHighlight( + textToHighlight = highLightText1, + onTextClick = {}, + ), + ClickableTextHighlight( + textToHighlight = highlightText2, + onTextClick = {}, + ), + ), + ) + Text(text = annotatedString) + } + val expectedStart1 = mainString.indexOf(highLightText1) + val expectedEnd1 = expectedStart1 + highLightText1.length + val expectedStart2 = mainString.indexOf(highlightText2) + val expectedEnd2 = expectedStart2 + highlightText2.length + composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction( + mainString, + highLightText1, + expectedStart1, + expectedEnd1, + ) + composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction( + mainString, + highlightText2, + expectedStart2, + expectedEnd2, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `clickable annotated string should add annotation to first instance of highlighted string`() { + val mainString = "Testing 1,2,3 testing" + val highLightText = "testing" + composeTestRule.setContent { + val annotatedString = createClickableAnnotatedString( + mainString, + listOf( + ClickableTextHighlight( + textToHighlight = highLightText, + onTextClick = {}, + instance = ClickableTextHighlight.Instance.FIRST, + ), + ), + ) + Text(text = annotatedString) + } + // indexOf returns the index of the first instance. + val expectedStart = mainString.indexOf(highLightText) + val expectedEnd = expectedStart + highLightText.length + composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction( + mainString, + highLightText, + expectedStart, + expectedEnd, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `clickable annotated string should add annotation to last instance of highlighted string`() { + val mainString = "Testing 1,2,3 testing" + val highLightText = "testing" + composeTestRule.setContent { + val annotatedString = createClickableAnnotatedString( + mainString, + listOf( + ClickableTextHighlight( + textToHighlight = highLightText, + onTextClick = {}, + instance = ClickableTextHighlight.Instance.FIRST, + ), + ), + ) + Text(text = annotatedString) + } + // indexOf returns the index of the first instance. + val expectedStart = mainString.lastIndexOf(highLightText) + val expectedEnd = expectedStart + highLightText.length + composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction( + mainString, + highLightText, + expectedStart, + expectedEnd, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt index 7ff0e5b41..bb836c3a6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.test.hasScrollToNodeAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onFirst @@ -21,6 +22,9 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.printToString +import androidx.compose.ui.text.LinkAnnotation +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.jupiter.api.assertThrows /** @@ -173,3 +177,32 @@ fun SemanticsNodeInteraction.performCustomAccessibilityAction(label: String) { ) } } + +/** + * Helper function to assert link annotation is applied to the given text in + * the [mainString] and invoke click action if it is found. + */ +fun ComposeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction( + mainString: String, + highLightText: String, + expectedStart: Int, + expectedEnd: Int, +) { + this + .onNodeWithText(mainString) + .fetchSemanticsNode() + .config + .getOrNull(SemanticsProperties.Text) + ?.let { text -> + text.forEach { + it.getLinkAnnotations(expectedStart, expectedEnd) + .forEach { annotationRange -> + val annotation = annotationRange.item as? LinkAnnotation.Clickable + val tag = annotation?.tag + assertNotNull(tag) + assertTrue(highLightText.equals(tag, ignoreCase = true)) + annotation?.linkInteractionListener?.onClick(annotation) + } + } + } +}