From ced0e69ad12f2264760533a9b1251014d8cdb52c Mon Sep 17 00:00:00 2001 From: Danyal Ahmed <58849388+danyalahmed1995@users.noreply.github.com> Date: Tue, 19 May 2026 04:31:45 +0500 Subject: [PATCH 1/2] Fix Android Text accessibility leaking style spans --- .../ReactTextViewAccessibilityDelegate.kt | 8 +- .../ReactTextViewAccessibilityDelegateTest.kt | 142 ++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt index 6834a329ac6c..563a6e6319e2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt @@ -188,9 +188,8 @@ internal class ReactTextViewAccessibilityDelegate( super.onInitializeAccessibilityNodeInfo(host, info) // PreparedLayoutTextView isn't actually a TextView, so we need to teach it about its text that // it is holding so TalkBack knows what to announce when focusing it. - if (host is PreparedLayoutTextView) { - info.text = host.text - } + val accessibilityText = if (host is PreparedLayoutTextView) host.text else info.text + info.text = accessibilityText.toPlainTextForAccessibility() } @Suppress("DEPRECATION") @@ -364,3 +363,6 @@ private fun isWholeTextSingleLink(text: Spanned, spans: Array): B val end = text.getSpanEnd(span) return start == 0 && end == text.length } + +private fun CharSequence?.toPlainTextForAccessibility(): CharSequence? = + if (this is Spanned) toString() else this diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt new file mode 100644 index 000000000000..c3fb01ea20e8 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.text + +import android.graphics.Color +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.AbsoluteSizeSpan +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ReactTextViewAccessibilityDelegateTest { + @Test + fun reactTextViewAccessibilityNodeText_stripsStyleSpans() { + val textView = createReactTextViewWithStyledText("Start") + + val nodeInfo = createNodeInfo(textView) + + assertSourceTextKeepsStyleSpans(textView.text) + assertThat(nodeInfo.text.toString()).isEqualTo("Start") + assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java) + } + + @Test + fun reactTextViewAccessibilityNodeText_preservesExplicitContentDescription() { + val textView = createReactTextViewWithStyledText("Visible text") + textView.contentDescription = "Custom label" + + val nodeInfo = createNodeInfo(textView) + + assertThat(textView.contentDescription.toString()).isEqualTo("Custom label") + assertThat(nodeInfo.text.toString()).isEqualTo("Visible text") + assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java) + } + + @Test + fun reactTextViewAccessibilityNodeText_doesNotStripSourceClickableSpans() { + val clickableSpan = + object : ClickableSpan() { + override fun onClick(widget: View) = Unit + } + val text = createStyledText("Read docs") + text.setSpan(clickableSpan, 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val textView = createReactTextView(text) + + val nodeInfo = createNodeInfo(textView) + val sourceText = textView.text as Spanned + + assertThat(sourceText.getSpans(0, sourceText.length, ClickableSpan::class.java)).isNotEmpty() + assertSourceTextKeepsStyleSpans(sourceText) + assertThat(nodeInfo.text.toString()).isEqualTo("Read docs") + assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java) + } + + @Test + fun preparedLayoutTextViewAccessibilityNodeText_stripsStyleSpans() { + val text = createStyledText("Prepared text") + val layout = + StaticLayout.Builder.obtain(text, 0, text.length, TextPaint(), 300).build() + val textView = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + textView.preparedLayout = + PreparedLayout( + layout, + Int.MAX_VALUE, + 0f, + intArrayOf(1), + Layout.BREAK_STRATEGY_SIMPLE, + 0, + ) + ReactTextViewAccessibilityDelegate.resetDelegate( + textView, + textView.isFocusable, + textView.importantForAccessibility, + ) + + val nodeInfo = createNodeInfo(textView) + + assertSourceTextKeepsStyleSpans(textView.text) + assertThat(nodeInfo.text.toString()).isEqualTo("Prepared text") + assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java) + } + + private fun createReactTextViewWithStyledText(text: String): ReactTextView { + return createReactTextView(createStyledText(text)) + } + + private fun createReactTextView(text: Spanned): ReactTextView { + val textView = ReactTextView(RuntimeEnvironment.getApplication()) + textView.setText( + ReactTextUpdate( + text, + -1, + 0, + Layout.BREAK_STRATEGY_SIMPLE, + 0, + ) + ) + ReactTextViewAccessibilityDelegate.resetDelegate( + textView, + textView.isFocusable, + textView.importantForAccessibility, + ) + return textView + } + + private fun createStyledText(text: String): SpannableString = + SpannableString(text).apply { + setSpan(AbsoluteSizeSpan(48), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan(ForegroundColorSpan(Color.BLACK), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + private fun createNodeInfo(view: View): AccessibilityNodeInfoCompat = + AccessibilityNodeInfoCompat.obtain().also { + ViewCompat.onInitializeAccessibilityNodeInfo(view, it) + } + + private fun assertSourceTextKeepsStyleSpans(text: CharSequence?) { + assertThat(text).isInstanceOf(Spanned::class.java) + val spanned = text as Spanned + assertThat(spanned.getSpans(0, spanned.length, AbsoluteSizeSpan::class.java)).isNotEmpty() + assertThat(spanned.getSpans(0, spanned.length, ForegroundColorSpan::class.java)).isNotEmpty() + } +} From 5cbf8454e64bdacaea3013dad37a2d419e3f731e Mon Sep 17 00:00:00 2001 From: Danyal Ahmed <58849388+danyalahmed1995@users.noreply.github.com> Date: Tue, 19 May 2026 23:20:37 +0500 Subject: [PATCH 2/2] 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. --- .../ReactTextViewAccessibilityDelegate.kt | 62 +++++++++++++- .../ReactTextViewAccessibilityDelegateTest.kt | 85 +++++++++++++++++-- 2 files changed, 136 insertions(+), 11 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt index 563a6e6319e2..f06eec675fa6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt @@ -10,8 +10,16 @@ package com.facebook.react.views.text import android.graphics.Rect import android.os.Bundle import android.text.Layout +import android.text.SpannableString import android.text.Spanned +import android.text.style.AbsoluteSizeSpan +import android.text.style.BackgroundColorSpan import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.StrikethroughSpan +import android.text.style.StyleSpan +import android.text.style.URLSpan +import android.text.style.UnderlineSpan import android.view.View import android.widget.TextView import androidx.core.view.ViewCompat @@ -20,7 +28,18 @@ import androidx.core.view.accessibility.AccessibilityNodeProviderCompat import com.facebook.react.R import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.uimanager.ReactAccessibilityDelegate +import com.facebook.react.views.text.internal.span.CustomLetterSpacingSpan +import com.facebook.react.views.text.internal.span.CustomLineHeightSpan +import com.facebook.react.views.text.internal.span.CustomStyleSpan +import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan +import com.facebook.react.views.text.internal.span.ReactBackgroundColorSpan import com.facebook.react.views.text.internal.span.ReactClickableSpan +import com.facebook.react.views.text.internal.span.ReactForegroundColorSpan +import com.facebook.react.views.text.internal.span.ReactLinkSpan +import com.facebook.react.views.text.internal.span.ReactOpacitySpan +import com.facebook.react.views.text.internal.span.ReactStrikethroughSpan +import com.facebook.react.views.text.internal.span.ReactUnderlineSpan +import com.facebook.react.views.text.internal.span.ShadowStyleSpan @OptIn(UnstableReactNativeAPI::class) internal class ReactTextViewAccessibilityDelegate( @@ -189,7 +208,7 @@ internal class ReactTextViewAccessibilityDelegate( // PreparedLayoutTextView isn't actually a TextView, so we need to teach it about its text that // it is holding so TalkBack knows what to announce when focusing it. val accessibilityText = if (host is PreparedLayoutTextView) host.text else info.text - info.text = accessibilityText.toPlainTextForAccessibility() + info.text = accessibilityText.toAccessibilityTextWithoutVisualSpans() } @Suppress("DEPRECATION") @@ -364,5 +383,42 @@ private fun isWholeTextSingleLink(text: Spanned, spans: Array): B return start == 0 && end == text.length } -private fun CharSequence?.toPlainTextForAccessibility(): CharSequence? = - if (this is Spanned) toString() else this +private fun CharSequence?.toAccessibilityTextWithoutVisualSpans(): CharSequence? { + if (this !is Spanned) { + return this + } + + return SpannableString(this).apply { + getSpans(0, length, Any::class.java) + .filter { isVisualSpanForAccessibility(it) } + .forEach { removeSpan(it) } + } +} + +private fun isVisualSpanForAccessibility(span: Any): Boolean { + if ( + span is URLSpan || + span is ReactClickableSpan || + span is ReactLinkSpan || + span is ClickableSpan + ) { + return false + } + + return span is ReactAbsoluteSizeSpan || + span is ReactForegroundColorSpan || + span is ReactBackgroundColorSpan || + span is CustomStyleSpan || + span is CustomLetterSpacingSpan || + span is CustomLineHeightSpan || + span is ReactOpacitySpan || + span is ShadowStyleSpan || + span is ReactUnderlineSpan || + span is ReactStrikethroughSpan || + span is AbsoluteSizeSpan || + span is ForegroundColorSpan || + span is BackgroundColorSpan || + span is StyleSpan || + span is UnderlineSpan || + span is StrikethroughSpan +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt index c3fb01ea20e8..7e350e7cff4d 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt @@ -16,8 +16,11 @@ import android.text.Spanned import android.text.StaticLayout import android.text.TextPaint import android.text.style.AbsoluteSizeSpan +import android.text.style.BackgroundColorSpan import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.URLSpan import android.view.View import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat @@ -37,7 +40,7 @@ class ReactTextViewAccessibilityDelegateTest { assertSourceTextKeepsStyleSpans(textView.text) assertThat(nodeInfo.text.toString()).isEqualTo("Start") - assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java) + assertAccessibilityTextDoesNotHaveVisualSpans(nodeInfo.text) } @Test @@ -49,31 +52,61 @@ class ReactTextViewAccessibilityDelegateTest { assertThat(textView.contentDescription.toString()).isEqualTo("Custom label") assertThat(nodeInfo.text.toString()).isEqualTo("Visible text") - assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java) + assertAccessibilityTextDoesNotHaveVisualSpans(nodeInfo.text) } @Test - fun reactTextViewAccessibilityNodeText_doesNotStripSourceClickableSpans() { + fun reactTextViewAccessibilityNodeText_preservesWholeTextClickableSpan() { val clickableSpan = object : ClickableSpan() { override fun onClick(widget: View) = Unit } val text = createStyledText("Read docs") - text.setSpan(clickableSpan, 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + text.setSpan(clickableSpan, 0, text.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) val textView = createReactTextView(text) val nodeInfo = createNodeInfo(textView) val sourceText = textView.text as Spanned + val accessibilityText = nodeInfo.text as Spanned - assertThat(sourceText.getSpans(0, sourceText.length, ClickableSpan::class.java)).isNotEmpty() assertSourceTextKeepsStyleSpans(sourceText) + assertThat(ReactTextViewAccessibilityDelegate.AccessibilityLinks(sourceText).size()).isEqualTo(0) assertThat(nodeInfo.text.toString()).isEqualTo("Read docs") - assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java) + assertAccessibilityTextDoesNotHaveVisualSpans(accessibilityText) + assertPreservedSpanMatchesSource(sourceText, accessibilityText, clickableSpan) + } + + @Test + fun reactTextViewAccessibilityNodeText_preservesMixedClickableAndUrlSpans() { + val clickableSpan = + object : ClickableSpan() { + override fun onClick(widget: View) = Unit + } + val urlSpan = URLSpan("https://reactnative.dev") + val text = createStyledText("Read docs now") + text.setSpan(clickableSpan, 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + text.setSpan(urlSpan, 0, 4, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + val textView = createReactTextView(text) + + val nodeInfo = createNodeInfo(textView) + val sourceText = textView.text as Spanned + val accessibilityText = nodeInfo.text as Spanned + + assertSourceTextKeepsStyleSpans(sourceText) + assertThat(nodeInfo.text.toString()).isEqualTo("Read docs now") + assertAccessibilityTextDoesNotHaveVisualSpans(accessibilityText) + assertPreservedSpanMatchesSource(sourceText, accessibilityText, clickableSpan) + assertPreservedSpanMatchesSource(sourceText, accessibilityText, urlSpan) } @Test - fun preparedLayoutTextViewAccessibilityNodeText_stripsStyleSpans() { + fun preparedLayoutTextViewAccessibilityNodeText_stripsStyleSpansAndPreservesClickableSpan() { val text = createStyledText("Prepared text") + val clickableSpan = + object : ClickableSpan() { + override fun onClick(widget: View) = Unit + } + text.setSpan(clickableSpan, 0, 8, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) val layout = StaticLayout.Builder.obtain(text, 0, text.length, TextPaint(), 300).build() val textView = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) @@ -96,7 +129,9 @@ class ReactTextViewAccessibilityDelegateTest { assertSourceTextKeepsStyleSpans(textView.text) assertThat(nodeInfo.text.toString()).isEqualTo("Prepared text") - assertThat(nodeInfo.text).isNotInstanceOf(Spanned::class.java) + val accessibilityText = nodeInfo.text as Spanned + assertAccessibilityTextDoesNotHaveVisualSpans(accessibilityText) + assertPreservedSpanMatchesSource(textView.text as Spanned, accessibilityText, clickableSpan) } private fun createReactTextViewWithStyledText(text: String): ReactTextView { @@ -126,6 +161,8 @@ class ReactTextViewAccessibilityDelegateTest { SpannableString(text).apply { setSpan(AbsoluteSizeSpan(48), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) setSpan(ForegroundColorSpan(Color.BLACK), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan(BackgroundColorSpan(Color.WHITE), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan(StyleSpan(android.graphics.Typeface.BOLD), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } private fun createNodeInfo(view: View): AccessibilityNodeInfoCompat = @@ -138,5 +175,37 @@ class ReactTextViewAccessibilityDelegateTest { val spanned = text as Spanned assertThat(spanned.getSpans(0, spanned.length, AbsoluteSizeSpan::class.java)).isNotEmpty() assertThat(spanned.getSpans(0, spanned.length, ForegroundColorSpan::class.java)).isNotEmpty() + assertThat(spanned.getSpans(0, spanned.length, BackgroundColorSpan::class.java)).isNotEmpty() + assertThat(spanned.getSpans(0, spanned.length, StyleSpan::class.java)).isNotEmpty() + } + + private fun assertAccessibilityTextDoesNotHaveVisualSpans(text: CharSequence?) { + if (text !is Spanned) { + return + } + + assertThat(text.getSpans(0, text.length, AbsoluteSizeSpan::class.java)).isEmpty() + assertThat(text.getSpans(0, text.length, ForegroundColorSpan::class.java)).isEmpty() + assertThat(text.getSpans(0, text.length, BackgroundColorSpan::class.java)).isEmpty() + assertThat(text.getSpans(0, text.length, StyleSpan::class.java)).isEmpty() + } + + private fun assertPreservedSpanMatchesSource( + sourceText: Spanned, + accessibilityText: Spanned, + sourceSpan: Any, + ) { + val preservedSpans = + accessibilityText + .getSpans( + sourceText.getSpanStart(sourceSpan), + sourceText.getSpanEnd(sourceSpan), + sourceSpan.javaClass, + ) + .filter { accessibilityText.getSpanStart(it) == sourceText.getSpanStart(sourceSpan) } + .filter { accessibilityText.getSpanEnd(it) == sourceText.getSpanEnd(sourceSpan) } + .filter { accessibilityText.getSpanFlags(it) == sourceText.getSpanFlags(sourceSpan) } + + assertThat(preservedSpans).isNotEmpty() } }