Skip to content
Merged
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

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ internal class FabricTextViewUtils(private val reactContext: ReactContext, priva
}

private fun getFontSize(view: TextView, pixelsDensity: Float): Long {
val fontSize = (view.textSize / pixelsDensity).toLong()
return fontSize
val density = pixelsDensity.coerceAtLeast(1f)
return (view.textSize / density).toLong()
}

private fun getFontFamily(textWireframe: MobileSegment.Wireframe.TextWireframe): String {
Expand Down
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) {
Comment thread
jonathanmos marked this conversation as resolved.

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()
}
Comment thread
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")
}
}
Comment thread
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"
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.recorder.MappingContext
import com.datadog.reactnative.sessionreplay.BuildConfig
import com.datadog.reactnative.sessionreplay.utils.DrawableUtils
import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils
import com.facebook.react.bridge.ReactContext

internal abstract class TextViewUtils(private val reactContext: ReactContext, private val drawableUtils: DrawableUtils) {
Expand Down Expand Up @@ -142,27 +141,21 @@ internal abstract class TextViewUtils(private val reactContext: ReactContext, pr

@VisibleForTesting
companion object {
internal const val TEXT_ATTRIBUTES_FIELD_NAME = "mTextAttributes"
internal const val FONT_FAMILY_FIELD_NAME = "mFontFamily"
internal const val COLOR_FIELD_NAME = "mColor"
internal const val IS_COLOR_SET_FIELD_NAME = "mIsColorSet"
internal const val SPANNED_FIELD_NAME = "mSpanned"

private const val ROBOTO_TYPEFACE_NAME = "roboto"
private const val SERIF_FAMILY_NAME = "serif"
private const val SANS_SERIF_FAMILY_NAME = "roboto, sans-serif"
internal const val MONOSPACE_FAMILY_NAME = "monospace"


internal const val RESOLVE_UIMANAGERMODULE_ERROR = "Unable to resolve UIManagerModule"
internal const val RESOLVE_FABRICFIELD_ERROR = "Unable to resolve field from fabric view"
internal const val NULL_FABRICFIELD_ERROR = "Null value found when trying to resolve field from fabric view"


fun create(reactContext: ReactContext, logger: InternalLogger): TextViewUtils {
return when (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
true -> FabricTextViewUtils(reactContext, logger, ReactViewBackgroundDrawableUtils())
false -> LegacyTextViewUtils(reactContext, logger, ReflectionUtils(), ReactViewBackgroundDrawableUtils())
false -> LegacyTextViewUtils(reactContext, logger, ReactViewBackgroundDrawableUtils())
}
}
}
Expand Down
Loading