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/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 44bc9a9a3..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 @@ -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() } - 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") } + } - 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` 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) ?: 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" + } + } 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..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 @@ -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) { @@ -142,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" @@ -153,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" @@ -162,7 +155,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..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 @@ -9,6 +9,8 @@ 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.AbsoluteSizeSpan import android.text.style.ForegroundColorSpan import android.util.DisplayMetrics import android.widget.TextView @@ -16,23 +18,12 @@ 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 @@ -66,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 @@ -81,21 +66,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 +97,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) - - whenever( - mockUiImplementation.resolveShadowNode( - eq(mockTextView.id) - ) - ).thenReturn(mockShadowNode) + // 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(mockReactContext.runOnNativeModulesQueueThread(any())).thenAnswer { - (it.arguments[0] as Runnable).run() - } whenever(mockReactContext.hasActiveReactInstance()).thenReturn(true) val realUtils = LegacyTextViewUtils( mockReactContext, mockLogger, - mockReflectionUtils, mockDrawableUtils ) @@ -210,16 +168,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 +233,220 @@ 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) + + // Then + assertThat(result.textStyle.family).isEqualTo(MONOSPACE_FAMILY_NAME) + } + @Test + fun `M resolve monospace font family W addReactNativeProperties() { CustomStyleSpan }`() { + // 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) // 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 }`() { + fun `M resolve serif font family W addReactNativeProperties() { CustomStyleSpan }`() { // Given + 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(mockShadowNodeWrapper.getDeclaredShadowNodeField(FONT_FAMILY_FIELD_NAME)) - .thenReturn(null) + whenever(mockTextView.text).thenReturn(mockSpanned) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.family).isEqualTo(fakeWireframe.textStyle.family) + assertThat(result.textStyle.family).isEqualTo("serif") } @Test - fun `M resolve font size W addReactNativeProperties()`( - @Mock mockTextAttributes: TextAttributes, - @IntForgery fakeTextSize: Int - ) { - // Given - whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, TEXT_ATTRIBUTES_FIELD_NAME)) - .thenReturn(mockTextAttributes) + fun `M fall back to sans-serif W addReactNativeProperties() { null fontFamily span }`() { + // 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(mockTextAttributes.effectiveFontSize).thenReturn(fakeTextSize) + whenever(mockTextView.text).thenReturn(mockSpanned) + whenever(mockTextView.typeface).thenReturn(mock(Typeface::class.java)) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.size).isEqualTo(fakeTextSize.toLong()) + assertThat(result.textStyle.family).isEqualTo("roboto, sans-serif") } @Test - fun `M fallback W addReactNativeProperties() { cannot resolve fontSize }`( - @Mock mockTextAttributes: TextAttributes - ) { + fun `M resolve sans-serif font family W addReactNativeProperties() { default typeface }`() { // Given whenever(mockTextView.background).thenReturn(null) - whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(TEXT_ATTRIBUTES_FIELD_NAME)) - .thenReturn(null) + // 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.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 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()) + + // 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 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(mockReflectionUtils.getDeclaredField(mockShadowNode, IS_COLOR_SET_FIELD_NAME)) - .thenReturn(true) - whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, COLOR_FIELD_NAME)) - .thenReturn(fakeTextColor) + 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 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) + whenever(mockTextView.text).thenReturn(mockSpanned) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.color).isEqualTo(formatAsRgba(fakeTextColor)) + assertThat(result.textStyle.color).isEqualTo(formatAsRgba(fakeSpanColor)) } @Test - fun `M resolve font color W addReactNativeProperties() { color is not defined by developer }`( - @IntForgery fakeTextColor: Int - ) { - // Given + 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(mockReflectionUtils.getDeclaredField(mockShadowNode, IS_COLOR_SET_FIELD_NAME)) - .thenReturn(false) - whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, COLOR_FIELD_NAME)) - .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("#000000FF") + assertThat(result.textStyle.color).isEqualTo( + formatAsRgba(LegacyTextViewUtils.RN_DEFAULT_TEXT_COLOR) + ) } @Test - fun `M fallback W addReactNativeProperties() { cannot resolve fontColor }`() { - // Given + 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(mockReflectionUtils.getDeclaredField(mockShadowNode, IS_COLOR_SET_FIELD_NAME)) - .thenReturn(true) - whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(COLOR_FIELD_NAME)) - .thenReturn(null) + whenever(mockTextView.text).thenReturn(mockSpanned) // When val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then - assertThat(result.textStyle.color).isEqualTo(fakeWireframe.textStyle.color) + assertThat(result.textStyle.color).isEqualTo( + formatAsRgba(LegacyTextViewUtils.RN_DEFAULT_TEXT_COLOR) + ) } @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 @@ -418,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 }