Skip to content

Commit 5cbf845

Browse files
Preserve link spans when sanitizing Android text accessibility
Sanitize Android Text accessibility node text by removing known visual spans while preserving ClickableSpan/URLSpan semantics. This avoids leaking style metadata to TalkBack without regressing whole-text single-link accessibility.
1 parent ced0e69 commit 5cbf845

2 files changed

Lines changed: 136 additions & 11 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@ package com.facebook.react.views.text
1010
import android.graphics.Rect
1111
import android.os.Bundle
1212
import android.text.Layout
13+
import android.text.SpannableString
1314
import android.text.Spanned
15+
import android.text.style.AbsoluteSizeSpan
16+
import android.text.style.BackgroundColorSpan
1417
import android.text.style.ClickableSpan
18+
import android.text.style.ForegroundColorSpan
19+
import android.text.style.StrikethroughSpan
20+
import android.text.style.StyleSpan
21+
import android.text.style.URLSpan
22+
import android.text.style.UnderlineSpan
1523
import android.view.View
1624
import android.widget.TextView
1725
import androidx.core.view.ViewCompat
@@ -20,7 +28,18 @@ import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
2028
import com.facebook.react.R
2129
import com.facebook.react.common.annotations.UnstableReactNativeAPI
2230
import com.facebook.react.uimanager.ReactAccessibilityDelegate
31+
import com.facebook.react.views.text.internal.span.CustomLetterSpacingSpan
32+
import com.facebook.react.views.text.internal.span.CustomLineHeightSpan
33+
import com.facebook.react.views.text.internal.span.CustomStyleSpan
34+
import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan
35+
import com.facebook.react.views.text.internal.span.ReactBackgroundColorSpan
2336
import com.facebook.react.views.text.internal.span.ReactClickableSpan
37+
import com.facebook.react.views.text.internal.span.ReactForegroundColorSpan
38+
import com.facebook.react.views.text.internal.span.ReactLinkSpan
39+
import com.facebook.react.views.text.internal.span.ReactOpacitySpan
40+
import com.facebook.react.views.text.internal.span.ReactStrikethroughSpan
41+
import com.facebook.react.views.text.internal.span.ReactUnderlineSpan
42+
import com.facebook.react.views.text.internal.span.ShadowStyleSpan
2443

2544
@OptIn(UnstableReactNativeAPI::class)
2645
internal class ReactTextViewAccessibilityDelegate(
@@ -189,7 +208,7 @@ internal class ReactTextViewAccessibilityDelegate(
189208
// PreparedLayoutTextView isn't actually a TextView, so we need to teach it about its text that
190209
// it is holding so TalkBack knows what to announce when focusing it.
191210
val accessibilityText = if (host is PreparedLayoutTextView) host.text else info.text
192-
info.text = accessibilityText.toPlainTextForAccessibility()
211+
info.text = accessibilityText.toAccessibilityTextWithoutVisualSpans()
193212
}
194213

195214
@Suppress("DEPRECATION")
@@ -364,5 +383,42 @@ private fun isWholeTextSingleLink(text: Spanned, spans: Array<ClickableSpan>): B
364383
return start == 0 && end == text.length
365384
}
366385

367-
private fun CharSequence?.toPlainTextForAccessibility(): CharSequence? =
368-
if (this is Spanned) toString() else this
386+
private fun CharSequence?.toAccessibilityTextWithoutVisualSpans(): CharSequence? {
387+
if (this !is Spanned) {
388+
return this
389+
}
390+
391+
return SpannableString(this).apply {
392+
getSpans(0, length, Any::class.java)
393+
.filter { isVisualSpanForAccessibility(it) }
394+
.forEach { removeSpan(it) }
395+
}
396+
}
397+
398+
private fun isVisualSpanForAccessibility(span: Any): Boolean {
399+
if (
400+
span is URLSpan ||
401+
span is ReactClickableSpan ||
402+
span is ReactLinkSpan ||
403+
span is ClickableSpan
404+
) {
405+
return false
406+
}
407+
408+
return span is ReactAbsoluteSizeSpan ||
409+
span is ReactForegroundColorSpan ||
410+
span is ReactBackgroundColorSpan ||
411+
span is CustomStyleSpan ||
412+
span is CustomLetterSpacingSpan ||
413+
span is CustomLineHeightSpan ||
414+
span is ReactOpacitySpan ||
415+
span is ShadowStyleSpan ||
416+
span is ReactUnderlineSpan ||
417+
span is ReactStrikethroughSpan ||
418+
span is AbsoluteSizeSpan ||
419+
span is ForegroundColorSpan ||
420+
span is BackgroundColorSpan ||
421+
span is StyleSpan ||
422+
span is UnderlineSpan ||
423+
span is StrikethroughSpan
424+
}

packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ import android.text.Spanned
1616
import android.text.StaticLayout
1717
import android.text.TextPaint
1818
import android.text.style.AbsoluteSizeSpan
19+
import android.text.style.BackgroundColorSpan
1920
import android.text.style.ClickableSpan
2021
import android.text.style.ForegroundColorSpan
22+
import android.text.style.StyleSpan
23+
import android.text.style.URLSpan
2124
import android.view.View
2225
import androidx.core.view.ViewCompat
2326
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
@@ -37,7 +40,7 @@ class ReactTextViewAccessibilityDelegateTest {
3740

3841
assertSourceTextKeepsStyleSpans(textView.text)
3942
assertThat(nodeInfo.text.toString()).isEqualTo("Start")
40-
assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java)
43+
assertAccessibilityTextDoesNotHaveVisualSpans(nodeInfo.text)
4144
}
4245

4346
@Test
@@ -49,31 +52,61 @@ class ReactTextViewAccessibilityDelegateTest {
4952

5053
assertThat(textView.contentDescription.toString()).isEqualTo("Custom label")
5154
assertThat(nodeInfo.text.toString()).isEqualTo("Visible text")
52-
assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java)
55+
assertAccessibilityTextDoesNotHaveVisualSpans(nodeInfo.text)
5356
}
5457

