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
@@ -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<Method?, Method?> by lazy {
Pair(
optionalBooleanSetter(StaticLayout.Builder::class.java, "setUseBoundsForWidth"),
optionalBooleanSetter(
StaticLayout.Builder::class.java,
"setShiftDrawingOffsetForStartOverhang",
),
)
}

private val textViewSetters: Pair<Method?, Method?> 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).
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,18 +112,6 @@ internal object TextLayoutManager {

private val tagToSpannableCache = ConcurrentHashMap<Int, Spannable>()

// 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
}
Expand Down Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 = "آية الكرسي"
}
}