From db7dfe6215edc47c9220da2cf927c4f3d7e19fb2 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:53:46 +0300 Subject: [PATCH 1/3] RUMS-5996: Fix ANR in old-arch SR text mapper by removing synchronous shadow node fetch --- .../utils/text/LegacyTextViewUtils.kt | 129 ++++++----- .../sessionreplay/utils/text/TextViewUtils.kt | 3 +- .../utils/text/TextViewUtilsTest.kt | 218 +++++++++--------- 3 files changed, 182 insertions(+), 168 deletions(-) diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt index 44bc9a9a3..a7cb86643 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt @@ -1,105 +1,112 @@ package com.datadog.reactnative.sessionreplay.utils.text +import android.graphics.Typeface +import android.text.Spanned +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, 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 = (view.textSize / pixelsDensity).toLong() + 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? + private fun resolveTextColor(view: TextView): String { + val spanned = view.text as? Spanned ?: return formatAsRgba(view.currentTextColor) - if (isColorSet != true) { - // Improvement: get default text color if different from black - return "#000000FF" + val span = spanned.getSpans(0, spanned.length, ForegroundColorSpan::class.java).firstOrNull() + return if (span != null) { + formatAsRgba(span.foregroundColor) + } else { + formatAsRgba(view.currentTextColor) } - val resolvedColor = - shadowNodeWrapper - .getDeclaredShadowNodeField(COLOR_FIELD_NAME) as? Int - if (resolvedColor != null) { - return formatAsRgba(resolvedColor) - } - - return null } - private fun getFontSize(shadowNodeWrapper: ShadowNodeWrapper?): Long? { - if (shadowNodeWrapper == null) return null + private fun resolveFontFamilyFromTypeface(view: TextView): String { + resolveFontFamilyFromSpans(view)?.let { return resolveFontFamily(it) } - val textAttributes = - shadowNodeWrapper - .getDeclaredShadowNodeField(TEXT_ATTRIBUTES_FIELD_NAME) as? TextAttributes? - if (textAttributes != null) { - return textAttributes.effectiveFontSize.toLong() + // 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") } + } - 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 + } } - private fun getFontFamily(shadowNodeWrapper: ShadowNodeWrapper?): String? { - if (shadowNodeWrapper == null) return null + // The class is loaded by name to avoid a hard compile-time dependency on an RN-internal type. + // `spanClass as Class` 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 - val fontFamily = - shadowNodeWrapper - .getDeclaredShadowNodeField(FONT_FAMILY_FIELD_NAME) as? String + @Suppress("UNCHECKED_CAST") + val spans = spanned.getSpans(0, spanned.length, spanClass as Class) ?: return null + val span = spans.firstOrNull() ?: return null - if (fontFamily != null) { - return resolveFontFamily(fontFamily.lowercase(Locale.US)) + return try { + 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_FONT_FAMILY_FROM_SPAN_ERROR }, + throwable = e, + ) + null } + } + + companion object { + private const val CUSTOM_STYLE_SPAN_CLASS_NAME = + "com.facebook.react.views.text.internal.span.CustomStyleSpan" + private const val GET_FONT_FAMILY_METHOD = "getFontFamily" - return null + 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" } - // store to avoid calling it multiple times + // Kept for backward-compat callers and tests. @VisibleForTesting internal fun getUiManagerModule(): UIManagerModule? { return try { diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtils.kt index 603115409..9133c8530 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtils.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtils.kt @@ -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) { @@ -162,7 +161,7 @@ internal abstract class TextViewUtils(private val reactContext: ReactContext, pr 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()) } } } diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtilsTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtilsTest.kt index 76ef8f6e8..33dc0e1f7 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtilsTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtilsTest.kt @@ -9,6 +9,7 @@ package com.datadog.reactnative.sessionreplay.utils.text import android.content.res.Resources import android.graphics.Typeface import android.text.Spannable +import android.text.Spanned import android.text.style.ForegroundColorSpan import android.util.DisplayMetrics import android.widget.TextView @@ -16,23 +17,14 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.SystemInformation -import com.datadog.reactnative.sessionreplay.ShadowNodeWrapper -import com.datadog.reactnative.sessionreplay.ShadowNodeWrapper.Companion.UI_IMPLEMENTATION_FIELD_NAME import com.datadog.reactnative.sessionreplay.utils.DrawableUtils -import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils import com.datadog.reactnative.sessionreplay.utils.formatAsRgba -import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils.Companion.COLOR_FIELD_NAME -import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils.Companion.FONT_FAMILY_FIELD_NAME -import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils.Companion.IS_COLOR_SET_FIELD_NAME import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils.Companion.MONOSPACE_FAMILY_NAME -import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils.Companion.TEXT_ATTRIBUTES_FIELD_NAME import com.datadog.reactnative.tools.unit.forge.ForgeConfigurator import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.ReactShadowNode -import com.facebook.react.uimanager.UIImplementation import com.facebook.react.uimanager.UIManagerModule -import com.facebook.react.views.text.TextAttributes +import com.facebook.react.views.text.internal.span.CustomStyleSpan import com.facebook.react.views.view.ReactViewBackgroundDrawable import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery @@ -81,21 +73,9 @@ internal class TextViewUtilsTest { @Mock lateinit var mockDrawableUtils: DrawableUtils - @Mock - lateinit var mockShadowNodeWrapper: ShadowNodeWrapper - - @Mock - lateinit var mockReflectionUtils: ReflectionUtils - @Forgery private lateinit var fakeWireframe: MobileSegment.Wireframe.TextWireframe - @Mock - private lateinit var mockUiImplementation: UIImplementation - - @Mock - private lateinit var mockShadowNode: ReactShadowNode> - @Mock private lateinit var mockLogger: InternalLogger @@ -124,34 +104,19 @@ internal class TextViewUtilsTest { whenever(mockSystemInformation.screenDensity).thenReturn(0f) whenever(mockMappingContext.systemInformation).thenReturn(mockSystemInformation) whenever(mockTextView.text).thenReturn(forge.aString()) - whenever(mockTextView.typeface).thenReturn(Typeface.SANS_SERIF) - - whenever(mockReactContext.getNativeModule(UIManagerModule::class.java)) - .thenReturn(mockUiManagerModule) - - whenever( - mockReflectionUtils.getDeclaredField( - eq(mockUiManagerModule), - eq(UI_IMPLEMENTATION_FIELD_NAME) - ) - ).thenReturn(mockUiImplementation) + // Typeface static constants (SANS_SERIF, MONOSPACE, SERIF) are all null in the JVM test + // environment (Android framework statics are not initialised). Use a non-null Mockito mock + // for the default typeface so it falls through to the "else → roboto/sans-serif" branch. + whenever(mockTextView.typeface).thenReturn(mock(Typeface::class.java)) + whenever(mockTextView.currentTextColor).thenReturn(0xFF000000.toInt()) + whenever(mockTextView.textSize).thenReturn(16f) - whenever( - mockUiImplementation.resolveShadowNode( - eq(mockTextView.id) - ) - ).thenReturn(mockShadowNode) - - whenever(mockReactContext.runOnNativeModulesQueueThread(any())).thenAnswer { - (it.arguments[0] as Runnable).run() - } whenever(mockReactContext.hasActiveReactInstance()).thenReturn(true) val realUtils = LegacyTextViewUtils( mockReactContext, mockLogger, - mockReflectionUtils, mockDrawableUtils ) @@ -210,16 +175,22 @@ internal class TextViewUtilsTest { // region addReactNativeProperties @Test - fun `M get original wireframe W addReactNativeProperties() { no react properties }`() { + fun `M resolve text style from view W addReactNativeProperties() { no background drawable }`() { // Given whenever(mockTextView.background).thenReturn(null) - whenever(mockUiImplementation.resolveShadowNode(any())).thenReturn(null) + whenever(mockTextView.currentTextColor).thenReturn(0xFF000000.toInt()) + whenever(mockTextView.textSize).thenReturn(16f) + // Use a non-null Typeface mock so it falls to the default "roboto, sans-serif" branch. + // (Typeface.SANS_SERIF is null on JVM, same as MONOSPACE, which would match the wrong branch.) + whenever(mockTextView.typeface).thenReturn(mock(Typeface::class.java)) // When - val result = testedUtils.addReactNativeProperties(mockWireframe, mockTextView, 0f) + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 1f) - // Then - assertThat(result).isEqualTo(mockWireframe) + // Then — text properties are always resolved directly from the view + assertThat(result.textStyle.color).isEqualTo(formatAsRgba(0xFF000000.toInt())) + assertThat(result.textStyle.size).isEqualTo(16L) + assertThat(result.textStyle.family).isEqualTo("roboto, sans-serif") } @Test @@ -269,135 +240,172 @@ internal class TextViewUtilsTest { } @Test - fun `M resolve font family W addReactNativeProperties()`() { - // Given - whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, FONT_FAMILY_FIELD_NAME)) - .thenReturn(MONOSPACE_FAMILY_NAME) - + fun `M resolve monospace font family W addReactNativeProperties() { typeface fallback }`() { + // Given — non-RN fallback path: no CustomStyleSpan in text (plain String), typeface comparison used. + // In the JVM test environment Typeface.MONOSPACE is null, so view.typeface == null matches + // the MONOSPACE branch via null == null comparison. whenever(mockTextView.background).thenReturn(null) + whenever(mockTextView.typeface).thenReturn(Typeface.MONOSPACE) + // @BeforeEach sets view.text to a plain String (not Spanned) → resolveFontFamilyFromSpans returns null // When - val result = - testedUtils - .addReactNativeProperties(fakeWireframe, mockTextView, 0f) + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.family) - .isEqualTo(MONOSPACE_FAMILY_NAME) + assertThat(result.textStyle.family).isEqualTo(MONOSPACE_FAMILY_NAME) } @Test - fun `M fallback W addReactNativeProperties() { cannot resolve fontFamily }`() { - // Given + fun `M resolve monospace font family W addReactNativeProperties() { CustomStyleSpan }`() { + // Given — primary path: RN old arch stores font family in CustomStyleSpan on the Spanned + // text. view.typeface is always DEFAULT because RN never calls setTypeface(). + val mockSpanned = mock(Spanned::class.java) + val mockCustomStyleSpan = mock(CustomStyleSpan::class.java) + whenever(mockCustomStyleSpan.fontFamily).thenReturn("monospace") whenever(mockTextView.background).thenReturn(null) - whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(FONT_FAMILY_FIELD_NAME)) - .thenReturn(null) + whenever(mockTextView.text).thenReturn(mockSpanned) + whenever(mockSpanned.length).thenReturn(10) + whenever( + mockSpanned.getSpans(anyInt(), anyInt(), eq(ForegroundColorSpan::class.java)) + ).thenReturn(emptyArray()) + whenever( + mockSpanned.getSpans(anyInt(), anyInt(), eq(CustomStyleSpan::class.java)) + ).thenReturn(arrayOf(mockCustomStyleSpan)) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.family).isEqualTo(fakeWireframe.textStyle.family) + assertThat(result.textStyle.family).isEqualTo(MONOSPACE_FAMILY_NAME) } @Test - fun `M resolve font size W addReactNativeProperties()`( - @Mock mockTextAttributes: TextAttributes, - @IntForgery fakeTextSize: Int - ) { + fun `M resolve serif font family W addReactNativeProperties() { CustomStyleSpan }`() { // Given - whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, TEXT_ATTRIBUTES_FIELD_NAME)) - .thenReturn(mockTextAttributes) + val mockSpanned = mock(Spanned::class.java) + val mockCustomStyleSpan = mock(CustomStyleSpan::class.java) + whenever(mockCustomStyleSpan.fontFamily).thenReturn("serif") whenever(mockTextView.background).thenReturn(null) - whenever(mockTextAttributes.effectiveFontSize).thenReturn(fakeTextSize) + whenever(mockTextView.text).thenReturn(mockSpanned) + whenever(mockSpanned.length).thenReturn(10) + whenever( + mockSpanned.getSpans(anyInt(), anyInt(), eq(ForegroundColorSpan::class.java)) + ).thenReturn(emptyArray()) + whenever( + mockSpanned.getSpans(anyInt(), anyInt(), eq(CustomStyleSpan::class.java)) + ).thenReturn(arrayOf(mockCustomStyleSpan)) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.size).isEqualTo(fakeTextSize.toLong()) + assertThat(result.textStyle.family).isEqualTo("serif") } @Test - fun `M fallback W addReactNativeProperties() { cannot resolve fontSize }`( - @Mock mockTextAttributes: TextAttributes - ) { - // Given + fun `M fall back to sans-serif W addReactNativeProperties() { null fontFamily span }`() { + // Given — CustomStyleSpan present but fontFamily is null: resolveFontFamilyFromSpans + // returns null, falls through to typeface check → default → "roboto, sans-serif" + val mockSpanned = mock(Spanned::class.java) + val mockCustomStyleSpan = mock(CustomStyleSpan::class.java) + whenever(mockCustomStyleSpan.fontFamily).thenReturn(null) whenever(mockTextView.background).thenReturn(null) - whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(TEXT_ATTRIBUTES_FIELD_NAME)) - .thenReturn(null) + whenever(mockTextView.text).thenReturn(mockSpanned) + whenever(mockSpanned.length).thenReturn(10) + whenever(mockTextView.typeface).thenReturn(mock(Typeface::class.java)) + whenever( + mockSpanned.getSpans(anyInt(), anyInt(), eq(ForegroundColorSpan::class.java)) + ).thenReturn(emptyArray()) + whenever( + mockSpanned.getSpans(anyInt(), anyInt(), eq(CustomStyleSpan::class.java)) + ).thenReturn(arrayOf(mockCustomStyleSpan)) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.size).isEqualTo(fakeWireframe.textStyle.size) + assertThat(result.textStyle.family).isEqualTo("roboto, sans-serif") } @Test - fun `M resolve font color W addReactNativeProperties() { color is defined by developer }`( - @IntForgery fakeTextColor: Int - ) { + fun `M resolve sans-serif font family W addReactNativeProperties() { default typeface }`() { // Given whenever(mockTextView.background).thenReturn(null) - whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, IS_COLOR_SET_FIELD_NAME)) - .thenReturn(true) - whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, COLOR_FIELD_NAME)) - .thenReturn(fakeTextColor) + // Use a non-null Typeface mock (SANS_SERIF is null on JVM; MONOSPACE is also null, so + // comparing null==null would incorrectly match the MONOSPACE branch). + whenever(mockTextView.typeface).thenReturn(mock(Typeface::class.java)) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.color).isEqualTo(formatAsRgba(fakeTextColor)) + assertThat(result.textStyle.family).isEqualTo("roboto, sans-serif") } @Test - fun `M resolve font color W addReactNativeProperties() { color is not defined by developer }`( - @IntForgery fakeTextColor: Int + fun `M resolve font size from view W addReactNativeProperties()`( + @IntForgery(min = 10, max = 100) fakeTextSizePx: Int ) { // Given whenever(mockTextView.background).thenReturn(null) - whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, IS_COLOR_SET_FIELD_NAME)) - .thenReturn(false) - whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, COLOR_FIELD_NAME)) - .thenReturn(fakeTextColor) + whenever(mockTextView.textSize).thenReturn(fakeTextSizePx.toFloat()) + + // When — pixelDensity = 1f so size should equal fakeTextSizePx + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 1f) + + // Then + assertThat(result.textStyle.size).isEqualTo(fakeTextSizePx.toLong()) + } + + @Test + fun `M resolve color from ForegroundColorSpan W addReactNativeProperties() { Spanned }`( + @IntForgery fakeSpanColor: Int + ) { + // Given — RN old arch stores text as SpannedString (implements Spanned, not Spannable). + // Color is encoded as a ForegroundColorSpan; it is NOT set via TextView.setTextColor(). + whenever(mockTextView.background).thenReturn(null) + val spanned = mock(Spanned::class.java) + val colorSpan = mock(ForegroundColorSpan::class.java) + whenever(colorSpan.foregroundColor).thenReturn(fakeSpanColor) + whenever(mockTextView.text).thenReturn(spanned) + whenever(spanned.length).thenReturn(10) + whenever( + spanned.getSpans(anyInt(), anyInt(), eq(ForegroundColorSpan::class.java)) + ).thenReturn(arrayOf(colorSpan)) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) - // Then - assertThat(result.textStyle.color).isEqualTo("#000000FF") + // Then — color must come from the span, not from currentTextColor + assertThat(result.textStyle.color).isEqualTo(formatAsRgba(fakeSpanColor)) } @Test - fun `M fallback W addReactNativeProperties() { cannot resolve fontColor }`() { - // Given + fun `M fall back to currentTextColor W addReactNativeProperties() { no color span }`( + @IntForgery fakeTextColor: Int + ) { + // Given — plain String text (not Spanned): no span → fallback to currentTextColor whenever(mockTextView.background).thenReturn(null) - whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, IS_COLOR_SET_FIELD_NAME)) - .thenReturn(true) - whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(COLOR_FIELD_NAME)) - .thenReturn(null) + whenever(mockTextView.currentTextColor).thenReturn(fakeTextColor) + // mockTextView.text already returns a plain String from the @BeforeEach setup // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.color).isEqualTo(fakeWireframe.textStyle.color) + assertThat(result.textStyle.color).isEqualTo(formatAsRgba(fakeTextColor)) } @Test - fun `M return legacy textStyle W addReactNativeProperties() { no valid react context }`() { + fun `M return original wireframe W addReactNativeProperties() { no valid react context }`() { // Given whenever(mockReactContext.hasActiveReactInstance()).thenReturn(false) - whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, FONT_FAMILY_FIELD_NAME)) - .thenReturn(MONOSPACE_FAMILY_NAME) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.family).isNotEqualTo(MONOSPACE_FAMILY_NAME) + assertThat(result).isEqualTo(fakeWireframe) } @Test From 1930197e77f0514ac629d2e840b19dc2fc2a0a47 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:43:49 +0300 Subject: [PATCH 2/3] RUMS-5996: Fix text size in RN69 --- .../sessionreplay/ShadowNodeWrapper.kt | 70 -------- .../utils/text/LegacyTextViewUtils.kt | 49 +++--- .../sessionreplay/utils/text/TextViewUtils.kt | 6 - .../utils/text/TextViewUtilsTest.kt | 165 ++++++++++-------- 4 files changed, 123 insertions(+), 167 deletions(-) delete mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ShadowNodeWrapper.kt diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ShadowNodeWrapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ShadowNodeWrapper.kt deleted file mode 100644 index 65cdada37..000000000 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ShadowNodeWrapper.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * - * * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * * This product includes software developed at Datadog (https://www.datadoghq.com/). - * * Copyright 2016-Present Datadog, Inc. - * - */ - -package com.datadog.reactnative.sessionreplay - -import androidx.annotation.VisibleForTesting -import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils -import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.ReactShadowNode -import com.facebook.react.uimanager.UIImplementation -import com.facebook.react.uimanager.UIManagerModule -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -internal class ShadowNodeWrapper( - private val shadowNode: ReactShadowNode>?, - private val reflectionUtils: ReflectionUtils = ReflectionUtils() -) { - internal fun getDeclaredShadowNodeField(fieldName: String): Any? { - return shadowNode?.let { - reflectionUtils.getDeclaredField( - shadowNode, - fieldName - ) - } - } - - internal companion object { - internal fun getShadowNodeWrapper( - reactContext: ReactContext, - uiManagerModule: UIManagerModule?, - reflectionUtils: ReflectionUtils, - viewId: Int - ): ShadowNodeWrapper? { - val countDownLatch = CountDownLatch(1) - var target: ReactShadowNode>? = null - - val shadowNodeRunnable = Runnable { - val node = uiManagerModule?.let { resolveShadowNode(reflectionUtils, it, viewId) } - if (node != null) { - target = node - } - - countDownLatch.countDown() - } - - reactContext.runOnNativeModulesQueueThread(shadowNodeRunnable) - countDownLatch.await(5, TimeUnit.SECONDS) - - if (target == null) { - return null - } - - return ShadowNodeWrapper(reflectionUtils = reflectionUtils, shadowNode = target) - } - - private fun resolveShadowNode(reflectionUtils: ReflectionUtils, uiManagerModule: UIManagerModule, tag: Int): ReactShadowNode>? { - val uiManagerImplementation = reflectionUtils.getDeclaredField(uiManagerModule, UI_IMPLEMENTATION_FIELD_NAME) as? UIImplementation - return uiManagerImplementation?.resolveShadowNode(tag) - } - - @VisibleForTesting - internal const val UI_IMPLEMENTATION_FIELD_NAME = "mUIImplementation" - } -} diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt index a7cb86643..655ec1ee0 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt @@ -1,16 +1,16 @@ 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.sessionreplay.model.MobileSegment import com.datadog.reactnative.sessionreplay.utils.DrawableUtils import com.datadog.reactnative.sessionreplay.utils.formatAsRgba import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.UIManagerModule internal class LegacyTextViewUtils( private val reactContext: ReactContext, @@ -24,7 +24,7 @@ internal class LegacyTextViewUtils( view: TextView, ): MobileSegment.TextStyle { val family = resolveFontFamilyFromTypeface(view) - val sizeSp = (view.textSize / pixelsDensity).toLong() + val sizeSp = resolveTextSize(view, pixelsDensity) val color = resolveTextColor(view) return MobileSegment.TextStyle( @@ -34,14 +34,31 @@ internal class LegacyTextViewUtils( ) } + private fun resolveTextSize(view: TextView, pixelsDensity: Float): Long { + 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 / pixelsDensity).toLong() + } + } + return (view.textSize / pixelsDensity).toLong() + } + private fun resolveTextColor(view: TextView): String { - val spanned = view.text as? Spanned ?: return formatAsRgba(view.currentTextColor) + val spanned = view.text as? Spanned ?: return formatAsRgba(RN_DEFAULT_TEXT_COLOR) - val span = spanned.getSpans(0, spanned.length, ForegroundColorSpan::class.java).firstOrNull() + 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(view.currentTextColor) + formatAsRgba(RN_DEFAULT_TEXT_COLOR) } } @@ -95,6 +112,11 @@ internal class LegacyTextViewUtils( } 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" @@ -106,19 +128,4 @@ internal class LegacyTextViewUtils( "Unable to resolve font family from CustomStyleSpan via reflection" } - // Kept for backward-compat callers and tests. - @VisibleForTesting - internal fun getUiManagerModule(): UIManagerModule? { - return try { - reactContext.getNativeModule(UIManagerModule::class.java) - } catch (e: IllegalStateException) { - logger.log( - level = InternalLogger.Level.WARN, - targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), - messageBuilder = { RESOLVE_UIMANAGERMODULE_ERROR }, - throwable = e, - ) - return null - } - } } diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtils.kt index 9133c8530..d02ed7624 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtils.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtils.kt @@ -141,10 +141,6 @@ 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" @@ -152,8 +148,6 @@ internal abstract class TextViewUtils(private val reactContext: ReactContext, pr 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" diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtilsTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtilsTest.kt index 33dc0e1f7..494d4fabd 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtilsTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtilsTest.kt @@ -10,6 +10,7 @@ import android.content.res.Resources import android.graphics.Typeface import android.text.Spannable import android.text.Spanned +import android.text.style.AbsoluteSizeSpan import android.text.style.ForegroundColorSpan import android.util.DisplayMetrics import android.widget.TextView @@ -21,9 +22,7 @@ import com.datadog.reactnative.sessionreplay.utils.DrawableUtils import com.datadog.reactnative.sessionreplay.utils.formatAsRgba import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils.Companion.MONOSPACE_FAMILY_NAME import com.datadog.reactnative.tools.unit.forge.ForgeConfigurator -import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.UIManagerModule import com.facebook.react.views.text.internal.span.CustomStyleSpan import com.facebook.react.views.view.ReactViewBackgroundDrawable import fr.xgouchet.elmyr.Forge @@ -58,12 +57,6 @@ internal class TextViewUtilsTest { @Mock lateinit var mockReactContext: ReactContext - @Mock - lateinit var mockUiManagerModule: UIManagerModule - - @Mock - lateinit var mockWireframe: MobileSegment.Wireframe.TextWireframe - @Mock lateinit var mockTextView: TextView @@ -257,20 +250,17 @@ internal class TextViewUtilsTest { @Test fun `M resolve monospace font family W addReactNativeProperties() { CustomStyleSpan }`() { - // Given — primary path: RN old arch stores font family in CustomStyleSpan on the Spanned - // text. view.typeface is always DEFAULT because RN never calls setTypeface(). - val mockSpanned = mock(Spanned::class.java) + // Given — RN old arch stores font family in CustomStyleSpan on the Spanned text. + // CustomStyleSpan requires an AssetManager so we mock it; getFontFamily() is stubbed + // so the real reflection call in production code exercises the right return value. val mockCustomStyleSpan = mock(CustomStyleSpan::class.java) whenever(mockCustomStyleSpan.fontFamily).thenReturn("monospace") + val mockSpanned = mock(Spanned::class.java) + whenever(mockSpanned.length).thenReturn(10) + whenever(mockSpanned.getSpans(anyInt(), anyInt(), eq(CustomStyleSpan::class.java))) + .thenReturn(arrayOf(mockCustomStyleSpan)) whenever(mockTextView.background).thenReturn(null) whenever(mockTextView.text).thenReturn(mockSpanned) - whenever(mockSpanned.length).thenReturn(10) - whenever( - mockSpanned.getSpans(anyInt(), anyInt(), eq(ForegroundColorSpan::class.java)) - ).thenReturn(emptyArray()) - whenever( - mockSpanned.getSpans(anyInt(), anyInt(), eq(CustomStyleSpan::class.java)) - ).thenReturn(arrayOf(mockCustomStyleSpan)) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) @@ -282,18 +272,14 @@ internal class TextViewUtilsTest { @Test fun `M resolve serif font family W addReactNativeProperties() { CustomStyleSpan }`() { // Given - val mockSpanned = mock(Spanned::class.java) val mockCustomStyleSpan = mock(CustomStyleSpan::class.java) whenever(mockCustomStyleSpan.fontFamily).thenReturn("serif") + val mockSpanned = mock(Spanned::class.java) + whenever(mockSpanned.length).thenReturn(10) + whenever(mockSpanned.getSpans(anyInt(), anyInt(), eq(CustomStyleSpan::class.java))) + .thenReturn(arrayOf(mockCustomStyleSpan)) whenever(mockTextView.background).thenReturn(null) whenever(mockTextView.text).thenReturn(mockSpanned) - whenever(mockSpanned.length).thenReturn(10) - whenever( - mockSpanned.getSpans(anyInt(), anyInt(), eq(ForegroundColorSpan::class.java)) - ).thenReturn(emptyArray()) - whenever( - mockSpanned.getSpans(anyInt(), anyInt(), eq(CustomStyleSpan::class.java)) - ).thenReturn(arrayOf(mockCustomStyleSpan)) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) @@ -304,21 +290,16 @@ internal class TextViewUtilsTest { @Test fun `M fall back to sans-serif W addReactNativeProperties() { null fontFamily span }`() { - // Given — CustomStyleSpan present but fontFamily is null: resolveFontFamilyFromSpans - // returns null, falls through to typeface check → default → "roboto, sans-serif" - val mockSpanned = mock(Spanned::class.java) + // Given — CustomStyleSpan present but fontFamily is null → falls through to typeface check. val mockCustomStyleSpan = mock(CustomStyleSpan::class.java) whenever(mockCustomStyleSpan.fontFamily).thenReturn(null) + val mockSpanned = mock(Spanned::class.java) + whenever(mockSpanned.length).thenReturn(10) + whenever(mockSpanned.getSpans(anyInt(), anyInt(), eq(CustomStyleSpan::class.java))) + .thenReturn(arrayOf(mockCustomStyleSpan)) whenever(mockTextView.background).thenReturn(null) whenever(mockTextView.text).thenReturn(mockSpanned) - whenever(mockSpanned.length).thenReturn(10) whenever(mockTextView.typeface).thenReturn(mock(Typeface::class.java)) - whenever( - mockSpanned.getSpans(anyInt(), anyInt(), eq(ForegroundColorSpan::class.java)) - ).thenReturn(emptyArray()) - whenever( - mockSpanned.getSpans(anyInt(), anyInt(), eq(CustomStyleSpan::class.java)) - ).thenReturn(arrayOf(mockCustomStyleSpan)) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) @@ -346,7 +327,7 @@ internal class TextViewUtilsTest { fun `M resolve font size from view W addReactNativeProperties()`( @IntForgery(min = 10, max = 100) fakeTextSizePx: Int ) { - // Given + // Given — fallback path: no AbsoluteSizeSpan in text, size read from view.textSize whenever(mockTextView.background).thenReturn(null) whenever(mockTextView.textSize).thenReturn(fakeTextSizePx.toFloat()) @@ -357,43 +338,103 @@ internal class TextViewUtilsTest { assertThat(result.textStyle.size).isEqualTo(fakeTextSizePx.toLong()) } + @Test + fun `M resolve font size from AbsoluteSizeSpan W addReactNativeProperties() { size in pixels }`( + @IntForgery(min = 10, max = 100) fakeTextSizePx: Int + ) { + // Given — RN 0.69 old arch applies font size as AbsoluteSizeSpan (dip=false, size in px). + val mockSizeSpan = mock(AbsoluteSizeSpan::class.java) + whenever(mockSizeSpan.dip).thenReturn(false) + whenever(mockSizeSpan.size).thenReturn(fakeTextSizePx) + val mockSpanned = mock(Spanned::class.java) + whenever(mockSpanned.length).thenReturn(10) + whenever(mockSpanned.getSpans(anyInt(), anyInt(), eq(AbsoluteSizeSpan::class.java))) + .thenReturn(arrayOf(mockSizeSpan)) + whenever(mockTextView.background).thenReturn(null) + whenever(mockTextView.text).thenReturn(mockSpanned) + + // When — density=2f, size in px → sp = fakeTextSizePx / 2 + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 2f) + + // Then + assertThat(result.textStyle.size).isEqualTo((fakeTextSizePx / 2).toLong()) + } + + @Test + fun `M resolve font size from AbsoluteSizeSpan W addReactNativeProperties() { size in dip }`( + @IntForgery(min = 10, max = 100) fakeTextSizeDip: Int + ) { + // Given — AbsoluteSizeSpan with dip=true: size is already density-independent, used as-is. + val mockSizeSpan = mock(AbsoluteSizeSpan::class.java) + whenever(mockSizeSpan.dip).thenReturn(true) + whenever(mockSizeSpan.size).thenReturn(fakeTextSizeDip) + val mockSpanned = mock(Spanned::class.java) + whenever(mockSpanned.length).thenReturn(10) + whenever(mockSpanned.getSpans(anyInt(), anyInt(), eq(AbsoluteSizeSpan::class.java))) + .thenReturn(arrayOf(mockSizeSpan)) + whenever(mockTextView.background).thenReturn(null) + whenever(mockTextView.text).thenReturn(mockSpanned) + + // When — density irrelevant when dip=true + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 2f) + + // Then + assertThat(result.textStyle.size).isEqualTo(fakeTextSizeDip.toLong()) + } + @Test fun `M resolve color from ForegroundColorSpan W addReactNativeProperties() { Spanned }`( @IntForgery fakeSpanColor: Int ) { - // Given — RN old arch stores text as SpannedString (implements Spanned, not Spannable). - // Color is encoded as a ForegroundColorSpan; it is NOT set via TextView.setTextColor(). + // Given — RN old arch encodes color as ForegroundColorSpan, not via setTextColor(). + val mockColorSpan = mock(ForegroundColorSpan::class.java) + whenever(mockColorSpan.foregroundColor).thenReturn(fakeSpanColor) + val mockSpanned = mock(Spanned::class.java) + whenever(mockSpanned.length).thenReturn(10) + whenever(mockSpanned.getSpans(anyInt(), anyInt(), eq(ForegroundColorSpan::class.java))) + .thenReturn(arrayOf(mockColorSpan)) whenever(mockTextView.background).thenReturn(null) - val spanned = mock(Spanned::class.java) - val colorSpan = mock(ForegroundColorSpan::class.java) - whenever(colorSpan.foregroundColor).thenReturn(fakeSpanColor) - whenever(mockTextView.text).thenReturn(spanned) - whenever(spanned.length).thenReturn(10) - whenever( - spanned.getSpans(anyInt(), anyInt(), eq(ForegroundColorSpan::class.java)) - ).thenReturn(arrayOf(colorSpan)) + whenever(mockTextView.text).thenReturn(mockSpanned) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) - // Then — color must come from the span, not from currentTextColor + // Then assertThat(result.textStyle.color).isEqualTo(formatAsRgba(fakeSpanColor)) } @Test - fun `M fall back to currentTextColor W addReactNativeProperties() { no color span }`( - @IntForgery fakeTextColor: Int - ) { - // Given — plain String text (not Spanned): no span → fallback to currentTextColor + fun `M fall back to RN default color W addReactNativeProperties() { no color span }`() { + // Given — plain String text (not Spanned): no ForegroundColorSpan present. + // RN old arch never calls setTextColor(), so view.currentTextColor is the Android theme + // default, not the actual rendered color. We fall back to RN's own default (opaque black). whenever(mockTextView.background).thenReturn(null) - whenever(mockTextView.currentTextColor).thenReturn(fakeTextColor) // mockTextView.text already returns a plain String from the @BeforeEach setup // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.color).isEqualTo(formatAsRgba(fakeTextColor)) + assertThat(result.textStyle.color).isEqualTo( + formatAsRgba(LegacyTextViewUtils.RN_DEFAULT_TEXT_COLOR) + ) + } + + @Test + fun `M fall back to RN default color W addReactNativeProperties() { Spanned no color span }`() { + // Given — Spanned text (e.g. has CustomStyleSpan) but no ForegroundColorSpan. + val mockSpanned = mock(Spanned::class.java) + whenever(mockSpanned.length).thenReturn(10) + whenever(mockTextView.background).thenReturn(null) + whenever(mockTextView.text).thenReturn(mockSpanned) + + // When + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + + // Then + assertThat(result.textStyle.color).isEqualTo( + formatAsRgba(LegacyTextViewUtils.RN_DEFAULT_TEXT_COLOR) + ) } @Test @@ -426,20 +467,4 @@ internal class TextViewUtilsTest { } // endregion - - // region getUiManagerModule - @Test - fun `M return null W getUiManagerModule() { cannot get uiManagerModule }`() { - // Given - whenever(mockReactContext.getNativeModule(any>())) - .thenThrow(IllegalStateException()) - - // When - val uiManagerModule = testedUtils.getUiManagerModule() - - // Then - assertThat(uiManagerModule).isNull() - } - - // endregion } From e0f5e85911c3047abcab289181717f5f7b4bfe4c Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:06:54 +0300 Subject: [PATCH 3/3] RUMS-5996: Fix review comments --- .../sessionreplay/utils/text/FabricTextViewUtils.kt | 4 ++-- .../sessionreplay/utils/text/LegacyTextViewUtils.kt | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/FabricTextViewUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/FabricTextViewUtils.kt index 10110d270..425bcd281 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/FabricTextViewUtils.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/FabricTextViewUtils.kt @@ -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 { diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt index 655ec1ee0..b405e9236 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt @@ -11,9 +11,10 @@ import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.reactnative.sessionreplay.utils.DrawableUtils import com.datadog.reactnative.sessionreplay.utils.formatAsRgba import com.facebook.react.bridge.ReactContext +import java.util.Locale internal class LegacyTextViewUtils( - private val reactContext: ReactContext, + reactContext: ReactContext, private val logger: InternalLogger, drawableUtils: DrawableUtils, ) : TextViewUtils(reactContext, drawableUtils) { @@ -35,15 +36,16 @@ internal class LegacyTextViewUtils( } 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 / pixelsDensity).toLong() + return if (span.dip) span.size.toLong() else (span.size / density).toLong() } } - return (view.textSize / pixelsDensity).toLong() + return (view.textSize / density).toLong() } private fun resolveTextColor(view: TextView): String { @@ -63,7 +65,7 @@ internal class LegacyTextViewUtils( } private fun resolveFontFamilyFromTypeface(view: TextView): String { - resolveFontFamilyFromSpans(view)?.let { return resolveFontFamily(it) } + resolveFontFamilyFromSpans(view)?.let { return resolveFontFamily(it.lowercase(Locale.US)) } // Fallback for non-RN views. Typeface.familyName requires API 28, so we use identity // comparison against the standard singletons instead.