5558
@Test
56-
fun reactTextViewAccessibilityNodeText_doesNotStripSourceClickableSpans() {
59+
fun reactTextViewAccessibilityNodeText_preservesWholeTextClickableSpan() {
5760
val clickableSpan =
5861
object : ClickableSpan() {
5962
override fun onClick(widget: View) = Unit
6063
}
6164
val text = createStyledText("Read docs")
62-
text.setSpan(clickableSpan, 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
65+
text.setSpan(clickableSpan, 0, text.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
6366
val textView = createReactTextView(text)
6467

6568
val nodeInfo = createNodeInfo(textView)
6669
val sourceText = textView.text as Spanned
70+
val accessibilityText = nodeInfo.text as Spanned
6771

68-
assertThat(sourceText.getSpans(0, sourceText.length, ClickableSpan::class.java)).isNotEmpty()
6972
assertSourceTextKeepsStyleSpans(sourceText)
73+
assertThat(ReactTextViewAccessibilityDelegate.AccessibilityLinks(sourceText).size()).isEqualTo(0)
7074
assertThat(nodeInfo.text.toString()).isEqualTo("Read docs")
71-
assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java)
75+
assertAccessibilityTextDoesNotHaveVisualSpans(accessibilityText)
76+
assertPreservedSpanMatchesSource(sourceText, accessibilityText, clickableSpan)
77+
}
78+
79+
@Test
80+
fun reactTextViewAccessibilityNodeText_preservesMixedClickableAndUrlSpans() {
81+
val clickableSpan =
82+
object : ClickableSpan() {
83+
override fun onClick(widget: View) = Unit
84+
}
85+
val urlSpan = URLSpan("https://reactnative.dev")
86+
val text = createStyledText("Read docs now")
87+
text.setSpan(clickableSpan, 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
88+
text.setSpan(urlSpan, 0, 4, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
89+
val textView = createReactTextView(text)
90+
91+
val nodeInfo = createNodeInfo(textView)
92+
val sourceText = textView.text as Spanned
93+
val accessibilityText = nodeInfo.text as Spanned
94+
95+
assertSourceTextKeepsStyleSpans(sourceText)
96+
assertThat(nodeInfo.text.toString()).isEqualTo("Read docs now")
97+
assertAccessibilityTextDoesNotHaveVisualSpans(accessibilityText)
98+
assertPreservedSpanMatchesSource(sourceText, accessibilityText, clickableSpan)
99+
assertPreservedSpanMatchesSource(sourceText, accessibilityText, urlSpan)
72100
}
73101

74102
@Test
75-
fun preparedLayoutTextViewAccessibilityNodeText_stripsStyleSpans() {
103+
fun preparedLayoutTextViewAccessibilityNodeText_stripsStyleSpansAndPreservesClickableSpan() {
76104
val text = createStyledText("Prepared text")
105+
val clickableSpan =
106+
object : ClickableSpan() {
107+
override fun onClick(widget: View) = Unit
108+
}
109+
text.setSpan(clickableSpan, 0, 8, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
77110
val layout =
78111
StaticLayout.Builder.obtain(text, 0, text.length, TextPaint(), 300).build()
79112
val textView = PreparedLayoutTextView(RuntimeEnvironment.getApplication())
@@ -96,7 +129,9 @@ class ReactTextViewAccessibilityDelegateTest {
96129

97130
assertSourceTextKeepsStyleSpans(textView.text)
98131
assertThat(nodeInfo.text.toString()).isEqualTo("Prepared text")
99-
assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java)
132+
val accessibilityText = nodeInfo.text as Spanned
133+
assertAccessibilityTextDoesNotHaveVisualSpans(accessibilityText)
134+
assertPreservedSpanMatchesSource(textView.text as Spanned, accessibilityText, clickableSpan)
100135
}
101136

102137
private fun createReactTextViewWithStyledText(text: String): ReactTextView {
@@ -126,6 +161,8 @@ class ReactTextViewAccessibilityDelegateTest {
126161
SpannableString(text).apply {
127162
setSpan(AbsoluteSizeSpan(48), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
128163
setSpan(ForegroundColorSpan(Color.BLACK), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
164+
setSpan(BackgroundColorSpan(Color.WHITE), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
165+
setSpan(StyleSpan(android.graphics.Typeface.BOLD), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
129166
}
130167

131168
private fun createNodeInfo(view: View): AccessibilityNodeInfoCompat =
@@ -138,5 +175,37 @@ class ReactTextViewAccessibilityDelegateTest {
138175
val spanned = text as Spanned
139176
assertThat(spanned.getSpans(0, spanned.length, AbsoluteSizeSpan::class.java)).isNotEmpty()
140177
assertThat(spanned.getSpans(0, spanned.length, ForegroundColorSpan::class.java)).isNotEmpty()
178+
assertThat(spanned.getSpans(0, spanned.length, BackgroundColorSpan::class.java)).isNotEmpty()
179+
assertThat(spanned.getSpans(0, spanned.length, StyleSpan::class.java)).isNotEmpty()
180+
}
181+
182+
private fun assertAccessibilityTextDoesNotHaveVisualSpans(text: CharSequence?) {
183+
if (text !is Spanned) {
184+
return
185+
}
186+
187+
assertThat(text.getSpans(0, text.length, AbsoluteSizeSpan::class.java)).isEmpty()
188+
assertThat(text.getSpans(0, text.length, ForegroundColorSpan::class.java)).isEmpty()
189+
assertThat(text.getSpans(0, text.length, BackgroundColorSpan::class.java)).isEmpty()
190+
assertThat(text.getSpans(0, text.length, StyleSpan::class.java)).isEmpty()
191+
}
192+
193+
private fun assertPreservedSpanMatchesSource(
194+
sourceText: Spanned,
195+
accessibilityText: Spanned,
196+
sourceSpan: Any,
197+
) {
198+
val preservedSpans =
199+
accessibilityText
200+
.getSpans(
201+
sourceText.getSpanStart(sourceSpan),
202+
sourceText.getSpanEnd(sourceSpan),
203+
sourceSpan.javaClass,
204+
)
205+
.filter { accessibilityText.getSpanStart(it) == sourceText.getSpanStart(sourceSpan) }
206+
.filter { accessibilityText.getSpanEnd(it) == sourceText.getSpanEnd(sourceSpan) }
207+
.filter { accessibilityText.getSpanFlags(it) == sourceText.getSpanFlags(sourceSpan) }
208+
209+
assertThat(preservedSpans).isNotEmpty()
141210
}
142211
}

0 commit comments

Comments
 (0)