Skip to content

Commit

Permalink
PR suggestions and inline comments plus another test
Browse files Browse the repository at this point in the history
  • Loading branch information
dseverns-livefront committed Dec 18, 2024
1 parent 0d4f5a9 commit a58e112
Show file tree
Hide file tree
Showing 3 changed files with 97 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Annotation>(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<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,
)
}

"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 <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 annotations = getSpans<Annotation>()
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<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
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/values/strings_non_localized.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@
<string name="app_review_prompt">App Review Prompt</string>
<string name="cipher_key_encryption">Cipher Key Encryption</string>">
<!-- /Debug Menu -->

<!-- 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>
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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"),
),
Expand All @@ -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()
}
}

0 comments on commit a58e112

Please sign in to comment.