-
Notifications
You must be signed in to change notification settings - Fork 57
RUMS-5996: Fix ANR in old-arch SR text mapper #1296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jonathanmos
merged 3 commits into
develop
from
jmoskovich/rums-5996/android-old-arch-text-style-resolution
Jun 2, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
70 changes: 0 additions & 70 deletions
70
...replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ShadowNodeWrapper.kt
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
162 changes: 89 additions & 73 deletions
162
...d/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,117 +1,133 @@ | ||
| package com.datadog.reactnative.sessionreplay.utils.text | ||
|
|
||
| import android.graphics.Color | ||
| import android.graphics.Typeface | ||
| import android.text.Spanned | ||
| import android.text.style.AbsoluteSizeSpan | ||
| import android.text.style.ForegroundColorSpan | ||
| import android.widget.TextView | ||
| import androidx.annotation.VisibleForTesting | ||
| import com.datadog.android.api.InternalLogger | ||
| import com.datadog.android.internal.utils.densityNormalized | ||
| import com.datadog.android.sessionreplay.model.MobileSegment | ||
| import com.datadog.reactnative.sessionreplay.ShadowNodeWrapper | ||
| import com.datadog.reactnative.sessionreplay.utils.DrawableUtils | ||
| import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils | ||
| import com.datadog.reactnative.sessionreplay.utils.formatAsRgba | ||
| import com.facebook.react.bridge.ReactContext | ||
| import com.facebook.react.uimanager.UIManagerModule | ||
| import com.facebook.react.views.text.TextAttributes | ||
| import java.util.Locale | ||
|
|
||
| internal class LegacyTextViewUtils( | ||
| private val reactContext: ReactContext, | ||
| reactContext: ReactContext, | ||
| private val logger: InternalLogger, | ||
| private val reflectionUtils: ReflectionUtils, | ||
| drawableUtils: DrawableUtils, | ||
| ) : TextViewUtils(reactContext, drawableUtils) { | ||
|
|
||
| private val uiManager: UIManagerModule? by lazy { | ||
| getUiManagerModule() | ||
| } | ||
|
|
||
| override fun resolveTextStyle( | ||
| textWireframe: MobileSegment.Wireframe.TextWireframe, | ||
| pixelsDensity: Float, | ||
| view: TextView, | ||
| ): MobileSegment.TextStyle? { | ||
| val shadowNodeWrapper: ShadowNodeWrapper = | ||
| ShadowNodeWrapper.getShadowNodeWrapper( | ||
| reactContext = reactContext, | ||
| uiManagerModule = uiManager, | ||
| reflectionUtils = reflectionUtils, | ||
| viewId = view.id, | ||
| ) ?: return null | ||
|
|
||
| val fontFamily = getFontFamily(shadowNodeWrapper) ?: textWireframe.textStyle.family | ||
|
|
||
| val fontSize = getFontSize(shadowNodeWrapper)?.densityNormalized(pixelsDensity) ?: textWireframe.textStyle.size | ||
|
|
||
| val fontColor = getTextColor(shadowNodeWrapper) ?: textWireframe.textStyle.color | ||
| ): MobileSegment.TextStyle { | ||
| val family = resolveFontFamilyFromTypeface(view) | ||
| val sizeSp = resolveTextSize(view, pixelsDensity) | ||
| val color = resolveTextColor(view) | ||
|
|
||
| return MobileSegment.TextStyle( | ||
| family = fontFamily, | ||
| size = fontSize, | ||
| color = fontColor, | ||
| family = family, | ||
| size = sizeSp, | ||
| color = color, | ||
| ) | ||
| } | ||
|
|
||
| private fun getTextColor(shadowNodeWrapper: ShadowNodeWrapper?): String? { | ||
| if (shadowNodeWrapper == null) return null | ||
|
|
||
| val isColorSet = | ||
| shadowNodeWrapper | ||
| .getDeclaredShadowNodeField(IS_COLOR_SET_FIELD_NAME) as Boolean? | ||
|
|
||
| if (isColorSet != true) { | ||
| // Improvement: get default text color if different from black | ||
| return "#000000FF" | ||
| } | ||
| val resolvedColor = | ||
| shadowNodeWrapper | ||
| .getDeclaredShadowNodeField(COLOR_FIELD_NAME) as? Int | ||
| if (resolvedColor != null) { | ||
| return formatAsRgba(resolvedColor) | ||
| private fun resolveTextSize(view: TextView, pixelsDensity: Float): Long { | ||
| val density = pixelsDensity.coerceAtLeast(1f) | ||
| val spanned = view.text as? Spanned | ||
| if (spanned != null) { | ||
| val span = spanned.getSpans(0, spanned.length, AbsoluteSizeSpan::class.java) | ||
| ?.firstOrNull() | ||
| if (span != null) { | ||
| return if (span.dip) span.size.toLong() else (span.size / density).toLong() | ||
| } | ||
| } | ||
|
|
||
| return null | ||
| return (view.textSize / density).toLong() | ||
| } | ||
|
jonathanmos marked this conversation as resolved.
|
||
|
|
||
| private fun getFontSize(shadowNodeWrapper: ShadowNodeWrapper?): Long? { | ||
| if (shadowNodeWrapper == null) return null | ||
|
|
||
| val textAttributes = | ||
| shadowNodeWrapper | ||
| .getDeclaredShadowNodeField(TEXT_ATTRIBUTES_FIELD_NAME) as? TextAttributes? | ||
| if (textAttributes != null) { | ||
| return textAttributes.effectiveFontSize.toLong() | ||
| private fun resolveTextColor(view: TextView): String { | ||
| val spanned = view.text as? Spanned ?: return formatAsRgba(RN_DEFAULT_TEXT_COLOR) | ||
|
|
||
| val span = spanned.getSpans(0, spanned.length, ForegroundColorSpan::class.java) | ||
| ?.firstOrNull() | ||
| // If no ForegroundColorSpan is present, RN has not set an explicit color — fall back to | ||
| // RN's default (opaque black). view.currentTextColor is not used because RN old arch never | ||
| // calls setTextColor(); color is always applied via spans, so currentTextColor reflects | ||
| // the Android theme default rather than the actual rendered color. | ||
| return if (span != null) { | ||
| formatAsRgba(span.foregroundColor) | ||
| } else { | ||
| formatAsRgba(RN_DEFAULT_TEXT_COLOR) | ||
| } | ||
|
|
||
| return null | ||
| } | ||
|
|
||
| private fun getFontFamily(shadowNodeWrapper: ShadowNodeWrapper?): String? { | ||
| if (shadowNodeWrapper == null) return null | ||
|
|
||
| val fontFamily = | ||
| shadowNodeWrapper | ||
| .getDeclaredShadowNodeField(FONT_FAMILY_FIELD_NAME) as? String | ||
| private fun resolveFontFamilyFromTypeface(view: TextView): String { | ||
| resolveFontFamilyFromSpans(view)?.let { return resolveFontFamily(it.lowercase(Locale.US)) } | ||
|
|
||
| if (fontFamily != null) { | ||
| return resolveFontFamily(fontFamily.lowercase(Locale.US)) | ||
| // Fallback for non-RN views. Typeface.familyName requires API 28, so we use identity | ||
| // comparison against the standard singletons instead. | ||
| return when (view.typeface) { | ||
| Typeface.MONOSPACE -> MONOSPACE_FAMILY_NAME | ||
| Typeface.SERIF -> resolveFontFamily("serif") | ||
| else -> resolveFontFamily("roboto") | ||
| } | ||
| } | ||
|
jonathanmos marked this conversation as resolved.
|
||
|
|
||
| return null | ||
| private val customStyleSpanClass: Class<*>? by lazy { | ||
| try { | ||
| Class.forName(CUSTOM_STYLE_SPAN_CLASS_NAME) | ||
| } catch (e: ClassNotFoundException) { | ||
| logger.log( | ||
| level = InternalLogger.Level.WARN, | ||
| targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), | ||
| messageBuilder = { CUSTOM_STYLE_SPAN_CLASS_NOT_FOUND_ERROR }, | ||
| throwable = e, | ||
| ) | ||
| null | ||
| } | ||
| } | ||
|
|
||
| // store to avoid calling it multiple times | ||
| @VisibleForTesting | ||
| internal fun getUiManagerModule(): UIManagerModule? { | ||
| // The class is loaded by name to avoid a hard compile-time dependency on an RN-internal type. | ||
| // `spanClass as Class<Any>` is a generic (erased) cast — safe at runtime. | ||
| private fun resolveFontFamilyFromSpans(view: TextView): String? { | ||
| val spanned = view.text as? Spanned ?: return null | ||
| val spanClass = customStyleSpanClass ?: return null | ||
|
|
||
| @Suppress("UNCHECKED_CAST") | ||
| val spans = spanned.getSpans(0, spanned.length, spanClass as Class<Any>) ?: return null | ||
| val span = spans.firstOrNull() ?: return null | ||
|
|
||
| return try { | ||
| reactContext.getNativeModule(UIManagerModule::class.java) | ||
| } catch (e: IllegalStateException) { | ||
| span.javaClass.getMethod(GET_FONT_FAMILY_METHOD).invoke(span) as? String | ||
| } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { | ||
| logger.log( | ||
| level = InternalLogger.Level.WARN, | ||
| targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), | ||
| messageBuilder = { RESOLVE_UIMANAGERMODULE_ERROR }, | ||
| messageBuilder = { RESOLVE_FONT_FAMILY_FROM_SPAN_ERROR }, | ||
| throwable = e, | ||
| ) | ||
| return null | ||
| null | ||
| } | ||
| } | ||
|
|
||
| companion object { | ||
| // RN old arch applies color exclusively via ForegroundColorSpan and never calls | ||
| // setTextColor(), so view.currentTextColor returns the Android theme default rather than | ||
| // the actual rendered color. When no span is present, fall back to RN's own default. | ||
| internal val RN_DEFAULT_TEXT_COLOR = Color.BLACK | ||
|
|
||
| private const val CUSTOM_STYLE_SPAN_CLASS_NAME = | ||
| "com.facebook.react.views.text.internal.span.CustomStyleSpan" | ||
| private const val GET_FONT_FAMILY_METHOD = "getFontFamily" | ||
|
|
||
| internal const val CUSTOM_STYLE_SPAN_CLASS_NOT_FOUND_ERROR = | ||
| "CustomStyleSpan class not found — font family will fall back to typeface comparison. " + | ||
| "The class may have been moved or renamed in this version of React Native." | ||
| internal const val RESOLVE_FONT_FAMILY_FROM_SPAN_ERROR = | ||
| "Unable to resolve font family from CustomStyleSpan via reflection" | ||
| } | ||
|
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.