From 65e1dd623c0081b4e5b59496e2f6ba376b31bee5 Mon Sep 17 00:00:00 2001 From: ayush-singh-0601 Date: Sat, 22 Aug 2026 18:58:09 +0530 Subject: [PATCH] Fix RTL start-side text clipping on Android 15+ Wire StaticLayout.Builder.setUseBoundsForWidth and setShiftDrawingOffsetForStartOverhang through reflection so Arabic glyphs that overhang their advance box are drawn at RTL line starts instead of clipped. Apply the same TextView setters for Paper. Fixes #58064 --- .../text/AndroidTextStartOverhangCompat.kt | 92 ++++++++++++ .../react/views/text/ReactTextView.java | 2 + .../react/views/text/TextLayoutManager.kt | 15 +- .../TextLayoutManagerStartOverhangTest.kt | 135 ++++++++++++++++++ 4 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/AndroidTextStartOverhangCompat.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/AndroidTextStartOverhangCompat.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/AndroidTextStartOverhangCompat.kt new file mode 100644 index 000000000000..73067fa320e6 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/AndroidTextStartOverhangCompat.kt @@ -0,0 +1,92 @@ +/* + * 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.os.Build +import android.text.StaticLayout +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import com.facebook.react.util.AndroidVersion +import java.lang.reflect.Method + +/** + * Android 15 (API 35) added StaticLayout / TextView APIs that keep start-side glyph ink from being + * clipped when it extends past the advance box (common for Arabic alef-madda / alef-wasla at an RTL + * line start). + * + * Reflection is required because some internal targets compile against an SDK older than 35, so we + * cannot call [StaticLayout.Builder.setUseBoundsForWidth] or + * [StaticLayout.Builder.setShiftDrawingOffsetForStartOverhang] directly. + * + * These setters change how StaticLayout uses visual bounds for wrapping and drawing. They do not + * implement the two-pass AT_MOST/UNDEFINED width expansion that was previously tried and reverted. + */ +internal object AndroidTextStartOverhangCompat { + + // Looked up only on API 35+, so a missing method does not throw on older devices. + private val builderSetters: Pair by lazy { + Pair( + optionalBooleanSetter(StaticLayout.Builder::class.java, "setUseBoundsForWidth"), + optionalBooleanSetter( + StaticLayout.Builder::class.java, + "setShiftDrawingOffsetForStartOverhang", + ), + ) + } + + private val textViewSetters: Pair by lazy { + Pair( + optionalBooleanSetter(TextView::class.java, "setUseBoundsForWidth"), + optionalBooleanSetter(TextView::class.java, "setShiftDrawingOffsetForStartOverhang"), + ) + } + + @JvmStatic + fun applyToBuilder(builder: StaticLayout.Builder) { + if (Build.VERSION.SDK_INT < AndroidVersion.VERSION_CODE_VANILLA_ICE_CREAM) { + return + } + val (useBoundsForWidth, shiftDrawingOffset) = builderSetters + invokeBooleanSetter(useBoundsForWidth, builder, true) + invokeBooleanSetter(shiftDrawingOffset, builder, true) + } + + @JvmStatic + fun applyToTextView(textView: TextView) { + if (Build.VERSION.SDK_INT < AndroidVersion.VERSION_CODE_VANILLA_ICE_CREAM) { + return + } + val (useBoundsForWidth, shiftDrawingOffset) = textViewSetters + invokeBooleanSetter(useBoundsForWidth, textView, true) + invokeBooleanSetter(shiftDrawingOffset, textView, true) + } + + @VisibleForTesting + internal fun builderStartOverhangApisAvailable(): Boolean { + val (useBoundsForWidth, shiftDrawingOffset) = builderSetters + return useBoundsForWidth != null && shiftDrawingOffset != null + } + + private fun optionalBooleanSetter(clazz: Class<*>, name: String): Method? = + try { + clazz.getMethod(name, Boolean::class.javaPrimitiveType) + } catch (_: ReflectiveOperationException) { + null + } + + private fun invokeBooleanSetter(method: Method?, target: Any, value: Boolean) { + if (method == null) { + return + } + try { + method.invoke(target, value) + } catch (_: ReflectiveOperationException) { + // Runtime image may not match the looked-up API (for example, a preview stub). + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index c20f271bb512..af3c1db4a71c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -97,6 +97,8 @@ private void initView() { mOverflow = Overflow.VISIBLE; mSpanned = null; mPreparedLayout = null; + // Paper TextView builds its own Layout; apply the API 35 start-overhang draw shift here too. + AndroidTextStartOverhangCompat.applyToTextView(this); } /* package */ void recycleView() { 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 66e6f24fbb0a..a41b1d936a4e 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 @@ -112,18 +112,6 @@ internal object TextLayoutManager { private val tagToSpannableCache = ConcurrentHashMap() - // Lazily cached Method for StaticLayout.Builder.setUseBoundsForWidth (API 35+). - // Reflection is needed because some internal targets compile against an SDK older than 35. - private val setUseBoundsForWidthMethod: java.lang.reflect.Method? by lazy { - try { - StaticLayout.Builder::class - .java - .getMethod("setUseBoundsForWidth", Boolean::class.javaPrimitiveType) - } catch (_: ReflectiveOperationException) { - null - } - } - fun setCachedSpannableForTag(reactTag: Int, sp: Spannable): Unit { tagToSpannableCache[reactTag] = sp } @@ -872,6 +860,9 @@ internal object TextLayoutManager { builder.setUseLineSpacingFromFallbacks(true) } + // API 35+: draw start-side glyph overhang (RTL Arabic line starts) instead of clipping it. + AndroidTextStartOverhangCompat.applyToBuilder(builder) + return builder.build() } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt new file mode 100644 index 000000000000..127aec3b5efc --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt @@ -0,0 +1,135 @@ +/* + * 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.os.Build +import android.text.BoringLayout +import android.text.Layout +import android.text.SpannableString +import android.text.TextPaint +import android.text.TextUtils +import android.widget.TextView +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 coverage for Android 15+ RTL start-side glyph clipping (#58064). + * + * Full-width (EXACTLY) paragraphs never go through the AT_MOST visual-bounds measurement path, so + * the drawn StaticLayout must opt into setUseBoundsForWidth + + * setShiftDrawingOffsetForStartOverhang or leading Arabic ink is clipped at the line start. + */ +@RunWith(RobolectricTestRunner::class) +class TextLayoutManagerStartOverhangTest { + + @Test + @Config(sdk = [33]) + fun `createLayout still builds RTL Arabic text on pre-API 35`() { + val layout = invokeCreateLayout(SpannableString(ARABIC_WITH_ALEF_MADDA), width = 200f) + + assertThat(layout.lineCount).isGreaterThan(0) + assertThat(layout.text.toString()).isEqualTo(ARABIC_WITH_ALEF_MADDA) + } + + @Test + @Config(sdk = [35]) + fun `API 35 StaticLayout Builder exposes start overhang setters`() { + assertThat(AndroidTextStartOverhangCompat.builderStartOverhangApisAvailable()).isTrue() + } + + @Test + @Config(sdk = [35]) + fun `EXACTLY layout on API 35 enables bounds width and start overhang shift`() { + val layout = invokeCreateLayout(SpannableString(ARABIC_WITH_ALEF_MADDA), width = 200f) + + assertThat(booleanLayoutGetter(layout, "getUseBoundsForWidth")).isTrue() + assertThat(booleanLayoutGetter(layout, "getShiftDrawingOffsetForStartOverhang")).isTrue() + } + + @Test + @Config(sdk = [35]) + fun `Paper TextView receives the same API 35 start overhang setters`() { + val view = TextView(RuntimeEnvironment.getApplication()) + AndroidTextStartOverhangCompat.applyToTextView(view) + + assertThat(booleanGetter(view, "getUseBoundsForWidth")).isTrue() + assertThat(booleanGetter(view, "getShiftDrawingOffsetForStartOverhang")).isTrue() + } + + /** + * Invokes the private TextLayoutManager.createLayout via reflection. Defaults match a plain + * full-width Fabric paragraph (EXACTLY width, no ellipsize). + */ + @SuppressLint("InlinedApi") + private fun invokeCreateLayout(text: SpannableString, width: Float): Layout { + val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f } + 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 = */ true, + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, + Layout.Alignment.ALIGN_OPPOSITE, + /* justificationMode = */ 0, + /* ellipsizeMode = */ null, + /* maxNumberOfLines = */ -1, + paint, + ) as Layout + } + + private fun booleanLayoutGetter(layout: Layout, name: String): Boolean = + booleanGetter(layout, name) + + private fun booleanGetter(target: Any, name: String): Boolean { + val method = target.javaClass.methods.firstOrNull { it.name == name && it.parameterCount == 0 } + assertThat(method) + .withFailMessage( + "%s.%s() is missing on API %d. Robolectric must be running with an android-all jar that includes the API 35 text overhang APIs.", + target.javaClass.simpleName, + name, + Build.VERSION.SDK_INT, + ) + .isNotNull() + return method!!.invoke(target) as Boolean + } + + private companion object { + // U+0622 (alef madda) is the glyph called out in #58064 as clipping at RTL line start. + const val ARABIC_WITH_ALEF_MADDA = "آية الكرسي" + } +}