diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt index 354a03a24..c15f4429d 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt @@ -6,15 +6,13 @@ package com.datadog.reactnative.sessionreplay +import android.annotation.SuppressLint import com.datadog.android.api.feature.FeatureSdkCore -import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.SessionReplayConfiguration -import com.datadog.android.sessionreplay.TextAndInputPrivacy -import com.datadog.android.sessionreplay.TouchPrivacy import com.datadog.reactnative.DatadogSDKWrapperStorage +import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactContext -import java.util.Locale /** * The entry point to use Datadog's Session Replay feature. @@ -33,6 +31,7 @@ class DdSessionReplayImplementation( * @param customEndpoint Custom server url for sending replay data. * @param startRecordingImmediately Whether the recording should start immediately when the feature is enabled. */ + @SuppressLint("VisibleForTests") fun enable( replaySampleRate: Double, customEndpoint: String, @@ -42,12 +41,13 @@ class DdSessionReplayImplementation( ) { val sdkCore = DatadogSDKWrapperStorage.getSdkCore() as FeatureSdkCore val logger = sdkCore.internalLogger + val textViewUtils = TextViewUtils.create(reactContext, logger) val configuration = SessionReplayConfiguration.Builder(replaySampleRate.toFloat()) .startRecordingImmediately(startRecordingImmediately) .setImagePrivacy(privacySettings.imagePrivacyLevel) .setTouchPrivacy(privacySettings.touchPrivacyLevel) .setTextAndInputPrivacy(privacySettings.textAndInputPrivacyLevel) - .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(reactContext, logger)) + .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(textViewUtils)) if (customEndpoint != "") { configuration.useCustomEndpoint(customEndpoint) diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/NoopTextPropertiesResolver.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/NoopTextPropertiesResolver.kt deleted file mode 100644 index 59931a090..000000000 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/NoopTextPropertiesResolver.kt +++ /dev/null @@ -1,22 +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 android.widget.TextView -import com.datadog.android.sessionreplay.model.MobileSegment - -internal class NoopTextPropertiesResolver: TextPropertiesResolver { - override fun addReactNativeProperties( - originalWireframe: MobileSegment.Wireframe.TextWireframe, - view: TextView, - pixelDensity: Float - ): MobileSegment.Wireframe.TextWireframe { - return originalWireframe - } -} diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupport.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupport.kt index 8212bd990..c5bf57834 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupport.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupport.kt @@ -6,8 +6,6 @@ package com.datadog.reactnative.sessionreplay -import androidx.annotation.VisibleForTesting -import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ExtensionSupport import com.datadog.android.sessionreplay.MapperTypeWrapper import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector @@ -16,47 +14,29 @@ import com.datadog.reactnative.sessionreplay.mappers.ReactEditTextMapper import com.datadog.reactnative.sessionreplay.mappers.ReactNativeImageViewMapper import com.datadog.reactnative.sessionreplay.mappers.ReactTextMapper import com.datadog.reactnative.sessionreplay.mappers.ReactViewGroupMapper -import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.UIManagerModule +import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils import com.facebook.react.views.image.ReactImageView import com.facebook.react.views.text.ReactTextView import com.facebook.react.views.textinput.ReactEditText import com.facebook.react.views.view.ReactViewGroup + internal class ReactNativeSessionReplayExtensionSupport( - private val reactContext: ReactContext, - private val logger: InternalLogger + private val textViewUtils: TextViewUtils ) : ExtensionSupport { override fun name(): String { return ReactNativeSessionReplayExtensionSupport::class.java.simpleName } override fun getCustomViewMappers(): List> { - val uiManagerModule = getUiManagerModule() - return listOf( MapperTypeWrapper(ReactImageView::class.java, ReactNativeImageViewMapper()), MapperTypeWrapper(ReactViewGroup::class.java, ReactViewGroupMapper()), - MapperTypeWrapper(ReactTextView::class.java, ReactTextMapper(reactContext, uiManagerModule)), - MapperTypeWrapper(ReactEditText::class.java, ReactEditTextMapper(reactContext, uiManagerModule)), + MapperTypeWrapper(ReactTextView::class.java, ReactTextMapper(textViewUtils)), + MapperTypeWrapper(ReactEditText::class.java, ReactEditTextMapper(textViewUtils)), ) } - @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 - } - } - override fun getOptionSelectorDetectors(): List { return listOf() } @@ -64,8 +44,4 @@ internal class ReactNativeSessionReplayExtensionSupport( override fun getCustomDrawableMapper(): List { return emptyList() } - - internal companion object { - internal const val RESOLVE_UIMANAGERMODULE_ERROR = "Unable to resolve UIManagerModule" - } } 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 index 98da40a7a..60f2dddca 100644 --- 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 @@ -33,7 +33,7 @@ internal class ShadowNodeWrapper( internal companion object { internal fun getShadowNodeWrapper( reactContext: ReactContext, - uiManagerModule: UIManagerModule, + uiManagerModule: UIManagerModule?, reflectionUtils: ReflectionUtils, viewId: Int ): ShadowNodeWrapper? { @@ -41,7 +41,7 @@ internal class ShadowNodeWrapper( var target: ReactShadowNode>? = null val shadowNodeRunnable = Runnable { - val node = resolveShadowNode(reflectionUtils, uiManagerModule, viewId) + val node = uiManagerModule?.let { resolveShadowNode(reflectionUtils, it, viewId) } if (node != null) { target = node } diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactEditTextMapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactEditTextMapper.kt index d3f3bba9b..1dfe21456 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactEditTextMapper.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactEditTextMapper.kt @@ -19,18 +19,11 @@ import com.datadog.android.sessionreplay.utils.DefaultViewBoundsResolver import com.datadog.android.sessionreplay.utils.DefaultViewIdentifierResolver import com.datadog.android.sessionreplay.utils.DrawableToColorMapper import com.datadog.android.sessionreplay.utils.GlobalBounds -import com.datadog.reactnative.sessionreplay.NoopTextPropertiesResolver -import com.datadog.reactnative.sessionreplay.ReactTextPropertiesResolver -import com.datadog.reactnative.sessionreplay.TextPropertiesResolver -import com.datadog.reactnative.sessionreplay.utils.TextViewUtils -import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.UIManagerModule +import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils import com.facebook.react.views.textinput.ReactEditText internal class ReactEditTextMapper( - private val reactTextPropertiesResolver: TextPropertiesResolver = - NoopTextPropertiesResolver(), - private val textViewUtils: TextViewUtils = TextViewUtils(), + private val textViewUtils: TextViewUtils ) : BaseAsyncBackgroundWireframeMapper( viewIdentifierResolver = DefaultViewIdentifierResolver, colorStringFormatter = DefaultColorStringFormatter, @@ -46,20 +39,6 @@ internal class ReactEditTextMapper( drawableToColorMapper = drawableToColorMapper, ) - internal constructor( - reactContext: ReactContext, - uiManagerModule: UIManagerModule? - ) : this( - reactTextPropertiesResolver = if (uiManagerModule == null) { - NoopTextPropertiesResolver() - } else { - ReactTextPropertiesResolver( - reactContext = reactContext, - uiManagerModule = uiManagerModule - ) - } - ) - override fun map( view: ReactEditText, mappingContext: MappingContext, @@ -88,7 +67,6 @@ internal class ReactEditTextMapper( wireframes = backgroundWireframes, view = view, mappingContext = mappingContext, - reactTextPropertiesResolver = reactTextPropertiesResolver ) } diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapper.kt index dcdd71b28..9ff287f33 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapper.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapper.kt @@ -8,7 +8,6 @@ package com.datadog.reactnative.sessionreplay.mappers import android.widget.TextView import com.datadog.android.api.InternalLogger -import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper @@ -17,17 +16,10 @@ import com.datadog.android.sessionreplay.utils.DefaultColorStringFormatter import com.datadog.android.sessionreplay.utils.DefaultViewBoundsResolver import com.datadog.android.sessionreplay.utils.DefaultViewIdentifierResolver import com.datadog.android.sessionreplay.utils.DrawableToColorMapper -import com.datadog.reactnative.sessionreplay.NoopTextPropertiesResolver -import com.datadog.reactnative.sessionreplay.ReactTextPropertiesResolver -import com.datadog.reactnative.sessionreplay.TextPropertiesResolver -import com.datadog.reactnative.sessionreplay.utils.TextViewUtils -import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.UIManagerModule +import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils internal class ReactTextMapper( - private val reactTextPropertiesResolver: TextPropertiesResolver = - NoopTextPropertiesResolver(), - private val textViewUtils: TextViewUtils = TextViewUtils(), + private val textViewUtils: TextViewUtils ): TextViewMapper( viewIdentifierResolver = DefaultViewIdentifierResolver, colorStringFormatter = DefaultColorStringFormatter, @@ -35,20 +27,6 @@ internal class ReactTextMapper( drawableToColorMapper = DrawableToColorMapper.getDefault() ) { - internal constructor( - reactContext: ReactContext, - uiManagerModule: UIManagerModule? - ): this( - reactTextPropertiesResolver = if (uiManagerModule == null) { - NoopTextPropertiesResolver() - } else { - ReactTextPropertiesResolver( - reactContext = reactContext, - uiManagerModule = uiManagerModule - ) - } - ) - override fun map( view: TextView, mappingContext: MappingContext, @@ -56,11 +34,11 @@ internal class ReactTextMapper( internalLogger: InternalLogger ): List { val wireframes = super.map(view, mappingContext, asyncJobStatusCallback, internalLogger) + return textViewUtils.mapTextViewToWireframes( wireframes = wireframes, view = view, mappingContext = mappingContext, - reactTextPropertiesResolver = reactTextPropertiesResolver ) } } diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/TextViewUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/TextViewUtils.kt deleted file mode 100644 index ede97acf1..000000000 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/TextViewUtils.kt +++ /dev/null @@ -1,40 +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.utils - -import android.widget.TextView -import com.datadog.android.sessionreplay.model.MobileSegment -import com.datadog.android.sessionreplay.recorder.MappingContext -import com.datadog.reactnative.sessionreplay.TextPropertiesResolver - -internal class TextViewUtils { - internal fun mapTextViewToWireframes( - wireframes: List, - view: TextView, - mappingContext: MappingContext, - reactTextPropertiesResolver: TextPropertiesResolver - ): List { - val result = mutableListOf() - val pixelDensity = mappingContext.systemInformation.screenDensity - - for (originalWireframe in wireframes) { - if (originalWireframe !is MobileSegment.Wireframe.TextWireframe) { - result.add(originalWireframe) - } else { - result.add(reactTextPropertiesResolver.addReactNativeProperties( - originalWireframe = originalWireframe, - view = view, - pixelDensity = pixelDensity, - )) - } - } - - return result - } -} 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 new file mode 100644 index 000000000..10110d270 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/FabricTextViewUtils.kt @@ -0,0 +1,74 @@ +package com.datadog.reactnative.sessionreplay.utils.text + +import android.text.Spannable +import android.text.style.ForegroundColorSpan +import android.view.View +import android.widget.TextView +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 java.util.Locale + +internal class FabricTextViewUtils(private val reactContext: ReactContext, private val logger: InternalLogger, drawableUtils: DrawableUtils): TextViewUtils(reactContext, drawableUtils) { + + override fun resolveTextStyle( + textWireframe: MobileSegment.Wireframe.TextWireframe, + pixelsDensity: Float, + view: TextView + ): MobileSegment.TextStyle { + + val fontColor = getTextColor(view, textWireframe) + val fontSize = getFontSize(view, pixelsDensity) + val fontFamily = getFontFamily(textWireframe) + + return MobileSegment.TextStyle( + family = fontFamily, + size = fontSize, + color = fontColor + ) + } + + private fun getTextColor(view: TextView, textWireframe: MobileSegment.Wireframe.TextWireframe): String { + val spanned = getFieldFromView(view, SPANNED_FIELD_NAME) as? Spannable + val spans = spanned?.getSpans(0, spanned.length, ForegroundColorSpan::class.java) + val fontColor = spans?.firstOrNull()?.foregroundColor?.let { formatAsRgba(it) } ?: textWireframe.textStyle.color + + return fontColor + } + + private fun getFontSize(view: TextView, pixelsDensity: Float): Long { + val fontSize = (view.textSize / pixelsDensity).toLong() + return fontSize + } + + private fun getFontFamily(textWireframe: MobileSegment.Wireframe.TextWireframe): String { + val fontFamily = textWireframe.textStyle.family + return resolveFontFamily(fontFamily.lowercase(Locale.US)) + } + + internal fun getFieldFromView(view: View, value: String): Any? { + try { + val field = view.javaClass.getDeclaredField(value) + field.isAccessible = true + return field.get(view) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + when (e) { + is NoSuchFieldException -> handleError(e, RESOLVE_FABRICFIELD_ERROR) + is NullPointerException -> handleError(e, NULL_FABRICFIELD_ERROR) + else -> handleError(e, RESOLVE_FABRICFIELD_ERROR) + } + return null + } + } + + private fun handleError(e: Exception, message: String) { + logger.log( + level = InternalLogger.Level.WARN, + targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + messageBuilder = { message }, + throwable = e + ) + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..6e3a80084 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/LegacyTextViewUtils.kt @@ -0,0 +1,118 @@ +package com.datadog.reactnative.sessionreplay.utils.text + +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 + + return MobileSegment.TextStyle( + family = fontFamily, + size = fontSize, + color = fontColor, + ) + } + + 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) + } + + return null + } + + 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() + } + + return null + } + + private fun getFontFamily(shadowNodeWrapper: ShadowNodeWrapper?): String? { + if (shadowNodeWrapper == null) return null + + val fontFamily = + shadowNodeWrapper + .getDeclaredShadowNodeField(FONT_FAMILY_FIELD_NAME) as? String + + if (fontFamily != null) { + return resolveFontFamily(fontFamily.lowercase(Locale.US)) + } + + return null + } + + // store to avoid calling it multiple times + @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/ReactTextPropertiesResolver.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtils.kt similarity index 53% rename from packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolver.kt rename to packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtils.kt index 5527c4cd8..603115409 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolver.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtils.kt @@ -1,33 +1,42 @@ -/* - * 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 +package com.datadog.reactnative.sessionreplay.utils.text import ReactViewBackgroundDrawableUtils import android.view.Gravity import android.widget.TextView import androidx.annotation.VisibleForTesting -import com.datadog.android.internal.utils.densityNormalized +import com.datadog.android.api.InternalLogger 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.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 ReactTextPropertiesResolver( - private val reactContext: ReactContext, - private val uiManagerModule: UIManagerModule, - private val reflectionUtils: ReflectionUtils = ReflectionUtils(), - private val drawableUtils: DrawableUtils = - ReactViewBackgroundDrawableUtils() -): TextPropertiesResolver { - override fun addReactNativeProperties( + +internal abstract class TextViewUtils(private val reactContext: ReactContext, private val drawableUtils: DrawableUtils) { + fun mapTextViewToWireframes( + wireframes: List, + view: TextView, + mappingContext: MappingContext, + ): List { + val result = mutableListOf() + val pixelDensity = mappingContext.systemInformation.screenDensity + + for (originalWireframe in wireframes) { + if (originalWireframe !is MobileSegment.Wireframe.TextWireframe) { + result.add(originalWireframe) + } else { + result.add(addReactNativeProperties( + originalWireframe = originalWireframe, + view = view, + pixelDensity = pixelDensity, + )) + } + } + + return result + } + + fun addReactNativeProperties( originalWireframe: MobileSegment.Wireframe.TextWireframe, view: TextView, pixelDensity: Float, @@ -59,25 +68,17 @@ internal class ReactTextPropertiesResolver( ) } - private fun resolveTextStyleAndPosition( + + protected fun resolveTextStyleAndPosition( originalWireframe: MobileSegment.Wireframe.TextWireframe, view: TextView, - pixelDensity: Float, - ): - Pair? { - + pixelDensity: Float + ): Pair? { if (!reactContext.hasActiveReactInstance()) { return null } - val shadowNodeWrapper: ShadowNodeWrapper = - ShadowNodeWrapper.getShadowNodeWrapper( - reactContext = reactContext, - uiManagerModule = uiManagerModule, - reflectionUtils = reflectionUtils, - viewId = view.id) ?: return null - - val textStyle = resolveTextStyle(originalWireframe, pixelDensity, shadowNodeWrapper) + val textStyle = resolveTextStyle(originalWireframe, pixelDensity, view) ?: return null val alignment = resolveTextAlignment(view, originalWireframe) val textPosition = MobileSegment.TextPosition( @@ -88,7 +89,7 @@ internal class ReactTextPropertiesResolver( return textStyle to textPosition } - private fun resolveShapeStyleAndBorder( + protected fun resolveShapeStyleAndBorder( view: TextView, pixelDensity: Float, ): Pair? { @@ -105,7 +106,7 @@ internal class ReactTextPropertiesResolver( return shapeStyle to border } - private fun resolveTextAlignment( + protected fun resolveTextAlignment( view: TextView, textWireframe: MobileSegment.Wireframe.TextWireframe ): MobileSegment.Alignment { @@ -126,64 +127,7 @@ internal class ReactTextPropertiesResolver( ) } - private fun resolveTextStyle( - textWireframe: MobileSegment.Wireframe.TextWireframe, - pixelsDensity: Float, - shadowNodeWrapper: ShadowNodeWrapper - ): MobileSegment.TextStyle { - val fontFamily = getFontFamily(shadowNodeWrapper) - ?: textWireframe.textStyle.family - val fontSize = getFontSize(shadowNodeWrapper) - ?.densityNormalized(pixelsDensity) - ?: textWireframe.textStyle.size - val fontColor = getTextColor(shadowNodeWrapper) - ?: textWireframe.textStyle.color - - return MobileSegment.TextStyle( - family = fontFamily, - size = fontSize, - color = fontColor - ) - } - - private fun getTextColor(shadowNodeWrapper: ShadowNodeWrapper): String? { - 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) - } - - return null - } - - private fun getFontSize(shadowNodeWrapper: ShadowNodeWrapper): Long? { - val textAttributes = shadowNodeWrapper - .getDeclaredShadowNodeField(TEXT_ATTRIBUTES_FIELD_NAME) as? TextAttributes? - if (textAttributes != null) { - return textAttributes.effectiveFontSize.toLong() - } - - return null - } - - private fun getFontFamily(shadowNodeWrapper: ShadowNodeWrapper): String? { - val fontFamily = shadowNodeWrapper - .getDeclaredShadowNodeField(FONT_FAMILY_FIELD_NAME) as? String - - if (fontFamily != null) { - return resolveFontFamily(fontFamily.lowercase(Locale.US)) - } - - return null - } - - private fun resolveFontFamily(typefaceName: String): String = + protected fun resolveFontFamily(typefaceName: String): String = when (typefaceName) { ROBOTO_TYPEFACE_NAME -> SANS_SERIF_FAMILY_NAME MONOSPACE_FAMILY_NAME -> MONOSPACE_FAMILY_NAME @@ -191,16 +135,36 @@ internal class ReactTextPropertiesResolver( else -> SANS_SERIF_FAMILY_NAME } + protected abstract fun resolveTextStyle( textWireframe: MobileSegment.Wireframe.TextWireframe, + pixelsDensity: Float, + view: TextView + ): MobileSegment.TextStyle? + @VisibleForTesting - internal companion object { + 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()) + } + } } -} + +} \ No newline at end of file diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupportTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupportTest.kt index 6ebf23bbf..668b1c637 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupportTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupportTest.kt @@ -11,6 +11,7 @@ import com.datadog.reactnative.sessionreplay.mappers.ReactEditTextMapper import com.datadog.reactnative.sessionreplay.mappers.ReactNativeImageViewMapper import com.datadog.reactnative.sessionreplay.mappers.ReactTextMapper import com.datadog.reactnative.sessionreplay.mappers.ReactViewGroupMapper +import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerModule @@ -51,10 +52,8 @@ internal class ReactNativeSessionReplayExtensionSupportTest { whenever(mockReactContext.getNativeModule(any>())) .doReturn(mockUiManagerModule) - testedExtensionSupport = ReactNativeSessionReplayExtensionSupport( - logger = mockLogger, - reactContext = mockReactContext - ) + val textViewUtils = TextViewUtils.create(mockReactContext, mockLogger) + testedExtensionSupport = ReactNativeSessionReplayExtensionSupport(textViewUtils) } @Test @@ -77,17 +76,4 @@ internal class ReactNativeSessionReplayExtensionSupportTest { assertThat(customViewMappers[3].getUnsafeMapper()) .isInstanceOf(ReactEditTextMapper::class.java) } - - @Test - fun `M return null W getUiManagerModule() { cannot get uiManagerModule }`() { - // Given - whenever(mockReactContext.getNativeModule(any>())) - .thenThrow(IllegalStateException()) - - // When - val uiManagerModule = testedExtensionSupport.getUiManagerModule() - - // Then - assertThat(uiManagerModule).isNull() - } } diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/TextViewUtilsTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/TextViewUtilsTest.kt deleted file mode 100644 index 485dce64d..000000000 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/TextViewUtilsTest.kt +++ /dev/null @@ -1,109 +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.utils - -import android.content.res.Resources -import android.graphics.Typeface -import android.util.DisplayMetrics -import android.widget.TextView -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.ReactTextPropertiesResolver -import fr.xgouchet.elmyr.Forge -import fr.xgouchet.elmyr.junit5.ForgeExtension -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.api.extension.Extensions -import org.mockito.Mock -import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.junit.jupiter.MockitoSettings -import org.mockito.kotlin.eq -import org.mockito.kotlin.whenever -import org.mockito.quality.Strictness - -@Extensions( - ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class) -) -@MockitoSettings(strictness = Strictness.LENIENT) -internal class TextViewUtilsTest { - private lateinit var testedUtils: TextViewUtils - - @Mock - private lateinit var mockReactTextPropertiesResolver: ReactTextPropertiesResolver - - @Mock - private lateinit var mockMappingContext: MappingContext - - @Mock - private lateinit var mockTextView: TextView - - @Mock - private lateinit var mockSystemInformation: SystemInformation - - @Mock - private lateinit var mockResources: Resources - - @Mock - private lateinit var mockDisplayMetrics: DisplayMetrics - - @BeforeEach - fun `set up`(forge: Forge) { - whenever(mockResources.displayMetrics).thenReturn(mockDisplayMetrics) - whenever(mockTextView.resources).thenReturn(mockResources) - whenever(mockSystemInformation.screenDensity).thenReturn(0f) - whenever(mockMappingContext.systemInformation).thenReturn(mockSystemInformation) - whenever(mockTextView.text).thenReturn(forge.aString()) - whenever(mockTextView.typeface).thenReturn(Typeface.SANS_SERIF) - - testedUtils = TextViewUtils() - } - - @Test - fun `M return wireframe W map() { even if not TextWireframeType }`( - @Mock mockImageWireframe: MobileSegment.Wireframe.ImageWireframe - ) { - // When - val result = testedUtils.mapTextViewToWireframes( - wireframes = listOf(mockImageWireframe), - view = mockTextView, - mappingContext = mockMappingContext, - reactTextPropertiesResolver = mockReactTextPropertiesResolver - ) - - // Then - assertThat(result).contains(mockImageWireframe) - } - - @Test - fun `M return textWireframe W map()`( - @Mock mockTextWireframe: MobileSegment.Wireframe.TextWireframe - ) { - // Given - whenever( - mockReactTextPropertiesResolver.addReactNativeProperties( - originalWireframe = eq(mockTextWireframe), - view = eq(mockTextView), - pixelDensity = eq(0f) - ) - ).thenReturn(mockTextWireframe) - - // When - val result = testedUtils.mapTextViewToWireframes( - wireframes = listOf(mockTextWireframe), - view = mockTextView, - mappingContext = mockMappingContext, - reactTextPropertiesResolver = mockReactTextPropertiesResolver - )[0] as MobileSegment.Wireframe.TextWireframe - - // Then - assertThat(result).isEqualTo(mockTextWireframe) - } -} diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolverTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtilsTest.kt similarity index 57% rename from packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolverTest.kt rename to packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtilsTest.kt index 93ab816f7..76ef8f6e8 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolverTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/utils/text/TextViewUtilsTest.kt @@ -4,20 +4,30 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.reactnative.sessionreplay +package com.datadog.reactnative.sessionreplay.utils.text +import android.content.res.Resources +import android.graphics.Typeface +import android.text.Spannable +import android.text.style.ForegroundColorSpan +import android.util.DisplayMetrics import android.widget.TextView +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.model.MobileSegment -import com.datadog.reactnative.sessionreplay.ReactTextPropertiesResolver.Companion.COLOR_FIELD_NAME -import com.datadog.reactnative.sessionreplay.ReactTextPropertiesResolver.Companion.FONT_FAMILY_FIELD_NAME -import com.datadog.reactnative.sessionreplay.ReactTextPropertiesResolver.Companion.IS_COLOR_SET_FIELD_NAME -import com.datadog.reactnative.sessionreplay.ReactTextPropertiesResolver.Companion.MONOSPACE_FAMILY_NAME -import com.datadog.reactnative.sessionreplay.ReactTextPropertiesResolver.Companion.TEXT_ATTRIBUTES_FIELD_NAME +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 @@ -34,7 +44,11 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions +import org.mockito.ArgumentMatchers.anyInt import org.mockito.Mock +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.spy import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any @@ -48,9 +62,7 @@ import org.mockito.quality.Strictness ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) -internal class ReactTextPropertiesResolverTest { - private lateinit var testedResolver: ReactTextPropertiesResolver - +internal class TextViewUtilsTest { @Mock lateinit var mockReactContext: ReactContext @@ -84,10 +96,44 @@ internal class ReactTextPropertiesResolverTest { @Mock private lateinit var mockShadowNode: ReactShadowNode> + @Mock + private lateinit var mockLogger: InternalLogger + + @Mock + private lateinit var mockMappingContext: MappingContext + + @Mock + private lateinit var mockSystemInformation: SystemInformation + + @Mock + private lateinit var mockResources: Resources + + @Mock + private lateinit var mockDisplayMetrics: DisplayMetrics + + @Mock + private lateinit var testedUtils: LegacyTextViewUtils + + @Mock + private lateinit var fabricTestedUtils: FabricTextViewUtils + @BeforeEach fun `set up`(forge: Forge) { + whenever(mockResources.displayMetrics).thenReturn(mockDisplayMetrics) + whenever(mockTextView.resources).thenReturn(mockResources) + 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(mockUiManagerModule, UI_IMPLEMENTATION_FIELD_NAME) + mockReflectionUtils.getDeclaredField( + eq(mockUiManagerModule), + eq(UI_IMPLEMENTATION_FIELD_NAME) + ) ).thenReturn(mockUiImplementation) whenever( @@ -101,12 +147,64 @@ internal class ReactTextPropertiesResolverTest { } whenever(mockReactContext.hasActiveReactInstance()).thenReturn(true) - testedResolver = ReactTextPropertiesResolver( - reactContext = mockReactContext, - uiManagerModule = mockUiManagerModule, - drawableUtils = mockDrawableUtils, - reflectionUtils = mockReflectionUtils - ) + val realUtils = + LegacyTextViewUtils( + mockReactContext, + mockLogger, + mockReflectionUtils, + mockDrawableUtils + ) + + val realFabricUtils = + FabricTextViewUtils( + mockReactContext, + mockLogger, + mockDrawableUtils + ) + + testedUtils = spy(realUtils) + fabricTestedUtils = spy(realFabricUtils) + } + + @Test + fun `M return wireframe W map() { even if not TextWireframeType }`( + @Mock mockImageWireframe: MobileSegment.Wireframe.ImageWireframe + ) { + // When + val result = + testedUtils.mapTextViewToWireframes( + wireframes = listOf(mockImageWireframe), + view = mockTextView, + mappingContext = mockMappingContext + ) + + // Then + assertThat(result).contains(mockImageWireframe) + } + + @Test + fun `M return textWireframe W map()`( + @Mock mockTextWireframe: MobileSegment.Wireframe.TextWireframe + ) { + // Given + doReturn(mockTextWireframe) + .whenever(testedUtils) + .addReactNativeProperties( + originalWireframe = eq(mockTextWireframe), + view = eq(mockTextView), + pixelDensity = eq(0f) + ) + + // When + val result = + testedUtils.mapTextViewToWireframes( + wireframes = listOf(mockTextWireframe), + view = mockTextView, + mappingContext = mockMappingContext + )[0] as MobileSegment.Wireframe.TextWireframe + + // Then + assertThat(result).isEqualTo(mockTextWireframe) } // region addReactNativeProperties @@ -118,16 +216,15 @@ internal class ReactTextPropertiesResolverTest { whenever(mockUiImplementation.resolveShadowNode(any())).thenReturn(null) // When - val result = testedResolver.addReactNativeProperties(mockWireframe, mockTextView, 0f) + val result = testedUtils.addReactNativeProperties(mockWireframe, mockTextView, 0f) // Then assertThat(result).isEqualTo(mockWireframe) } @Test - fun `M add drawable properties W addReactNativeProperties() { has reactBackgroundDrawable }`( - forge: Forge - ) { + fun `M add drawable properties W addReactNativeProperties() { has reactBackgroundDrawable }` + (forge: Forge) { // Given val pixelDensity = 0f val fakeBorderRadius = forge.aPositiveFloat() @@ -150,18 +247,20 @@ internal class ReactTextPropertiesResolverTest { backgroundColor = formatAsRgba(fakeBorderColor), opacity = 0f, cornerRadius = fakeBorderRadius.toLong() - ) to MobileSegment.ShapeBorder( - color = formatAsRgba(fakeBorderColor), - width = fakeBorderWidth.toLong() - ) + ) to + MobileSegment.ShapeBorder( + color = formatAsRgba(fakeBorderColor), + width = fakeBorderWidth.toLong() + ) ) // When - val result = testedResolver.addReactNativeProperties( - fakeWireframe, - mockTextView, - pixelDensity - ) + val result = + testedUtils.addReactNativeProperties( + fakeWireframe, + mockTextView, + pixelDensity + ) // Then assertThat(result.shapeStyle?.cornerRadius).isEqualTo(fakeBorderRadius.toLong()) @@ -178,8 +277,9 @@ internal class ReactTextPropertiesResolverTest { whenever(mockTextView.background).thenReturn(null) // When - val result = testedResolver - .addReactNativeProperties(fakeWireframe, mockTextView, 0f) + val result = + testedUtils + .addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then assertThat(result.textStyle.family) @@ -194,7 +294,7 @@ internal class ReactTextPropertiesResolverTest { .thenReturn(null) // When - val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then assertThat(result.textStyle.family).isEqualTo(fakeWireframe.textStyle.family) @@ -212,7 +312,7 @@ internal class ReactTextPropertiesResolverTest { whenever(mockTextAttributes.effectiveFontSize).thenReturn(fakeTextSize) // When - val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then assertThat(result.textStyle.size).isEqualTo(fakeTextSize.toLong()) @@ -228,7 +328,7 @@ internal class ReactTextPropertiesResolverTest { .thenReturn(null) // When - val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then assertThat(result.textStyle.size).isEqualTo(fakeWireframe.textStyle.size) @@ -246,7 +346,7 @@ internal class ReactTextPropertiesResolverTest { .thenReturn(fakeTextColor) // When - val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then assertThat(result.textStyle.color).isEqualTo(formatAsRgba(fakeTextColor)) @@ -264,7 +364,7 @@ internal class ReactTextPropertiesResolverTest { .thenReturn(fakeTextColor) // When - val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then assertThat(result.textStyle.color).isEqualTo("#000000FF") @@ -280,7 +380,7 @@ internal class ReactTextPropertiesResolverTest { .thenReturn(null) // When - val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then assertThat(result.textStyle.color).isEqualTo(fakeWireframe.textStyle.color) @@ -294,11 +394,44 @@ internal class ReactTextPropertiesResolverTest { .thenReturn(MONOSPACE_FAMILY_NAME) // When - val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + val result = testedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) // Then assertThat(result.textStyle.family).isNotEqualTo(MONOSPACE_FAMILY_NAME) } + @Test + fun `M return fabric textStyle (color) W addReactNativeProperties`() { + val mockForegroundColorSpan = mock(ForegroundColorSpan::class.java) + whenever(mockForegroundColorSpan.foregroundColor).thenReturn(-1) + + val spannable = mock(Spannable::class.java) + doReturn(spannable).whenever(fabricTestedUtils).getFieldFromView(any(), any()) + + whenever(spannable.getSpans(anyInt(), anyInt(), eq(ForegroundColorSpan::class.java))) + .thenReturn( + arrayOf(mockForegroundColorSpan) + ) + + val result = fabricTestedUtils.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + assertThat(result.textStyle.color).isEqualTo("#ffffffff") + } + + // 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 }