PM-14333 Complete fix for crash caused by spannable text creation (#4479)

This commit is contained in:
Dave Severns 2024-12-20 16:45:55 -05:00 committed by GitHub
parent f32eecc0d7
commit 1148e4821c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 384 additions and 545 deletions

View File

@ -367,6 +367,7 @@ tasks {
maxHeapSize = "2g"
maxParallelForks = Runtime.getRuntime().availableProcessors()
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC"
android.sourceSets["main"].res.srcDirs("src/test/res")
}
}

View File

@ -35,11 +35,9 @@ 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.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
@ -152,18 +150,13 @@ private fun CheckEmailContent(
)
Spacer(modifier = Modifier.height(8.dp))
val descriptionAnnotatedString = createAnnotatedString(
mainString = stringResource(
id = R.string.we_sent_an_email_to,
email,
),
highlights = listOf(email),
highlightStyle = SpanStyle(
val descriptionAnnotatedString = R.string.we_sent_an_email_to.toAnnotatedString(
args = arrayOf(email),
emphasisHighlightStyle = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
),
tag = "EMAIL",
)
Text(
text = descriptionAnnotatedString,
@ -241,18 +234,14 @@ private fun CheckEmailLegacyContent(
Spacer(modifier = Modifier.height(16.dp))
@Suppress("MaxLineLength")
val descriptionAnnotatedString = createAnnotatedString(
mainString = stringResource(
id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account,
val descriptionAnnotatedString =
R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account.toAnnotatedString(
email,
),
highlights = listOf(email),
highlightStyle = SpanStyle(
emphasisHighlightStyle = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
),
tag = "EMAIL",
)
Text(
text = descriptionAnnotatedString,
@ -276,34 +265,17 @@ private fun CheckEmailLegacyContent(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val goBackAnnotatedString = createClickableAnnotatedString(
mainString = stringResource(
id = R.string.no_email_go_back_to_edit_your_email_address,
),
highlights = listOf(
ClickableTextHighlight(
textToHighlight = stringResource(id = R.string.go_back),
onTextClick = onChangeEmailClick,
),
),
)
Text(
text = goBackAnnotatedString,
text = R.string.no_email_go_back_to_edit_your_email_address.toAnnotatedString {
onChangeEmailClick()
},
)
Spacer(modifier = Modifier.height(32.dp))
val logInAnnotatedString = createClickableAnnotatedString(
mainString = stringResource(
id = R.string.or_log_in_you_may_already_have_an_account,
),
highlights = listOf(
ClickableTextHighlight(
textToHighlight = stringResource(id = R.string.log_in_verb),
onTextClick = onLoginClick,
),
),
)
Text(
text = logInAnnotatedString,
text = R.string.or_log_in_you_may_already_have_an_account
.toAnnotatedString {
onLoginClick()
},
)
}
}

View File

@ -24,8 +24,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.bitwardenBoldSpanStyle
import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
@ -130,6 +128,7 @@ private fun MasterPasswordGuidanceContent(
}
}
@Suppress("MaxLineLength")
@Composable
private fun MasterPasswordGuidanceContentBlocks(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
@ -138,46 +137,20 @@ private fun MasterPasswordGuidanceContentBlocks(modifier: Modifier = Modifier) {
ContentBlockData(
headerText = stringResource(R.string.choose_three_or_four_random_words)
.toAnnotatedString(),
subtitleText = createAnnotatedString(
mainString = stringResource(
R.string.pick_three_or_four_random_unrelated_words,
),
highlights = listOf(
stringResource(
R.string.pick_three_or_four_random_unrelated_words_highlight,
),
),
highlightStyle = bitwardenBoldSpanStyle,
),
subtitleText = R.string.pick_three_or_four_random_unrelated_words.toAnnotatedString(),
iconVectorResource = R.drawable.ic_number1,
),
ContentBlockData(
headerText = stringResource(R.string.combine_those_words_together)
.toAnnotatedString(),
subtitleText = createAnnotatedString(
mainString = stringResource(
R.string.put_the_words_together_in_any_order_to_form_your_passphrase,
),
highlights = listOf(
stringResource(
R.string.use_hyphens_spaces_or_leave_them_as_long_word_highlight,
),
),
highlightStyle = bitwardenBoldSpanStyle,
),
subtitleText = R.string.put_the_words_together_in_any_order_to_form_your_passphrase
.toAnnotatedString(),
iconVectorResource = R.drawable.ic_number2,
),
ContentBlockData(
headerText = stringResource(R.string.make_it_yours).toAnnotatedString(),
subtitleText = createAnnotatedString(
mainString = stringResource(
R.string.add_a_number_or_symbol_to_make_it_even_stronger,
),
highlights = listOf(
stringResource(R.string.add_a_number_or_symbol_highlight),
),
highlightStyle = bitwardenBoldSpanStyle,
),
subtitleText = R.string.add_a_number_or_symbol_to_make_it_even_stronger
.toAnnotatedString(),
iconVectorResource = R.drawable.ic_number3,
),
),

View File

@ -31,8 +31,8 @@ import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailA
import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.EmailAccessToggle
import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions
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.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
@ -146,18 +146,14 @@ private fun MainContent(
modifier = modifier,
) {
Text(
text = createAnnotatedString(
mainString = stringResource(
R.string.do_you_have_reliable_access_to_your_email,
email,
),
mainStringStyle = SpanStyle(
text = R.string.do_you_have_reliable_access_to_your_email.toAnnotatedString(
args = arrayOf(email),
style = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyLarge.fontSize,
fontWeight = FontWeight.Normal,
),
highlights = listOf(email),
highlightStyle = SpanStyle(
emphasisHighlightStyle = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyLarge.fontSize,
fontWeight = FontWeight.Bold,

View File

@ -49,10 +49,9 @@ 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.createClickableAnnotatedString
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
@ -288,7 +287,7 @@ private fun StartRegistrationContent(
}
}
@Suppress("LongMethod")
@Suppress("LongMethod", "MaxLineLength")
@Composable
private fun TermsAndPrivacyText(
onTermsClick: () -> Unit,
@ -297,22 +296,13 @@ private fun TermsAndPrivacyText(
) {
val strTerms = stringResource(id = R.string.terms_of_service)
val strPrivacy = stringResource(id = R.string.privacy_policy)
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,
),
ClickableTextHighlight(
textToHighlight = strPrivacy,
onTextClick = onPrivacyPolicyClick,
),
),
)
val annotatedLinkString: AnnotatedString =
R.string.by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy.toAnnotatedString {
when (it) {
"termsOfService" -> onTermsClick()
"privacyPolicy" -> onPrivacyPolicyClick()
}
}
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
@ -356,19 +346,7 @@ private fun ReceiveMarketingEmailsSwitch(
modifier: Modifier = Modifier,
) {
val unsubscribeString = stringResource(id = R.string.unsubscribe)
@Suppress("MaxLineLength")
val annotatedLinkString = createClickableAnnotatedString(
mainString = stringResource(
id = R.string.get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time,
),
highlights = listOf(
ClickableTextHighlight(
textToHighlight = unsubscribeString,
onTextClick = onUnsubscribeClick,
),
),
)
BitwardenSwitch(
modifier = modifier
.semantics(mergeDescendants = true) {
@ -382,7 +360,10 @@ private fun ReceiveMarketingEmailsSwitch(
),
)
},
label = annotatedLinkString,
label = R.string.get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time
.toAnnotatedString {
onUnsubscribeClick()
},
isChecked = isChecked,
onCheckedChange = onCheckedChange,
contentDescription = "ReceiveMarketingEmailsToggle",

View File

@ -0,0 +1,174 @@
package com.x8bit.bitwarden.ui.platform.base.util
import android.text.Annotation
import android.text.SpannableStringBuilder
import android.text.SpannedString
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.font.FontWeight
import androidx.core.text.getSpans
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Creates an [AnnotatedString] from a string resource allowing for optional arguments
* to be applied.
* @param args Optional arguments to be applied to the string resource, must already be a
* [String]
* @param style Style to apply to the entire string
* @param emphasisHighlightStyle Style to apply to part of the resource that has been annotated
* with the "emphasis" annotation.
* @param linkHighlightStyle Style to apply to part of the resource that has been annotated with
* the "link" annotation.
* @param onAnnotationClick Callback to invoke when a link annotation is clicked. Will pass back
* the value of the annotation as a string to allow for delineation if there are multiple callbacks
* to be applied.
*
* In order for the styles to be applied the resource must contain custom annotations for example:
*
* If a word or phrase is to have the [emphasisHighlightStyle] applied then it must be annotated
* with the custom XML tag: <annotation emphasis="anything"> where the value does not matter.
* <string foo>Foo <annotation emphasis="anything">bar</annotation> baz</string>
*
* If a word or phrase is to have the [linkHighlightStyle] applied then it must be annotated
* with the custom XML tag: <annotation link="callBackKey"> where the value will be passed back
* in [onAnnotationClick] and used to delineate which annotation was clicked.
* <string foo>Foo <annotation link="onBarClick">bar</annotation> baz</string>
*
* If the <string> contains a format argument (%1$s) then that argument should be wrapped in the
* following custom XML tag: <annotation arg="0"> where the value is the index of the argument,
* starting at 0.
*/
@Suppress("LongMethod")
@Composable
fun @receiver:StringRes Int.toAnnotatedString(
vararg args: String,
style: SpanStyle = bitwardenDefaultSpanStyle,
emphasisHighlightStyle: SpanStyle = bitwardenBoldSpanStyle,
linkHighlightStyle: SpanStyle = bitwardenClickableTextSpanStyle,
onAnnotationClick: ((annotationKey: String) -> Unit)? = null,
): AnnotatedString {
val resources = LocalContext.current.resources
// The spannableBuilder is used to help parse through the annotations in the string resource.
val spannableBuilder = try {
SpannableStringBuilder(resources.getText(this) as SpannedString)
} catch (e: ClassCastException) {
// the resource did not contain and valid spans so we just return the raw string.
return stringResource(id = this).toAnnotatedString()
}
// Replace any format arguments with the provided arguments.
spannableBuilder.applyArgAnnotations(args = args)
// The annotatedStringBuilder is used to apply the styles to the string resource.
val annotatedStringBuilder = AnnotatedString.Builder()
// Add the entire string to the annotated string builder and apply the style.
annotatedStringBuilder.append(spannableBuilder)
annotatedStringBuilder.addStyle(
style = style,
start = 0,
end = spannableBuilder.length,
)
val annotations = spannableBuilder.getSpans<Annotation>()
// Iterate through the annotations and apply the appropriate style. If the [Annotation.key]
// does not match a [ValidAnnotationType] an exception will be thrown.
for (annotation in annotations) {
// Skip the annotation if it does not have a valid start in the spanned string.
val start = spannableBuilder.getSpanStart(annotation).takeIf { it >= 0 } ?: continue
val end = spannableBuilder.getSpanEnd(annotation)
when (ValidAnnotationType.valueOf(annotation.key.uppercase())) {
ValidAnnotationType.EMPHASIS -> {
annotatedStringBuilder.addStyle(
style = emphasisHighlightStyle,
start = start,
end = end,
)
}
ValidAnnotationType.LINK -> {
val link = LinkAnnotation.Clickable(
tag = annotation.value.orEmpty(),
styles = TextLinkStyles(
style = linkHighlightStyle,
),
) {
onAnnotationClick?.invoke(annotation.value.orEmpty())
}
annotatedStringBuilder.addLink(
link,
start = start,
end = end,
)
}
// Handled prior to this point, not styling to be applied.
ValidAnnotationType.ARG -> Unit
}
}
return remember { annotatedStringBuilder.toAnnotatedString() }
}
/**
* The span between the <annotation arg="0"> and </annotation> tags in the string resource is
* replaced with the index value in the provided [args].
*/
private fun SpannableStringBuilder.applyArgAnnotations(
vararg args: String,
) {
val argAnnotations = getSpans<Annotation>()
.filter { it.isArgAnnotation() }
for (annotation in argAnnotations) {
// Skip the annotation if it does not have a valid start in the spanned string.
val spanStart = getSpanStart(annotation).takeIf { it >= 0 } ?: continue
val argIndex = Integer.parseInt(annotation.value)
// if no string is available just replace it with an empty string.
val replacementString = args.getOrNull(argIndex).orEmpty()
this.replace(
spanStart,
this.getSpanEnd(annotation),
replacementString,
)
}
}
/**
* Enumerated values representing the valid <annotation> keys that can be processed
* by [Int.toAnnotatedString]
*/
private enum class ValidAnnotationType {
ARG,
LINK,
EMPHASIS,
}
private fun Annotation.isArgAnnotation(): Boolean =
this.key.uppercase() == ValidAnnotationType.ARG.name
val bitwardenDefaultSpanStyle: SpanStyle
@Composable
@ReadOnlyComposable
get() = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontFamily = BitwardenTheme.typography.bodyMedium.fontFamily,
)
val bitwardenBoldSpanStyle: SpanStyle
@Composable
@ReadOnlyComposable
get() = bitwardenDefaultSpanStyle.copy(
fontWeight = FontWeight.Bold,
)
val bitwardenClickableTextSpanStyle: SpanStyle
@Composable
@ReadOnlyComposable
get() = bitwardenBoldSpanStyle.copy(
color = BitwardenTheme.colorScheme.text.interaction,
)

View File

@ -6,15 +6,7 @@ import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
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
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
@ -125,149 +117,3 @@ fun @receiver:StringRes Int.asText(): Text = ResText(this)
* Convert a resource Id to [Text] with format args.
*/
fun @receiver:StringRes Int.asText(vararg args: Any): Text = ResArgsText(this, args.asList())
/**
* Create an [AnnotatedString] with highlighted parts.
* @param mainString the full string
* @param highlights parts of the mainString that will be highlighted
* @param highlightStyle the style to apply to the highlights
* @param mainStringStyle the style to apply to the mainString
* @param tag the tag that will be used for the annotation
*/
@Composable
fun createAnnotatedString(
mainString: String,
highlights: List<String>,
highlightStyle: SpanStyle = bitwardenClickableTextSpanStyle,
mainStringStyle: SpanStyle = bitwardenDefaultSpanStyle,
tag: String? = null,
): AnnotatedString {
return buildAnnotatedString {
append(mainString)
addStyle(
style = mainStringStyle,
start = 0,
end = mainString.length,
)
for (highlightString in highlights) {
val startIndex = mainString.indexOf(highlightString, ignoreCase = true)
val endIndex = startIndex + highlightString.length
addStyle(
style = highlightStyle,
start = startIndex,
end = endIndex,
)
tag?.let {
addStringAnnotation(
tag = it,
annotation = highlightString,
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<ClickableTextHighlight>,
style: SpanStyle = bitwardenDefaultSpanStyle,
highlightStyle: SpanStyle = bitwardenClickableTextSpanStyle,
): 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)
}
}
// Skip adding the link if the text to highlight is not found in the main string.
// This can happen if the highlighted text is correctly translated, but the main string
// is not yet translated, causing the startIndex to be -1.
if (startIndex < 0) continue
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,
}
}
val bitwardenDefaultSpanStyle: SpanStyle
@Composable
@ReadOnlyComposable
get() = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontFamily = BitwardenTheme.typography.bodyMedium.fontFamily,
)
val bitwardenBoldSpanStyle: SpanStyle
@Composable
@ReadOnlyComposable
get() = bitwardenDefaultSpanStyle.copy(
fontWeight = FontWeight.Bold,
)
val bitwardenClickableTextSpanStyle: SpanStyle
@Composable
@ReadOnlyComposable
get() = bitwardenBoldSpanStyle.copy(
color = BitwardenTheme.colorScheme.text.interaction,
)

View File

@ -41,7 +41,6 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.bitwardenBoldSpanStyle
import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
@ -280,6 +279,7 @@ private fun InitialImportLoginsContent(
}
}
@Suppress("MaxLineLength")
@Composable
private fun ImportLoginsStepOneContent(
onContinueClick: () -> Unit,
@ -287,29 +287,13 @@ private fun ImportLoginsStepOneContent(
onHelpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val instruction1 = createAnnotatedString(
mainString = stringResource(
R.string.on_your_computer_log_in_to_your_current_browser_or_password_manager,
),
highlights = listOf(
stringResource(R.string.log_in_to_your_current_browser_or_password_manager_highlight),
),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction2 = createAnnotatedString(
mainString = stringResource(
R.string.export_your_passwords_this_option_is_usually_found_in_your_settings,
),
listOf(stringResource(R.string.export_your_passwords_highlight)),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction3 = createAnnotatedString(
mainString = stringResource(
R.string.save_the_exported_file_somewhere_on_your_computer_you_can_find_easily,
),
highlights = listOf(stringResource(R.string.save_the_exported_file_highlight)),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction1 =
R.string.on_your_computer_log_in_to_your_current_browser_or_password_manager.toAnnotatedString()
val instruction2 =
R.string.export_your_passwords_this_option_is_usually_found_in_your_settings.toAnnotatedString()
val instruction3 =
R.string.save_the_exported_file_somewhere_on_your_computer_you_can_find_easily
.toAnnotatedString()
ImportLoginsInstructionStep(
stepText = stringResource(R.string.step_1_of_3),
stepTitle = stringResource(R.string.export_your_saved_logins),
@ -346,19 +330,11 @@ private fun ImportLoginsStepTwoContent(
onHelpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val instruction1 = createAnnotatedString(
mainString = stringResource(
R.string.on_your_computer_open_a_new_browser_tab_and_go_to_vault_bitwarden_com,
vaultUrl,
),
highlights = listOf(
stringResource(
R.string.go_to_vault_bitwarden_com_highlight,
val instruction1 =
R.string.on_your_computer_open_a_new_browser_tab_and_go_to_vault_bitwarden_com
.toAnnotatedString(
vaultUrl,
),
),
highlightStyle = bitwardenBoldSpanStyle,
)
)
val instruction2Text = stringResource(R.string.log_in_to_the_bitwarden_web_app)
val instruction2 = buildAnnotatedString {
withStyle(bitwardenBoldSpanStyle) {
@ -387,7 +363,7 @@ private fun ImportLoginsStepTwoContent(
)
}
@Suppress("LongMethod")
@Suppress("LongMethod", "MaxLineLength")
@Composable
private fun ImportLoginsStepThreeContent(
onContinueClick: () -> Unit,
@ -395,42 +371,15 @@ private fun ImportLoginsStepThreeContent(
onHelpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val instruction1 = createAnnotatedString(
mainString = stringResource(
R.string.in_the_bitwarden_navigation_find_the_tools_option_and_select_import_data,
),
highlights = listOf(
stringResource(R.string.find_the_tools_highlight),
stringResource(R.string.select_import_data_step_3_highlight),
),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction2 = createAnnotatedString(
mainString = stringResource(R.string.fill_out_the_form_and_import_your_saved_password_file),
highlights = listOf(
stringResource(R.string.import_your_saved_password_file_highlight),
),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction3 = createAnnotatedString(
mainString = stringResource(
R.string.select_import_data_in_the_web_app_then_done_to_finish_syncing,
),
highlights = listOf(
stringResource(R.string.select_import_data_highlight),
stringResource(R.string.then_done_highlight),
),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction4 = createAnnotatedString(
mainString = stringResource(
R.string.for_your_security_be_sure_to_delete_your_saved_password_file,
),
highlights = listOf(
stringResource(R.string.delete_your_saved_password_file),
),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction1 =
R.string.in_the_bitwarden_navigation_find_the_tools_option_and_select_import_data
.toAnnotatedString()
val instruction2 = R.string.fill_out_the_form_and_import_your_saved_password_file
.toAnnotatedString()
val instruction3 = R.string.select_import_data_in_the_web_app_then_done_to_finish_syncing
.toAnnotatedString()
val instruction4 = R.string.for_your_security_be_sure_to_delete_your_saved_password_file
.toAnnotatedString()
ImportLoginsInstructionStep(
stepText = stringResource(R.string.step_3_of_3),
stepTitle = stringResource(R.string.import_logins_to_bitwarden),

View File

@ -20,8 +20,6 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.ClickableTextHighlight
import com.x8bit.bitwarden.ui.platform.base.util.createClickableAnnotatedString
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
@ -68,15 +66,10 @@ fun ImportLoginsInstructionStep(
)
Spacer(Modifier.height(24.dp))
Text(
text = createClickableAnnotatedString(
mainString = stringResource(R.string.need_help_check_out_import_help),
highlights = listOf(
ClickableTextHighlight(
textToHighlight = stringResource(R.string.import_help_highlight),
onTextClick = onHelpClick,
),
),
),
text = R.string.need_help_check_out_import_help
.toAnnotatedString {
onHelpClick()
},
style = BitwardenTheme.typography.bodySmall,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.standardHorizontalMargin(),

View File

@ -939,17 +939,17 @@ Do you want to switch to this account?</string>
<string name="user_verification_direction">User verification</string>
<string name="creating_on">Creating on:</string>
<string name="follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account">Follow the instructions in the email sent to %1$s to continue creating your account.</string>
<string name="we_sent_an_email_to">We sent an email to %1$s.</string>
<string name="by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy">By continuing, you agree to the Terms of Service and Privacy Policy</string>
<string name="we_sent_an_email_to">We sent an email to <annotation emphasis="bold"><annotation arg="0">%1$s</annotation></annotation>.</string>
<string name="by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy">By continuing, you agree to the <annotation link="termsOfService">Terms of Service</annotation> and <annotation link="privacyPolicy">Privacy Policy</annotation></string>
<string name="set_password">Set password</string>
<string name="unsubscribe">Unsubscribe</string>
<string name="check_your_email">Check your email</string>
<string name="open_email_app">Open email app</string>
<string name="go_back">Go back</string>
<string name="email_verified">Email verified</string>
<string name="no_email_go_back_to_edit_your_email_address">No email? Go back to edit your email address.</string>
<string name="or_log_in_you_may_already_have_an_account">Or log in, you may already have an account.</string>
<string name="get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time">Get emails from Bitwarden for announcements, advice, and research opportunities. Unsubscribe at any time.</string>
<string name="no_email_go_back_to_edit_your_email_address">No email? <annotation link="goBack">Go back</annotation> to edit your email address.</string>
<string name="or_log_in_you_may_already_have_an_account">Or <annotation link="logIn">log in</annotation>, you may already have an account.</string>
<string name="get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time">Get emails from Bitwarden for announcements, advice, and research opportunities. <annotation link="unsubscribe">Unsubscribe</annotation> at any time.</string>
<string name="get_advice_announcements_and_research_opportunities_from_bitwarden_in_your_inbox_unsubscribe_any_time">Get advice, announcements, and research opportunities from Bitwarden in your inbox. Unsubscribe at any time.</string>
<string name="security_prioritized">Security, prioritized</string>
<string name="welcome_message_1">Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect whats important to you.</string>
@ -1032,34 +1032,23 @@ Do you want to switch to this account?</string>
<string name="give_your_vault_a_head_start">Give your vault a head start</string>
<string name="class_3_biometrics_description">Unlock with biometrics requires strong biometric authentication and may not be compatible with all biometric options on this device.</string>
<string name="class_2_biometrics_description">Unlock with biometrics requires strong biometric authentication and is not compatible with the biometrics options available on this device.</string>
<string name="on_your_computer_log_in_to_your_current_browser_or_password_manager">On your computer, log in to your current browser or password manager.</string>
<string name="log_in_to_your_current_browser_or_password_manager_highlight">log in to your current browser or password manager.</string>
<string name="export_your_passwords_this_option_is_usually_found_in_your_settings">Export your passwords. This option is usually found in your settings.</string>
<string name="export_your_passwords_highlight">Export your passwords.</string>
<string name="select_import_data_in_the_web_app_then_done_to_finish_syncing">Select Import data in the web app, then Done below to finish syncing.</string>
<string name="select_import_data_highlight">Select Import data</string>
<string name="on_your_computer_log_in_to_your_current_browser_or_password_manager">On your computer, <annotation emphasis="bold">log in to your current browser or password manager.</annotation></string>
<string name="export_your_passwords_this_option_is_usually_found_in_your_settings"><annotation emphasis="bold">Export your passwords.</annotation> This option is usually found in your settings.</string>
<string name="select_import_data_in_the_web_app_then_done_to_finish_syncing"><annotation emphasis="bold">Select Import data</annotation> in the web app, then Done below to finish syncing.</string>
<string name="step_1_of_3">Step 1 of 3</string>
<string name="export_your_saved_logins">Export your saved logins</string>
<string name="delete_this_file_after_import_is_complete">Youll delete this file after import is complete.</string>
<string name="on_your_computer_open_a_new_browser_tab_and_go_to_vault_bitwarden_com">On your computer, open a new browser tab and go to %1$s</string>
<string name="go_to_vault_bitwarden_com_highlight">go to %1$s</string>
<string name="on_your_computer_open_a_new_browser_tab_and_go_to_vault_bitwarden_com">On your computer, open a new browser tab and <annotation emphasis="bold">go to <annotation arg="0">%1$s</annotation></annotation></string>
<string name="log_in_to_the_bitwarden_web_app">Log in to the Bitwarden web app.</string>
<string name="step_2_of_3">Step 2 of 3</string>
<string name="log_in_to_bitwarden">Log in to Bitwarden</string>
<string name="step_3_of_3">Step 3 of 3</string>
<string name="import_logins_to_bitwarden">Import logins to Bitwarden</string>
<string name="in_the_bitwarden_navigation_find_the_tools_option_and_select_import_data">In the Bitwarden navigation, find the Tools option and select Import data.</string>
<string name="find_the_tools_highlight">find the Tools</string>
<string name="select_import_data_step_3_highlight">select Import data.</string>
<string name="fill_out_the_form_and_import_your_saved_password_file">Fill out the form and import your saved password file.</string>
<string name="import_your_saved_password_file_highlight">import your saved password file.</string>
<string name="then_done_highlight">then Done</string>
<string name="for_your_security_be_sure_to_delete_your_saved_password_file">For your security, be sure to delete your saved password file.</string>
<string name="delete_your_saved_password_file">delete your saved password file.</string>
<string name="need_help_check_out_import_help">Need help? Check out import help.</string>
<string name="import_help_highlight">import help</string>
<string name="save_the_exported_file_somewhere_on_your_computer_you_can_find_easily">Save the exported file somewhere on your computer you can find easily.</string>
<string name="save_the_exported_file_highlight">Save the exported file</string>
<string name="in_the_bitwarden_navigation_find_the_tools_option_and_select_import_data">In the Bitwarden navigation, <annotation emphasis="bold">find the Tools</annotation> option and <annotation emphasis="bold">select Import data.</annotation></string>
<string name="fill_out_the_form_and_import_your_saved_password_file">Fill out the form and <annotation emphasis="bold">import your saved password file.</annotation></string>
<string name="for_your_security_be_sure_to_delete_your_saved_password_file">For your security, be sure to <annotation emphasis="bold">delete your saved password file.</annotation></string>
<string name="need_help_check_out_import_help">Need help? Check out <annotation link="importHelp">import help</annotation>.</string>
<string name="save_the_exported_file_somewhere_on_your_computer_you_can_find_easily"><annotation emphasis="bold">Save the exported file</annotation> somewhere on your computer you can find easily.</string>
<string name="this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server">This is not a recognized Bitwarden server. You may need to check with your provider or update your server.</string>
<string name="syncing_logins_loading_message">Syncing logins...</string>
<string name="ssh_key_cipher_item_types">SSH Key Cipher Item Types</string>
@ -1100,7 +1089,7 @@ Do you want to switch to this account?</string>
<string name="copy_address">Copy address</string>
<string name="important_notice">Important notice</string>
<string name="bitwarden_will_soon_send_a_code_to_your_account_email_to_verify_logins_from_new_devices_in_february">Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025.</string>
<string name="do_you_have_reliable_access_to_your_email">Do you have reliable access to your email, %1$s? </string>
<string name="do_you_have_reliable_access_to_your_email">Do you have reliable access to your email, <annotation emphasis="bold"><annotation arg="0">%1$s?</annotation></annotation></string>
<string name="yes_i_can_reliably_access_my_email">Yes, I can reliably access my email</string>
<string name="biometrics_no_longer_supported_title">Biometrics are no longer supported on this device</string>
<string name="biometrics_no_longer_supported">Youve been logged out because your devices biometrics dont meet the latest security requirements. To update settings, log in once again or contact your administrator for access.</string>
@ -1111,14 +1100,11 @@ Do you want to switch to this account?</string>
<string name="turn_on_two_step_login">Turn on two-step login</string>
<string name="change_account_email">Change account email</string>
<string name="choose_three_or_four_random_words">Choose three or four random words</string>
<string name="pick_three_or_four_random_unrelated_words">Pick three or four random, unrelated words that you can easily remember. Think of objects, places, or things you like.</string>
<string name="pick_three_or_four_random_unrelated_words_highlight">objects, places, or things</string>
<string name="pick_three_or_four_random_unrelated_words">Pick three or four random, unrelated words that you can easily remember. Think of <annotation emphasis="bold">objects, places, or things</annotation> you like.</string>
<string name="combine_those_words_together">Combine those words together</string>
<string name="put_the_words_together_in_any_order_to_form_your_passphrase">Put the words together in any order to form your passphrase. Use hyphens, spaces, or leave them as one long word—your choice!</string>
<string name="use_hyphens_spaces_or_leave_them_as_long_word_highlight">Use hyphens, spaces, or leave them as one long word</string>
<string name="put_the_words_together_in_any_order_to_form_your_passphrase">Put the words together in any order to form your passphrase. <annotation emphasis="bold">Use hyphens, spaces, or leave them as one long word</annotation>—your choice!</string>
<string name="make_it_yours">Make it yours</string>
<string name="add_a_number_or_symbol_to_make_it_even_stronger">Add a number or symbol to make it even stronger. Now you have a unique, secure, and memorable passphrase!</string>
<string name="add_a_number_or_symbol_highlight">Add a number or symbol</string>
<string name="add_a_number_or_symbol_to_make_it_even_stronger"><annotation emphasis="bold">Add a number or symbol</annotation> to make it even stronger. Now you have a unique, secure, and memorable passphrase!</string>
<string name="need_some_inspiration">"Need some inspiration?"</string>
<string name="check_out_the_passphrase_generator">"Check out the passphrase generator"</string>
<string name="copied_to_clipboard">Copied to clipboard.</string>

View File

@ -2,10 +2,8 @@ package com.x8bit.bitwarden.ui.auth.feature.checkemail
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.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
@ -91,16 +89,9 @@ class CheckEmailScreenTest : BaseComposeTest() {
@Test
fun `go back and update email text click should send ChangeEmailClick action`() {
mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = false)
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) }
@ -110,14 +101,8 @@ class CheckEmailScreenTest : BaseComposeTest() {
fun `already have account text click should send ChangeEmailClick action`() {
mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = false)
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) }

View File

@ -1,136 +0,0 @@
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,
)
}
}

View File

@ -0,0 +1,103 @@
package com.x8bit.bitwarden.ui.platform.base.util
import androidx.compose.material3.Text
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import com.x8bit.bitwarden.R
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 StringRestExtensionsTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `toAnnotatedString should add Clickable LinkAnnotation to highlighted string`() {
var textClickCalled = false
composeTestRule.setContent {
val annotatedString =
R.string.test_for_single_link_annotation.toAnnotatedString {
textClickCalled = true
}
Text(text = annotatedString)
}
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
mainString = "Get emails from Bitwarden",
)
assertTrue(textClickCalled)
}
@Suppress("MaxLineLength")
@Test
fun `toAnnotatedString should add multiple Clickable LinkAnnotations to highlighted string`() {
composeTestRule.setContent {
val annotatedString =
R.string.test_for_multi_link_annotation.toAnnotatedString()
Text(text = annotatedString)
}
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
mainString = "By continuing, you agree to the",
expectedLinkCount = 2,
)
}
@Test
fun `no link annotations should be applied to non annotated string resource`() {
composeTestRule.setContent {
Text(text = R.string.test_for_string_with_no_annotations.toAnnotatedString())
}
composeTestRule
.onNodeWithText("Nothing special here.")
.fetchSemanticsNode()
.config
.getOrNull(SemanticsProperties.Text)
?.let { text ->
text.forEach {
// get any link annotations present
val linkAnnotations = it.getLinkAnnotations(0, it.length)
assertTrue(linkAnnotations.isEmpty())
}
}
}
@Test
fun `string with args should only use the arguments available in the string`() {
composeTestRule.setContent {
Text(
text =
R.string.test_for_string_with_annotation_and_arg_annotation
.toAnnotatedString(
args = arrayOf("vault.bitwarden.com", "i should not exist"),
),
)
}
composeTestRule
.onNodeWithText(
"On your computer, open a new browser " +
"tab and go to vault.bitwarden.com",
)
.assertIsDisplayed()
composeTestRule
.onNodeWithText("i should not exist")
.assertDoesNotExist()
}
@Test
fun `string with arg annotations but no passed in args should just append empty string`() {
composeTestRule.setContent {
Text(
text = R.string.test_for_string_with_annotation_and_arg_annotation
.toAnnotatedString(),
)
}
composeTestRule
.onNodeWithText("On your computer, open a new browser tab and go to ")
.assertIsDisplayed()
}
}

View File

@ -23,8 +23,8 @@ 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.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.jupiter.api.assertThrows
/**
@ -182,27 +182,34 @@ 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.
*/
@Suppress("NestedBlockDepth")
fun ComposeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
mainString: String,
highLightText: String,
expectedStart: Int,
expectedEnd: Int,
expectedLinkCount: Int? = null,
) {
this
.onNodeWithText(mainString)
.onNodeWithText(mainString, substring = true, ignoreCase = true)
.fetchSemanticsNode()
.config
.getOrNull(SemanticsProperties.Text)
?.let { text ->
text.forEach {
it.getLinkAnnotations(expectedStart, expectedEnd)
.forEach { annotationRange ->
val linkAnnotations = it.getLinkAnnotations(0, it.length)
if (linkAnnotations.isEmpty()) {
throw AssertionError(
"No link annotation found",
)
} else {
linkAnnotations.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)
}
expectedLinkCount?.let {
assertEquals(expectedLinkCount, linkAnnotations.size)
}
}
}
}
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- StringResExtensions Test value -->
<string name="test_for_single_link_annotation">Get emails from Bitwarden for announcements, advice, and research opportunities. <annotation link="unsubscribe">Unsubscribe</annotation> at any time.</string>
<string name="test_for_multi_link_annotation">By continuing, you agree to the <annotation link="termsOfService">Terms of Service</annotation> and <annotation link="privacyPolicy">Privacy Policy</annotation></string>
<string name="test_for_string_with_no_annotations">Nothing special here.</string>
<string name="test_for_string_with_annotation_and_arg_annotation">On your computer, open a new browser tab and <annotation emphasis="bold">go to <annotation arg="0">%1$s</annotation></annotation></string>
<!-- /StringResExtensions Test value -->
</resources>