Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,8 @@ public PreparedLayout reusePreparedLayoutWithNewReactTags(
reactTags,
preparedLayout.getTextBreakStrategy(),
preparedLayout.getJustificationMode(),
preparedLayout.getBaseTextAttributes());
preparedLayout.getBaseTextAttributes(),
preparedLayout.getSourceText());
}

@AnyThread
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,9 @@ internal class PreparedLayout(
val textBreakStrategy: Int,
val justificationMode: Int,
val baseTextAttributes: TextAttributeProps,
// The full, un-ellipsized logical text. Required (no default) because when the layout is
// ellipsized `layout.text` is an Android `Ellipsizer` whose `toString()` is only the on-screen
// (truncated) text; every caller must consciously supply the complete string so accessibility
// and view-hierarchy consumers never fall back to the truncated text.
val sourceText: CharSequence,
)
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ 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
// reflection. Returns the full logical text, not `layout.text`, which can be an ellipsized
// `Ellipsizer` exposing only the on-screen (truncated) string to accessibility and the view
// hierarchy; internal rendering/hit-testing continues to use `layout.text` directly.
@DoNotStrip get() = preparedLayout?.sourceText

init {
// ViewGroup by default says only its children will draw
Expand Down Expand Up @@ -136,7 +138,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()

Expand Down Expand Up @@ -242,7 +244,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)
}
Expand Down Expand Up @@ -314,7 +316,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()) {
Expand Down Expand Up @@ -503,6 +505,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
textBreakStrategy,
justificationMode,
baseTextAttributes,
sourceText,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,8 @@ internal object TextLayoutManager {
}
}

