From 73029a5a6bb25bedef85db4c4c8046b8c56d8dbf Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Wed, 14 Oct 2026 10:26:37 -0700 Subject: [PATCH] Preserve source text in prepared text layouts Summary: Prepared text layouts used `Layout.text` for both rendering and semantic consumers. Android replaces that text with an ellipsis and filler characters when the layout is truncated, causing accessibility services and view hierarchy tools to lose the full logical string. Retain the source `Spanned` text alongside the display `Layout` and preserve it through layout reuse and stateful-span proxying. Semantic consumers now receive the full source text, while drawing, touch handling, and hit testing continue to use the display-shaped layout text. Changelog: [Android][Fixed] - Preserve full semantic text for ellipsized prepared text layouts Differential Revision: D125246577 --- .../react/fabric/FabricUIManager.java | 8 +- .../views/text/MutableSpannableLayout.kt | 50 ++-- .../react/views/text/PreparedLayout.kt | 19 +- .../views/text/PreparedLayoutTextView.kt | 16 +- .../react/views/text/ReactTextViewManager.kt | 7 +- .../react/views/text/TextLayoutManager.kt | 1 + .../views/text/PreparedLayoutTextViewTest.kt | 250 ++++++++++++++++++ 7 files changed, 316 insertions(+), 35 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index 97de9935b8cf..c3cd11723364 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -706,13 +706,7 @@ public PreparedLayout prepareTextLayout( @UnstableReactNativeAPI public PreparedLayout reusePreparedLayoutWithNewReactTags( PreparedLayout preparedLayout, int[] reactTags) { - return new PreparedLayout( - preparedLayout.getLayout(), - preparedLayout.getMaximumNumberOfLines(), - preparedLayout.getVerticalOffset(), - reactTags, - preparedLayout.getTextBreakStrategy(), - preparedLayout.getJustificationMode()); + return preparedLayout.copyWithReactTags(reactTags); } @AnyThread diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt index c4e16d8718bc..d61916d9a583 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt @@ -12,14 +12,16 @@ import android.text.SpannableString import android.text.Spanned import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.views.text.internal.span.StatefulSpan +import java.util.IdentityHashMap /** - * A delegating [Layout] subclass that clones the spannable text from [delegate] and replaces all - * [StatefulSpan] instances with fresh clones. This gives each [PreparedLayoutTextView] independent - * mutable span state (e.g. particle animation, dismiss state) even when the underlying [Layout] is - * shared from a cache. + * A delegating [Layout] subclass that clones both the display-shaped text from [delegate] and its + * full source text. Corresponding [StatefulSpan] instances in both copies are replaced with the + * same fresh clone, while spans present in only one copy are cloned on demand. This gives each + * [PreparedLayoutTextView] independent mutable span state (e.g. particle animation, dismiss state) + * even when the underlying [Layout] is shared from a cache. * - * The mutable [Spannable] can be useful for spans which affect display, but do not alter existing + * The mutable display text can be useful for spans which affect display, but do not alter existing * layout calculations. * * Line metrics are delegated to [delegate] so no expensive [StaticLayout] rebuild is needed. @@ -30,6 +32,7 @@ internal class MutableSpannableLayout private constructor( private val delegate: Layout, clonedText: SpannableString, + val sourceText: Spanned, ) : Layout( clonedText, @@ -43,22 +46,39 @@ private constructor( companion object { /** Returns a [MutableSpannableLayout] if [layout] contains stateful spans, else null. */ @OptIn(UnstableReactNativeAPI::class) - fun createIfNeeded(layout: Layout): MutableSpannableLayout? { - val spanned = layout.text as? Spanned ?: return null - val statefulSpans = spanned.getSpans(0, spanned.length, StatefulSpan::class.java) + fun createIfNeeded(layout: Layout, sourceText: Spanned): MutableSpannableLayout? { + val layoutText = layout.text as? Spanned ?: return null + val statefulSpans = layoutText.getSpans(0, layoutText.length, StatefulSpan::class.java) if (statefulSpans.isEmpty()) { return null } - val cloned = SpannableString(spanned) + val spanClones = IdentityHashMap() + val clonedSourceText = SpannableString(sourceText) + for (oldSpan in sourceText.getSpans(0, sourceText.length, StatefulSpan::class.java)) { + val newSpan = spanClones.getOrPut(oldSpan) { oldSpan.clone() } + replaceSpan(clonedSourceText, oldSpan, newSpan) + } + + val clonedLayoutText = SpannableString(layoutText) for (oldSpan in statefulSpans) { - val start = cloned.getSpanStart(oldSpan) - val end = cloned.getSpanEnd(oldSpan) - val flags = cloned.getSpanFlags(oldSpan) - cloned.removeSpan(oldSpan) - cloned.setSpan(oldSpan.clone(), start, end, flags) + val newSpan = spanClones.getOrPut(oldSpan) { oldSpan.clone() } + replaceSpan(clonedLayoutText, oldSpan, newSpan) } - return MutableSpannableLayout(layout, cloned) + return MutableSpannableLayout(layout, clonedLayoutText, clonedSourceText) + } + + @OptIn(UnstableReactNativeAPI::class) + private fun replaceSpan( + text: SpannableString, + oldSpan: StatefulSpan, + newSpan: StatefulSpan, + ) { + val start = text.getSpanStart(oldSpan) + val end = text.getSpanEnd(oldSpan) + val flags = text.getSpanFlags(oldSpan) + text.removeSpan(oldSpan) + text.setSpan(newSpan, start, end, flags) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt index 51836c884989..075a162eec4f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt @@ -8,18 +8,31 @@ package com.facebook.react.views.text import android.text.Layout +import android.text.Spanned import com.facebook.proguard.annotations.DoNotStrip /** - * Encapsulates an {android.text.Layout} along with any additional state needed to render or measure - * it. + * Encapsulates an [android.text.Layout] along with the full source text and any additional state + * needed to render or measure it. */ @DoNotStrip internal class PreparedLayout( val layout: Layout, + val sourceText: Spanned, val maximumNumberOfLines: Int, val verticalOffset: Float, val reactTags: IntArray, val textBreakStrategy: Int, val justificationMode: Int, -) +) { + fun copyWithReactTags(reactTags: IntArray): PreparedLayout = + PreparedLayout( + layout, + sourceText, + maximumNumberOfLines, + verticalOffset, + reactTags, + textBreakStrategy, + justificationMode, + ) +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 4202be214106..48ec8f125879 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -93,7 +93,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val text: CharSequence? // Avoid mangling the getter name, to allow black box E2E tests to read text content via // reflection - @DoNotStrip get() = preparedLayout?.layout?.text + @DoNotStrip get() = preparedLayout?.sourceText init { // ViewGroup by default says only its children will draw @@ -136,7 +136,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re selectionColor ?: DefaultStyleValuesUtil.getDefaultTextColorHighlight(context) } - val spanned = text as? Spanned + val spanned = layout.text as? Spanned val canvasEffectSpans = spanned?.getSpans(0, spanned.length, CanvasEffectSpan::class.java) ?: emptyArray() @@ -242,7 +242,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val action = event.actionMasked if (action == MotionEvent.ACTION_CANCEL) { // Forward ACTION_CANCEL to all TouchableSpans so they can reset pressed/animation state - val spanned = text as? Spanned + val spanned = preparedLayout?.layout?.text as? Spanned for (span in spanned?.getSpans(0, spanned.length, TouchableSpan::class.java).orEmpty()) { span.onTouchEvent(action, 0f, 0f) } @@ -314,7 +314,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re return null } - val spanned = text as? Spanned ?: return null + val spanned = preparedLayout?.layout?.text as? Spanned ?: return null val spans = spanned.getSpans(offset, offset, clazz) if (spans.isEmpty()) { @@ -490,13 +490,15 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } /** - * If the layout contains [StatefulSpan]s, returns a new [PreparedLayout] whose spannable has - * independent clones of those spans. Otherwise returns the receiver unchanged. + * If the layout contains [StatefulSpan]s, returns a new [PreparedLayout] whose display and + * source text share independent clones of those spans. Otherwise returns the receiver + * unchanged. */ private fun PreparedLayout.maybeProxyStatefulSpans(): PreparedLayout { - val proxyLayout = MutableSpannableLayout.createIfNeeded(layout) ?: return this + val proxyLayout = MutableSpannableLayout.createIfNeeded(layout, sourceText) ?: return this return PreparedLayout( proxyLayout, + proxyLayout.sourceText, maximumNumberOfLines, verticalOffset, reactTags, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index 92d781317a8d..1149b580ef7b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -17,6 +17,7 @@ import android.text.Spanned import android.text.TextUtils import android.text.util.Linkify import android.view.Gravity +import androidx.annotation.VisibleForTesting import com.facebook.common.logging.FLog import com.facebook.react.R import com.facebook.react.bridge.Dynamic @@ -207,13 +208,13 @@ public constructor( /** * Constructs a [ReactTextUpdate] from a [PreparedLayout] received via [ReferenceStateWrapper]. */ - private fun getReactTextUpdateFromPreparedLayout( + @VisibleForTesting + internal fun getReactTextUpdateFromPreparedLayout( view: ReactTextView, preparedLayout: PreparedLayout, ): ReactTextUpdate { val layout = preparedLayout.layout - val text = layout.text - val spanned = if (text is Spannable) text else SpannableString(text) + val spanned = SpannableString(preparedLayout.sourceText) view.setSpanned(spanned) view.setPreparedLayout(preparedLayout) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index d9c28ca5fa20..8d46030a2f7a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -1328,6 +1328,7 @@ internal object TextLayoutManager { return PreparedLayout( result.layout, + text, maximumNumberOfLines, verticalOffset, reactTags, diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewTest.kt new file mode 100644 index 000000000000..7e68eb21eff4 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewTest.kt @@ -0,0 +1,250 @@ +/* + * 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. + */ + +package com.facebook.react.views.text + +import android.annotation.SuppressLint +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.ClickableSpan +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.views.text.internal.span.StatefulSpan +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@OptIn(UnstableReactNativeAPI::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@SuppressLint("NewApi") +class PreparedLayoutTextViewTest { + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + } + + @Test + fun ellipsizedLayoutExposesFullSourceTextWithoutChangingDisplayText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = true) + val displayText = preparedLayout.layout.text.toString() + val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + + view.preparedLayout = preparedLayout + + assertThat(displayText).contains(ELLIPSIS) + assertThat(displayText).contains(ELLIPSIS_FILLER) + assertThat(displayText).isNotEqualTo(SOURCE_TEXT) + assertThat(displayText.length).isEqualTo(SOURCE_TEXT.length) + assertThat(view.text.toString()).isEqualTo(SOURCE_TEXT) + assertThat(view.preparedLayout?.layout?.text.toString()).isEqualTo(displayText) + } + + @Test + fun nonEllipsizedLayoutExposesFullSourceText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = false) + val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + + view.preparedLayout = preparedLayout + + assertThat(preparedLayout.layout.text.toString()).isEqualTo(SOURCE_TEXT) + assertThat(view.text.toString()).isEqualTo(SOURCE_TEXT) + } + + @Test + fun ellipsizedSpannedTextRetainsFullClickableSourceText() { + val clickableSpan = + object : ClickableSpan() { + override fun onClick(widget: View) = Unit + } + val sourceText = SpannableString(SOURCE_TEXT) + sourceText.setSpan( + clickableSpan, + 0, + sourceText.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + val preparedLayout = createPreparedLayout(sourceText, ellipsize = true) + val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + + view.preparedLayout = preparedLayout + + val semanticText = view.text as Spanned + assertThat(semanticText.toString()).isEqualTo(SOURCE_TEXT) + assertThat(semanticText.getSpans(0, semanticText.length, ClickableSpan::class.java)) + .containsExactly(clickableSpan) + assertThat(preparedLayout.layout.text.toString()).contains(ELLIPSIS) + } + + @Test + fun accessibilityDelegateExposesFullSourceText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = true) + val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + view.preparedLayout = preparedLayout + val delegate = ReactTextViewAccessibilityDelegate(view, false, view.importantForAccessibility) + val nodeInfo = AccessibilityNodeInfoCompat.obtain() + + delegate.onInitializeAccessibilityNodeInfo(view, nodeInfo) + + assertThat(nodeInfo.text.toString()).isEqualTo(SOURCE_TEXT) + assertThat(preparedLayout.layout.text.toString()).contains(ELLIPSIS) + } + + @Test + fun managerInstallsTextDelegateForPlainPreparedText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = true) + val manager = PreparedLayoutTextViewManager() + val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + manager.updateExtraData(view, preparedLayout) + + val delegate = ViewCompat.getAccessibilityDelegate(view) + assertThat(delegate).isInstanceOf(ReactTextViewAccessibilityDelegate::class.java) + val nodeInfo = AccessibilityNodeInfoCompat.obtain() + checkNotNull(delegate).onInitializeAccessibilityNodeInfo(view, nodeInfo) + assertThat(nodeInfo.text.toString()).isEqualTo(SOURCE_TEXT) + } + + @Test + fun legacyTextViewFallbackUsesFullSourceText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = true) + val manager = ReactTextViewManager() + val view = ReactTextView(RuntimeEnvironment.getApplication()) + + val update = manager.getReactTextUpdateFromPreparedLayout(view, preparedLayout) + + assertThat(update.text.toString()).isEqualTo(SOURCE_TEXT) + assertThat(update.text).isNotSameAs(preparedLayout.sourceText) + assertThat(view.spanned).isSameAs(update.text) + assertThat(view.preparedLayout).isSameAs(preparedLayout) + assertThat(preparedLayout.layout.text.toString()).contains(ELLIPSIS) + } + + @Test + fun reactTagCopyRetainsFullSourceText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = true) + val newReactTags = intArrayOf(10, 20) + + val copiedLayout = preparedLayout.copyWithReactTags(newReactTags) + + assertThat(copiedLayout.sourceText).isSameAs(preparedLayout.sourceText) + assertThat(copiedLayout.layout).isSameAs(preparedLayout.layout) + assertThat(copiedLayout.reactTags).containsExactly(10, 20) + assertThat(copiedLayout.sourceText.toString()).isEqualTo(SOURCE_TEXT) + } + + @Test + fun statefulSpanProxyRetainsFullSourceTextAndSharesPerViewCloneWithDisplayText() { + val originalSpan = TestStatefulSpan() + val sourceText = SpannableString(SOURCE_TEXT) + val spanStart = 5 + val spanEnd = sourceText.length - 5 + sourceText.setSpan( + originalSpan, + spanStart, + spanEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + val preparedLayout = createPreparedLayout(sourceText, ellipsize = true) + val firstView = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + val secondView = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + + firstView.preparedLayout = preparedLayout + secondView.preparedLayout = preparedLayout + + val firstPreparedLayout = checkNotNull(firstView.preparedLayout) + val secondPreparedLayout = checkNotNull(secondView.preparedLayout) + val firstSourceText = firstPreparedLayout.sourceText + val firstDisplayText = firstPreparedLayout.layout.text as Spanned + val secondSourceText = secondPreparedLayout.sourceText + val firstSourceSpan = firstSourceText.getStatefulSpan() + val firstDisplaySpan = firstDisplayText.getStatefulSpan() + val secondSourceSpan = secondSourceText.getStatefulSpan() + + assertThat(firstPreparedLayout.sourceText.toString()).isEqualTo(SOURCE_TEXT) + assertThat(firstPreparedLayout.layout.text.toString()).contains(ELLIPSIS) + assertThat(firstSourceSpan).isSameAs(firstDisplaySpan) + assertThat(firstSourceSpan).isNotSameAs(originalSpan) + assertThat(firstSourceSpan).isNotSameAs(secondSourceSpan) + assertThat(firstSourceText.getSpanStart(firstSourceSpan)).isEqualTo(spanStart) + assertThat(firstSourceText.getSpanEnd(firstSourceSpan)).isEqualTo(spanEnd) + assertThat(firstDisplayText.getSpanStart(firstDisplaySpan)).isEqualTo(spanStart) + assertThat(firstDisplayText.getSpanEnd(firstDisplaySpan)).isEqualTo(spanEnd) + assertThat(secondSourceText.getSpanStart(secondSourceSpan)).isEqualTo(spanStart) + assertThat(secondSourceText.getSpanEnd(secondSourceSpan)).isEqualTo(spanEnd) + } + + private fun createPreparedLayout(text: CharSequence, ellipsize: Boolean): PreparedLayout { + val sourceText = if (text is Spanned) text else SpannableString(text) + val displayText = if (ellipsize) createEllipsizedDisplayText(sourceText) else sourceText + val layout = + StaticLayout.Builder.obtain( + displayText, + 0, + displayText.length, + TextPaint().apply { textSize = 24f }, + 1000, + ) + .setIncludePad(false) + .build() + + return PreparedLayout( + layout, + sourceText, + if (ellipsize) 1 else -1, + 0f, + intArrayOf(1), + Layout.BREAK_STRATEGY_SIMPLE, + 0, + ) + } + + private fun createEllipsizedDisplayText(sourceText: Spanned): Spanned { + val ellipsisStart = sourceText.length / 2 + val displayText = + SpannableString( + buildString(sourceText.length) { + append(sourceText, 0, ellipsisStart) + append(ELLIPSIS) + repeat(sourceText.length - ellipsisStart - 1) { append(ELLIPSIS_FILLER) } + } + ) + TextUtils.copySpansFrom( + sourceText, + 0, + sourceText.length, + Any::class.java, + displayText, + 0, + ) + return displayText + } + + private fun Spanned.getStatefulSpan(): TestStatefulSpan = + getSpans(0, length, TestStatefulSpan::class.java).single() + + private class TestStatefulSpan : StatefulSpan { + override fun clone(): StatefulSpan = TestStatefulSpan() + } + + private companion object { + const val SOURCE_TEXT = "This is an order created from the data builder" + const val ELLIPSIS = "\u2026" + const val ELLIPSIS_FILLER = "\uFEFF" + } +}