diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringResExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringResExtensions.kt index 0f064808c1b..46992c23f64 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringResExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringResExtensions.kt @@ -6,6 +6,7 @@ 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 @@ -55,69 +56,100 @@ fun @receiver:StringRes Int.toAnnotatedString( 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, ) - spannableBuilder.getSpans(0, spannableBuilder.length) - .forEach { annotation -> - val start = spannableBuilder.getSpanStart(annotation) - val end = spannableBuilder.getSpanEnd(annotation) - when (annotation.key) { - "emphasis" -> { - annotatedStringBuilder.addStyle( - style = emphasisHighlightStyle, - start = start, - end = end, - ) - } + val annotations = spannableBuilder.getSpans() + // 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, + ) + } - "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, - ) + 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 annotatedStringBuilder.toAnnotatedString() + } + return remember { annotatedStringBuilder.toAnnotatedString() } } +/** + * The span between the and tags in the string resource is + * replaced with the index value in the provided [args]. + */ private fun SpannableStringBuilder.applyArgAnnotations( vararg args: String, ) { - val annotations = getSpans() - annotations - .filter { - it.key == "arg" - }.forEach { annotation -> - val argIndex = Integer.parseInt(annotation.value) - this.replace( - this.getSpanStart(annotation), - this.getSpanEnd(annotation), - args[argIndex], - ) - } + val argAnnotations = getSpans() + .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 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 diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml index 263a42e3b62..b5494b10a2f 100644 --- a/app/src/main/res/values/strings_non_localized.xml +++ b/app/src/main/res/values/strings_non_localized.xml @@ -23,4 +23,11 @@ App Review Prompt Cipher Key Encryption"> + + + Get emails from Bitwarden for announcements, advice, and research opportunities. Unsubscribe at any time. + By continuing, you agree to the Terms of Service and Privacy Policy + Nothing special here. + On your computer, open a new browser tab and go to %1$s + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringRestExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringRestExtensionsTest.kt index 2f0a0b208ad..caf3a599e2d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringRestExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringRestExtensionsTest.kt @@ -18,7 +18,7 @@ class StringRestExtensionsTest : BaseComposeTest() { var textClickCalled = false composeTestRule.setContent { val annotatedString = - R.string.get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time.toAnnotatedString { + R.string.test_for_single_link_annotation.toAnnotatedString { textClickCalled = true } Text(text = annotatedString) @@ -34,7 +34,7 @@ class StringRestExtensionsTest : BaseComposeTest() { fun `toAnnotatedString should add multiple Clickable LinkAnnotations to highlighted string`() { composeTestRule.setContent { val annotatedString = - R.string.by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy.toAnnotatedString() + R.string.test_for_multi_link_annotation.toAnnotatedString() Text(text = annotatedString) } composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction( @@ -46,11 +46,11 @@ class StringRestExtensionsTest : BaseComposeTest() { @Test fun `no link annotations should be applied to non annotated string resource`() { composeTestRule.setContent { - Text(text = R.string.about.toAnnotatedString()) + Text(text = R.string.test_for_string_with_no_annotations.toAnnotatedString()) } composeTestRule - .onNodeWithText("About") + .onNodeWithText("Nothing special here.") .fetchSemanticsNode() .config .getOrNull(SemanticsProperties.Text) @@ -68,7 +68,7 @@ class StringRestExtensionsTest : BaseComposeTest() { composeTestRule.setContent { Text( text = - R.string.on_your_computer_open_a_new_browser_tab_and_go_to_vault_bitwarden_com + R.string.test_for_string_with_annotation_and_arg_annotation .toAnnotatedString( args = arrayOf("vault.bitwarden.com", "i should not exist"), ), @@ -86,4 +86,18 @@ class StringRestExtensionsTest : BaseComposeTest() { .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() + } }