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 = "آية الكرسي" + } +}