private fun createLayout(
@VisibleForTesting
internal fun createLayout(
text: Spannable,
boring: BoringLayout.Metrics?,
width: Float,
Expand Down Expand Up @@ -1356,6 +1357,8 @@ internal object TextLayoutManager {
result.textBreakStrategy,
result.justificationMode,
baseTextAttributes,
// `result.layout.text` may be ellipsized; keep the full text for a11y.
sourceText = text,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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.TextPaint
import android.text.TextUtils
import com.facebook.react.common.annotations.UnstableReactNativeAPI
import com.facebook.react.common.mapbuffer.WritableMapBuffer
import com.facebook.yoga.YogaMeasureMode
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config

/**
* Regression test for prepared text layout exposing truncated text to accessibility / the view
* hierarchy.
*
* When a prepared [Layout] is ellipsized, `layout.text` is an Android `Ellipsizer` whose
* `toString()` is only the on-screen (truncated) string. [PreparedLayoutTextView.getText] — read by
* the accessibility node and by black-box E2E view-hierarchy tooling — must instead return the full
* logical text, matching a non-prepared `TextView` whose `getText()` returns the full source.
*/
@OptIn(UnstableReactNativeAPI::class)
@RunWith(RobolectricTestRunner::class)
@SuppressLint("NewApi")
class PreparedLayoutTextViewAccessibilityTextTest {

private val fullText =
listOf(
"This is an order created from the data builder",
"second line of the description",
"third line of the description",
)
.joinToString("\n")

@Test
@Config(sdk = [34])
fun `getText returns the full logical text when the prepared layout is ellipsized`() {
val layout = createEllipsizedLayout()

// Guard: the scenario is genuinely ellipsized, so layout.text is the truncated on-screen text.
assertThat(layout.getEllipsisCount(layout.lineCount - 1)).isGreaterThan(0)
assertThat(layout.text.toString()).contains("…")
assertThat(layout.text.toString()).isNotEqualTo(fullText)

val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication())
view.preparedLayout =
PreparedLayout(
layout,
/* maximumNumberOfLines = */ 1,
/* verticalOffset = */ 0f,
/* reactTags = */ IntArray(0),
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
/* justificationMode = */ 0,
baseTextAttributes = TextAttributeProps.fromMapBuffer(WritableMapBuffer()),
/* sourceText = */ fullText,
)

assertThat(view.text?.toString()).isEqualTo(fullText)
assertThat(view.text?.toString()).doesNotContain("…")
}

private fun createEllipsizedLayout(): Layout =
TextLayoutManager.createLayout(
SpannableString(fullText),
/* boring = */ null,
/* width = */ 200f,
YogaMeasureMode.EXACTLY,
/* includeFontPadding = */ false,
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
Layout.Alignment.ALIGN_NORMAL,
/* justificationMode = */ 0,
/* ellipsizeMode = */ TextUtils.TruncateAt.END,
/* maxNumberOfLines = */ 1,
TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f },
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import android.text.BoringLayout
import android.text.Layout
import android.text.SpannableString
import android.text.TextPaint
import android.text.TextUtils
import com.facebook.yoga.YogaMeasureMode
import kotlin.math.ceil
import org.assertj.core.api.Assertions.assertThat
Expand Down Expand Up @@ -78,9 +77,8 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest {
}

/**
* Invokes the private TextLayoutManager.createLayout via reflection. We can't call it directly
* because it's `private` (friend_paths only opens up `internal`). Default values mirror what
* measureText() passes in the production path for a plain single-paragraph label.
* Default values mirror what measureText() passes in the production path for a plain
* single-paragraph label.
*
* BREAK_STRATEGY_HIGH_QUALITY and HYPHENATION_FREQUENCY_NONE are API 23+ constants. The test runs
* only on Robolectric (JVM), never on a device, so the inlined integer values are safe.
Expand All @@ -90,42 +88,19 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest {
text: SpannableString,
width: Float,
paint: TextPaint,
): Layout {
val boring: BoringLayout.Metrics? = BoringLayout.isBoring(text, paint)
val method =
TextLayoutManager::class
.java
.getDeclaredMethod(
"createLayout",
android.text.Spannable::class.java,
BoringLayout.Metrics::class.java,
java.lang.Float.TYPE,
YogaMeasureMode::class.java,
java.lang.Boolean.TYPE,
java.lang.Integer.TYPE,
java.lang.Integer.TYPE,
Layout.Alignment::class.java,
java.lang.Integer.TYPE,
TextUtils.TruncateAt::class.java,
java.lang.Integer.TYPE,
TextPaint::class.java,
)
.apply { isAccessible = true }

return method.invoke(
TextLayoutManager,
text,
boring,
width,
YogaMeasureMode.EXACTLY,
/* includeFontPadding = */ false,
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
Layout.Alignment.ALIGN_NORMAL,
/* justificationMode = */ 0,
/* ellipsizeMode = */ null,
/* maxNumberOfLines = */ 2,
paint,
) as Layout
}
): Layout =
TextLayoutManager.createLayout(
text,
BoringLayout.isBoring(text, paint),
width,
YogaMeasureMode.EXACTLY,
/* includeFontPadding = */ false,
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
Layout.Alignment.ALIGN_NORMAL,
/* justificationMode = */ 0,
/* ellipsizeMode = */ null,
/* maxNumberOfLines = */ 2,
paint,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ package com.facebook.react.views.text

import android.annotation.SuppressLint
import android.graphics.RectF
import android.text.BoringLayout
import android.text.Layout
import android.text.SpannableString
import android.text.TextPaint
Expand Down Expand Up @@ -132,43 +131,21 @@ class TextLayoutManagerStartOverhangTest {
ellipsizeMode: TextUtils.TruncateAt? = null,
maxNumberOfLines: Int = 2,
paint: TextPaint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f },
): Layout {
val method =
TextLayoutManager::class
.java
.getDeclaredMethod(
"createLayout",
android.text.Spannable::class.java,
BoringLayout.Metrics::class.java,
java.lang.Float.TYPE,
YogaMeasureMode::class.java,
java.lang.Boolean.TYPE,
java.lang.Integer.TYPE,
java.lang.Integer.TYPE,
Layout.Alignment::class.java,
java.lang.Integer.TYPE,
TextUtils.TruncateAt::class.java,
java.lang.Integer.TYPE,
TextPaint::class.java,
)
.apply { isAccessible = true }

return method.invoke(
TextLayoutManager,
text,
null,
layoutWidth,
widthMode,
/* includeFontPadding = */ false,
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
Layout.Alignment.ALIGN_NORMAL,
/* justificationMode = */ 0,
/* ellipsizeMode = */ ellipsizeMode,
/* maxNumberOfLines = */ maxNumberOfLines,
paint,
) as Layout
}
): Layout =
TextLayoutManager.createLayout(
text,
/* boring = */ null,
layoutWidth,
widthMode,
/* includeFontPadding = */ false,
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
Layout.Alignment.ALIGN_NORMAL,
/* justificationMode = */ 0,
/* ellipsizeMode = */ ellipsizeMode,
/* maxNumberOfLines = */ maxNumberOfLines,
paint,
)

private fun getBooleanLayoutProperty(layout: Layout, methodName: String): Boolean =
layout.javaClass.getMethod(methodName).invoke(layout) as Boolean
Expand Down
Loading