From 8f2601ec9d910855dbd94edf550032d77774dcc9 Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 17 Dec 2023 23:46:52 +0200 Subject: [PATCH] Refactor text mapping --- ...eactNativeSessionReplayExtensionSupport.kt | 26 +- .../ReactTextPropertiesResolver.kt | 198 ++++++++------- .../sessionreplay/ReactTextShadowNodeUtils.kt | 112 --------- .../ReactViewBackgroundDrawableExt.kt | 39 --- .../sessionreplay/mappers/ReactTextMapper.kt | 76 ++---- .../mappers/ReactViewGroupMapper.kt | 12 +- .../sessionreplay/{ => utils}/ColorUtils.kt | 2 +- .../{ => utils}/DrawableUtils.kt | 8 +- .../utils/ReactViewBackgroundDrawableUtils.kt | 48 ++++ .../sessionreplay/utils/ReflectionUtils.kt | 63 +++++ .../sessionreplay/ColorUtilsTest.kt | 1 + .../DdSessionReplayImplementationTest.kt | 11 + .../sessionreplay/DrawableUtilsTest.kt | 7 + ...NativeSessionReplayExtensionSupportTest.kt | 30 +++ .../ReactTextPropertiesResolverTest.kt | 236 ++++++++++++++++++ .../mappers/ReactTextMapperTest.kt | 131 ++++------ 16 files changed, 617 insertions(+), 383 deletions(-) delete mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextShadowNodeUtils.kt delete mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/ReactViewBackgroundDrawableExt.kt rename packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/{ => utils}/ColorUtils.kt (93%) rename packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/{ => utils}/DrawableUtils.kt (72%) create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ReactViewBackgroundDrawableUtils.kt create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ReflectionUtils.kt create mode 100644 packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolverTest.kt 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 d79a958e6..ea90458da 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,7 +6,10 @@ package com.datadog.reactnative.sessionreplay +import android.annotation.SuppressLint +import android.util.Log import android.view.View +import androidx.annotation.VisibleForTesting import com.datadog.android.sessionreplay.ExtensionSupport import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.datadog.android.sessionreplay.internal.recorder.OptionSelectorDetector @@ -14,6 +17,7 @@ import com.datadog.android.sessionreplay.internal.recorder.mapper.WireframeMappe 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.facebook.react.views.text.ReactTextView import com.facebook.react.views.textinput.ReactEditText import com.facebook.react.views.view.ReactViewGroup @@ -23,11 +27,13 @@ internal class ReactNativeSessionReplayExtensionSupport( ) : ExtensionSupport { override fun getCustomViewMappers(): Map, WireframeMapper>> { + val uiManagerModule = getUiManagerModule() + return mapOf( SessionReplayPrivacy.ALLOW to mapOf( ReactViewGroup::class.java to ReactViewGroupMapper() as WireframeMapper, - ReactTextView::class.java to ReactTextMapper(reactContext) as WireframeMapper, - ReactEditText::class.java to ReactTextMapper(reactContext) as WireframeMapper + ReactTextView::class.java to ReactTextMapper(reactContext, uiManagerModule) as WireframeMapper, + ReactEditText::class.java to ReactTextMapper(reactContext, uiManagerModule) as WireframeMapper ) ) } @@ -35,4 +41,20 @@ internal class ReactNativeSessionReplayExtensionSupport( override fun getOptionSelectorDetectors(): List { return listOf() } + + @SuppressLint("LongLogTag") + @VisibleForTesting + internal fun getUiManagerModule(): UIManagerModule? { + return try { + reactContext.getNativeModule(UIManagerModule::class.java) as UIManagerModule + } catch (e: IllegalStateException) { + Log.e(TAG, RESOLVE_UIMANAGERMODULE_ERROR, e) + return null + } + } + + internal companion object { + internal const val TAG = "ReactNativeSessionReplayExtensionSupport" + 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/ReactTextPropertiesResolver.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolver.kt index ca68d00c9..d6c2b73d5 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/ReactTextPropertiesResolver.kt @@ -1,133 +1,149 @@ +/* + * 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.view.Gravity import android.widget.TextView +import androidx.annotation.VisibleForTesting import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.reactnative.sessionreplay.extensions.convertToDensityNormalized -import com.datadog.reactnative.sessionreplay.extensions.densityNormalized -import com.facebook.react.views.text.ReactTextShadowNode +import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils +import com.datadog.reactnative.sessionreplay.utils.formatAsRgba +import com.datadog.reactnative.sessionreplay.utils.getReactBackgroundFromDrawable +import com.datadog.reactnative.sessionreplay.utils.resolveShapeAndBorder +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerModule +import com.facebook.react.views.text.TextAttributes +import com.facebook.react.views.view.ReactViewBackgroundDrawable +import java.util.Locale internal class ReactTextPropertiesResolver( - private val reactTextShadowNodeUtils: ReactTextShadowNodeUtils + private val reactContext: ReactContext, + private val uiManagerModule: UIManagerModule?, + private val reflectionUtils: ReflectionUtils = ReflectionUtils(reactContext, uiManagerModule) ) { - internal fun resolveShadowProperties( + internal fun addReactNativeProperties( + originalWireframe: MobileSegment.Wireframe.TextWireframe, + view: TextView, + pixelDensity: Float, + ): MobileSegment.Wireframe.TextWireframe { + val reflectionUtilsSuccessfullyInitialized = reflectionUtils.initialize(view.id) + if (!reflectionUtilsSuccessfullyInitialized) { + return originalWireframe + } + + var textWireframe: MobileSegment.Wireframe.TextWireframe = + resolveDrawableProperties(view, pixelDensity, originalWireframe) + textWireframe = getReactNativeTextProperties(textWireframe, pixelDensity) + return textWireframe + } + + private fun resolveDrawableProperties( view: TextView, pixelDensity: Float, - shapeStyle: MobileSegment.ShapeStyle?, - border: MobileSegment.ShapeBorder?, textWireframe: MobileSegment.Wireframe.TextWireframe - ): MobileSegment.Wireframe { + ): MobileSegment.Wireframe.TextWireframe { + val backgroundDrawable: ReactViewBackgroundDrawable = + getReactBackgroundFromDrawable(view.background) ?: return textWireframe - val shadowNode = reactTextShadowNodeUtils.getShadowNode(view.id) as? ReactTextShadowNode + var resultWireframe = textWireframe - val textStyle = resolveTextStyle(view, pixelDensity, shadowNode) - val textPosition = resolveTextPosition(view, pixelDensity, shadowNode) + // view.alpha is the value of the opacity prop on the js side + val opacity = view.alpha - return textWireframe.copy( + val (shapeStyle, border) = + resolveShapeAndBorder(backgroundDrawable, opacity, pixelDensity) + + if (shapeStyle != null || border != null) { + resultWireframe = resultWireframe.copy( shapeStyle = shapeStyle, - border = border, - textStyle = textStyle, - textPosition = textPosition + border = border ) + } + + return resultWireframe } - private fun resolveTextPosition( - view: TextView, - pixelsDensity: Float, - shadowNode: ReactTextShadowNode? - ): MobileSegment.TextPosition { - return MobileSegment.TextPosition( - resolvePadding(view, pixelsDensity), - resolveAlignment(view, shadowNode) + private fun getReactNativeTextProperties( + textWireframe: MobileSegment.Wireframe.TextWireframe, + pixelDensity: Float, + ): MobileSegment.Wireframe.TextWireframe { + val textStyle = resolveTextStyle(textWireframe, pixelDensity) + val textPosition = textWireframe.textPosition + + return textWireframe.copy( + textStyle = textStyle, + textPosition = textPosition ) } private fun resolveTextStyle( - view: TextView, + textWireframe: MobileSegment.Wireframe.TextWireframe, pixelsDensity: Float, - shadowNode: ReactTextShadowNode? ): MobileSegment.TextStyle { - return MobileSegment.TextStyle( - family = reactTextShadowNodeUtils.getFontFamily(view, shadowNode), - size = reactTextShadowNodeUtils.getFontSize(view, shadowNode).convertToDensityNormalized(pixelsDensity), - color = resolveTextColor(view, shadowNode) - ) - } + val fontFamily = getFontFamily() + ?: textWireframe.textStyle.family + val fontSize = getFontSize() + ?.convertToDensityNormalized(pixelsDensity) + ?: textWireframe.textStyle.size + val fontColor = getTextColor() + ?: textWireframe.textStyle.color - private fun resolvePadding(textView: TextView, pixelsDensity: Float): MobileSegment.Padding { - return MobileSegment.Padding( - top = textView.totalPaddingTop.densityNormalized(pixelsDensity).toLong(), - bottom = textView.totalPaddingBottom.densityNormalized(pixelsDensity).toLong(), - left = textView.totalPaddingStart.densityNormalized(pixelsDensity).toLong(), - right = textView.totalPaddingEnd.densityNormalized(pixelsDensity).toLong() + return MobileSegment.TextStyle( + family = fontFamily, + size = fontSize, + color = fontColor ) } - private fun resolveAlignment( - textView: TextView, - shadowNode: ReactTextShadowNode? - ): MobileSegment.Alignment { - return when (textView.textAlignment) { - TextView.TEXT_ALIGNMENT_GRAVITY -> resolveShadowAlignment(textView, shadowNode = shadowNode) - TextView.TEXT_ALIGNMENT_CENTER -> MobileSegment.Alignment( - horizontal = MobileSegment.Horizontal.CENTER, - vertical = MobileSegment.Vertical.CENTER - ) - - TextView.TEXT_ALIGNMENT_TEXT_END, - TextView.TEXT_ALIGNMENT_VIEW_END -> MobileSegment.Alignment( - horizontal = MobileSegment.Horizontal.RIGHT, - vertical = MobileSegment.Vertical.CENTER - ) - - TextView.TEXT_ALIGNMENT_TEXT_START, - TextView.TEXT_ALIGNMENT_VIEW_START -> MobileSegment.Alignment( - horizontal = MobileSegment.Horizontal.LEFT, - vertical = MobileSegment.Vertical.CENTER - ) - - else -> MobileSegment.Alignment( - horizontal = MobileSegment.Horizontal.LEFT, - vertical = MobileSegment.Vertical.CENTER - ) + private fun getTextColor(): String? { + val resolvedColor = reflectionUtils.getReflectedFieldValue(COLOR_FIELD_NAME) as Int? + if (resolvedColor != null) { + return formatAsRgba(resolvedColor) } - } - - private fun resolveShadowAlignment( - view: TextView, - shadowNode: ReactTextShadowNode? - ): MobileSegment.Alignment { - - val shadowGravity = reactTextShadowNodeUtils.getGravity(view, shadowNode) + return null + } - val horizontalAlignment = when (shadowGravity.and(Gravity.HORIZONTAL_GRAVITY_MASK)) { - Gravity.START, - Gravity.LEFT -> MobileSegment.Horizontal.LEFT + private fun getFontSize(): Long? { + val textAttributes = reflectionUtils.getReflectedFieldValue(TEXT_ATTRIBUTES_FIELD_NAME) as? TextAttributes? + if (textAttributes != null) { + return textAttributes.effectiveFontSize.toLong() + } - Gravity.END, - Gravity.RIGHT -> MobileSegment.Horizontal.RIGHT + return null + } - Gravity.CENTER, - Gravity.CENTER_HORIZONTAL -> MobileSegment.Horizontal.CENTER + private fun getFontFamily(): String? { + val fontFamily = reflectionUtils.getReflectedFieldValue(FONT_FAMILY_FIELD_NAME) as? String - else -> MobileSegment.Horizontal.LEFT + if (fontFamily != null) { + return resolveFontFamily(fontFamily.lowercase(Locale.US)) } - val verticalAlignment = when (shadowGravity.and(Gravity.VERTICAL_GRAVITY_MASK)) { - Gravity.TOP -> MobileSegment.Vertical.TOP - Gravity.BOTTOM -> MobileSegment.Vertical.BOTTOM - Gravity.CENTER_VERTICAL, - Gravity.CENTER -> MobileSegment.Vertical.CENTER + return null + } - else -> MobileSegment.Vertical.CENTER + private fun resolveFontFamily(typefaceName: String): String = + when (typefaceName) { + ROBOTO_TYPEFACE_NAME -> SANS_SERIF_FAMILY_NAME + MONOSPACE_FAMILY_NAME -> MONOSPACE_FAMILY_NAME + SERIF_FAMILY_NAME -> SERIF_FAMILY_NAME + else -> SANS_SERIF_FAMILY_NAME } - return MobileSegment.Alignment(horizontalAlignment, verticalAlignment) - } + @VisibleForTesting + internal 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" - private fun resolveTextColor(view: TextView, shadowNode: ReactTextShadowNode?): String { - return formatAsRgba(reactTextShadowNodeUtils.getColor(view, shadowNode)) + 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" } } - diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextShadowNodeUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextShadowNodeUtils.kt deleted file mode 100644 index c080a0e5d..000000000 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextShadowNodeUtils.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.datadog.reactnative.sessionreplay - -import android.annotation.SuppressLint -import android.graphics.Typeface -import android.util.Log -import android.widget.TextView -import androidx.annotation.VisibleForTesting -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.ReactTextShadowNode -import com.facebook.react.views.text.TextAttributes -import okhttp3.internal.notify -import okhttp3.internal.wait - -internal class ReactTextShadowNodeUtils( - private val reactContext: ReactContext -) { - internal fun getFontSize(view: TextView, shadowNode: ReactTextShadowNode?): Long { - shadowNode?.javaClass?.superclass?.getDeclaredField("mTextAttributes")?.let { - it.isAccessible = true - val textAttributes = it.get(shadowNode) as TextAttributes - return textAttributes.effectiveFontSize.toLong() - } - - return view.textSize.toLong() - } - - internal fun getFontFamily(view: TextView, shadowNode: ReactTextShadowNode?): String { - shadowNode?.javaClass?.superclass?.getDeclaredField("mFontFamily")?.let { - it.isAccessible = true - return it.get(shadowNode)?.toString() - ?: resolveFontFamily(view.typeface) - } - - return resolveFontFamily(view.typeface) - } - - internal fun getGravity(view: TextView, shadowNode: ReactTextShadowNode?): Int { - shadowNode?.javaClass?.superclass?.getDeclaredField("mTextAlign")?.let { - it.isAccessible = true - return it.getInt(shadowNode) - } - - return view.gravity - } - - internal fun getColor(view: TextView, shadowNode: ReactTextShadowNode?): Int { - shadowNode?.javaClass?.superclass?.getDeclaredField("mColor")?.let { - it.isAccessible = true - return it.getInt(shadowNode) - } - - return view.currentTextColor - } - - internal fun getShadowNode(viewId: Int): ReactShadowNode>? { - val uiManagerModule = try { - reactContext.getNativeModule(UIManagerModule::class.java) as UIManagerModule - } catch (e: IllegalStateException) { - Log.e( - ReactTextShadowNodeUtils::class.java.canonicalName, - "Unable to resolve uiManagerModule", - e - ) - return null - } - var target: ReactShadowNode>? = null - val shadowNodeRunnable = Runnable { - val node = uiManagerModule.resolveShadowNode(viewId) - if (node != null) { - target = node - } - synchronized(this) { - this.notify() - } - } - synchronized(this) { - reactContext.runOnNativeModulesQueueThread(shadowNodeRunnable) - this.wait() - return target - } - } - - private fun UIManagerModule.resolveShadowNode(tag: Int): ReactShadowNode>? { - javaClass.getDeclaredField("mUIImplementation").let { - it.isAccessible = true - val value = it.get(this) as UIImplementation - return value.resolveShadowNode(tag) - } - } - - @SuppressLint("VisibleForTests") - private fun resolveFontFamily(typeface: Typeface?): String = - typefaceMap[typeface] - ?: SANS_SERIF_FAMILY_NAME - - internal companion object { - @VisibleForTesting - internal const val SANS_SERIF_FAMILY_NAME = "roboto, sans-serif" - private const val SERIF_FAMILY_NAME = "serif" - private const val MONOSPACE_FAMILY_NAME = "monospace" - - @VisibleForTesting - internal val typefaceMap: Map = mapOf( - Typeface.SANS_SERIF to SANS_SERIF_FAMILY_NAME, - Typeface.MONOSPACE to MONOSPACE_FAMILY_NAME, - Typeface.SERIF to SERIF_FAMILY_NAME - ) - } -} diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/ReactViewBackgroundDrawableExt.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/ReactViewBackgroundDrawableExt.kt deleted file mode 100644 index 83f5a19b1..000000000 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/ReactViewBackgroundDrawableExt.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.datadog.reactnative.sessionreplay.extensions - -import com.datadog.android.sessionreplay.model.MobileSegment -import com.datadog.reactnative.sessionreplay.formatAsRgba -import com.facebook.react.uimanager.Spacing -import com.facebook.react.views.view.ReactViewBackgroundDrawable - -internal fun ReactViewBackgroundDrawable.resolveShapeAndBorder(opacity: Float, pixelDensity: Float): Pair { - val borderProps = resolveBorder(this, pixelDensity) - val colorHexString = formatAsRgba(this.getBackgroundColor()) - val cornerRadius = this.fullBorderRadius.toLong().convertToDensityNormalized(pixelDensity) - - return MobileSegment.ShapeStyle( - colorHexString, - opacity, - cornerRadius - ) to borderProps -} - -private fun ReactViewBackgroundDrawable.getBackgroundColor(): Int { - javaClass.getDeclaredField("mColor").let { - it.isAccessible = true - return it.getInt(this) - } -} - -private fun resolveBorder(backgroundDrawable: ReactViewBackgroundDrawable?, pixelDensity: Float): MobileSegment.ShapeBorder? { - if (backgroundDrawable == null) { - return null - } - - val borderWidth = backgroundDrawable.fullBorderWidth.toLong().convertToDensityNormalized(pixelDensity) - val borderColor = formatAsRgba(backgroundDrawable.getBorderColor(Spacing.ALL)) - - return MobileSegment.ShapeBorder( - color = borderColor, - width = borderWidth - ) -} 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 7f60e57c5..6635d6661 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 @@ -12,72 +12,46 @@ import com.datadog.android.sessionreplay.internal.AsyncJobStatusCallback import com.datadog.android.sessionreplay.internal.recorder.MappingContext import com.datadog.android.sessionreplay.internal.recorder.mapper.TextViewMapper import com.datadog.android.sessionreplay.model.MobileSegment -import com.datadog.reactnative.sessionreplay.ReactTextShadowNodeUtils import com.datadog.reactnative.sessionreplay.ReactTextPropertiesResolver -import com.datadog.reactnative.sessionreplay.extensions.resolveShapeAndBorder -import com.datadog.reactnative.sessionreplay.getReactBackgroundFromDrawable import com.facebook.react.bridge.ReactContext -import com.facebook.react.views.view.ReactViewBackgroundDrawable - -internal class ReactTextMapper(): TextViewMapper() { - private lateinit var reactTextPropertiesResolver: ReactTextPropertiesResolver - - internal constructor( - reactContext: ReactContext - ): this() { - val reactTextShadowNodeUtils = ReactTextShadowNodeUtils(reactContext) - this.reactTextPropertiesResolver = ReactTextPropertiesResolver(reactTextShadowNodeUtils) - } - - internal constructor( - reactContext: ReactContext, - reactTextShadowNodeUtils: ReactTextShadowNodeUtils = ReactTextShadowNodeUtils(reactContext), - reactTextPropertiesResolver: ReactTextPropertiesResolver = ReactTextPropertiesResolver(reactTextShadowNodeUtils) - ) : this() { - this.reactTextPropertiesResolver = reactTextPropertiesResolver - } - +import com.facebook.react.uimanager.UIManagerModule + +internal class ReactTextMapper( + private val reactContext: ReactContext, + private val uiManagerModule: UIManagerModule?, + private val reactTextPropertiesResolver: ReactTextPropertiesResolver = + ReactTextPropertiesResolver(reactContext, uiManagerModule) +): TextViewMapper() { override fun map( view: TextView, mappingContext: MappingContext, asyncJobStatusCallback: AsyncJobStatusCallback ): List { + val result: MutableList = mutableListOf() val wireframes = mapOnSuperclass(view, mappingContext, asyncJobStatusCallback) + val pixelDensity = mappingContext.systemInformation.screenDensity - val result: MutableList = mutableListOf() - wireframes.forEach{ - if (it !is MobileSegment.Wireframe.TextWireframe) { - result.add(it) + wireframes.forEach{ originalWireframe -> + if (originalWireframe !is MobileSegment.Wireframe.TextWireframe) { + result.add(originalWireframe) } else { - val pixelDensity = mappingContext.systemInformation.screenDensity - - // view.alpha is the value of the opacity prop on the js side - val opacity = view.alpha - - val backgroundDrawable: ReactViewBackgroundDrawable? = - getReactBackgroundFromDrawable(view.background) - - val (shapeStyle, border) = - backgroundDrawable?.resolveShapeAndBorder(opacity, pixelDensity) - ?: view.background?.resolveShapeStyleAndBorder(opacity) - ?: (null to null) - - result.add(reactTextPropertiesResolver.resolveShadowProperties( - view, - pixelDensity, - shapeStyle, - border, - it - )) + result.add(reactTextPropertiesResolver.addReactNativeProperties( + originalWireframe = originalWireframe, + view = view, + pixelDensity = pixelDensity, + )) } } - return result + return result.toList() } @VisibleForTesting - internal fun mapOnSuperclass(view: TextView, mappingContext: MappingContext, asyncJobStatusCallback: AsyncJobStatusCallback) - : List { - return super.map(view, mappingContext, asyncJobStatusCallback) + internal fun mapOnSuperclass( + textView: TextView, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): List { + return super.map(textView, mappingContext, asyncJobStatusCallback) } } diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt index 952555156..de2653b9a 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt @@ -11,8 +11,8 @@ import com.datadog.android.sessionreplay.internal.recorder.MappingContext import com.datadog.android.sessionreplay.internal.recorder.mapper.BaseWireframeMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.TraverseAllChildrenMapper import com.datadog.android.sessionreplay.model.MobileSegment -import com.datadog.reactnative.sessionreplay.extensions.resolveShapeAndBorder -import com.datadog.reactnative.sessionreplay.getReactBackgroundFromDrawable +import com.datadog.reactnative.sessionreplay.utils.getReactBackgroundFromDrawable +import com.datadog.reactnative.sessionreplay.utils.resolveShapeAndBorder import com.facebook.react.views.view.ReactViewBackgroundDrawable import com.facebook.react.views.view.ReactViewGroup @@ -34,9 +34,11 @@ internal class ReactViewGroupMapper : val opacity = view.alpha val (shapeStyle, border) = - backgroundDrawable?.resolveShapeAndBorder(opacity, pixelDensity) - ?: view.background?.resolveShapeStyleAndBorder(opacity) - ?: (null to null) + if (backgroundDrawable != null) { + resolveShapeAndBorder(backgroundDrawable, opacity, pixelDensity) + } else { + null to null + } return listOf( MobileSegment.Wireframe.ShapeWireframe( diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ColorUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ColorUtils.kt similarity index 93% rename from packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ColorUtils.kt rename to packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ColorUtils.kt index 8b681e940..a3c36aebe 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ColorUtils.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ColorUtils.kt @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.reactnative.sessionreplay +package com.datadog.reactnative.sessionreplay.utils private const val HEX_COLOR_INCLUDING_ALPHA_LENGTH: Int = 8 diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/DrawableUtils.kt similarity index 72% rename from packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtils.kt rename to packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/DrawableUtils.kt index ad6705833..be63ccb99 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtils.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/DrawableUtils.kt @@ -1,4 +1,10 @@ -package com.datadog.reactnative.sessionreplay +/* + * 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.graphics.drawable.Drawable import android.graphics.drawable.InsetDrawable diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ReactViewBackgroundDrawableUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ReactViewBackgroundDrawableUtils.kt new file mode 100644 index 000000000..ec0e6566e --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ReactViewBackgroundDrawableUtils.kt @@ -0,0 +1,48 @@ +/* + * 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 com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.reactnative.sessionreplay.extensions.convertToDensityNormalized +import com.facebook.react.uimanager.Spacing +import com.facebook.react.views.view.ReactViewBackgroundDrawable + +internal fun resolveShapeAndBorder( + drawable: ReactViewBackgroundDrawable, + opacity: Float, + pixelDensity: Float +): Pair { + val borderProps = resolveBorder(drawable, pixelDensity) + val colorHexString = formatAsRgba(drawable.getBackgroundColor()) + val cornerRadius = drawable.fullBorderRadius.toLong().convertToDensityNormalized(pixelDensity) + + return MobileSegment.ShapeStyle( + colorHexString, + opacity, + cornerRadius + ) to borderProps +} + +private fun ReactViewBackgroundDrawable.getBackgroundColor(): Int { + javaClass.getDeclaredField("mColor").let { + it.isAccessible = true + return it.getInt(this) + } +} + +private fun resolveBorder( + backgroundDrawable: ReactViewBackgroundDrawable, + pixelDensity: Float +): MobileSegment.ShapeBorder? { + val borderWidth = backgroundDrawable.fullBorderWidth.toLong().convertToDensityNormalized(pixelDensity) + val borderColor = formatAsRgba(backgroundDrawable.getBorderColor(Spacing.ALL)) + + return MobileSegment.ShapeBorder( + color = borderColor, + width = borderWidth + ) +} diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ReflectionUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ReflectionUtils.kt new file mode 100644 index 000000000..3922e7b56 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ReflectionUtils.kt @@ -0,0 +1,63 @@ +/* + * 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 com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.UIImplementation +import com.facebook.react.uimanager.UIManagerModule +import okhttp3.internal.notify +import okhttp3.internal.wait + +internal class ReflectionUtils( + private val reactContext: ReactContext, + private val uiManagerModule: UIManagerModule?, +) { + private var shadowNode: ReactShadowNode>? = null + + internal fun initialize(viewId: Int): Boolean { + shadowNode = getShadowNode(viewId) + return shadowNode != null + } + + internal fun getReflectedFieldValue(fieldName: String): Any? { + try { + shadowNode?.javaClass?.superclass?.getDeclaredField(fieldName)?.let { + it.isAccessible = true + return it.get(shadowNode) + } + } catch (ignored: NoSuchFieldException) {} + + return null + } + + private fun getShadowNode(viewId: Int): ReactShadowNode>? { + var target: ReactShadowNode>? = null + val shadowNodeRunnable = Runnable { + val node = uiManagerModule?.resolveShadowNode(viewId) + if (node != null) { + target = node + } + synchronized(this) { + this.notify() + } + } + synchronized(this) { + reactContext.runOnNativeModulesQueueThread(shadowNodeRunnable) + this.wait() + return target + } + } + + private fun UIManagerModule.resolveShadowNode(tag: Int): ReactShadowNode>? { + javaClass.getDeclaredField("mUIImplementation").let { + it.isAccessible = true + val value = it.get(this) as UIImplementation + return value.resolveShadowNode(tag) + } + } +} diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ColorUtilsTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ColorUtilsTest.kt index 2c9c36aaa..fc8ae3ac8 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ColorUtilsTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ColorUtilsTest.kt @@ -6,6 +6,7 @@ package com.datadog.reactnative.sessionreplay +import com.datadog.reactnative.sessionreplay.utils.formatAsRgba import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt index c875202ef..d449536ac 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt @@ -9,8 +9,10 @@ package com.datadog.reactnative.sessionreplay import com.datadog.android.sessionreplay.SessionReplayConfiguration import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.datadog.tools.unit.GenericAssert.Companion.assertThat +import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerModule import fr.xgouchet.elmyr.annotation.DoubleForgery import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.StringForgery @@ -23,8 +25,11 @@ 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.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @Extensions( @@ -45,8 +50,14 @@ internal class DdSessionReplayImplementationTest { @Mock lateinit var mockSessionReplay: SessionReplayWrapper + @Mock + lateinit var mockUiManagerModule: UIManagerModule + @BeforeEach fun `set up`() { + whenever(mockReactContext.getNativeModule(any>())) + .doReturn(mockUiManagerModule) + testedSessionReplay = DdSessionReplayImplementation(mockReactContext) { mockSessionReplay } } diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtilsTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtilsTest.kt index 77707d444..d5a7bc71e 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtilsTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtilsTest.kt @@ -1,8 +1,15 @@ +/* + * 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.graphics.drawable.ColorDrawable import android.graphics.drawable.InsetDrawable import android.graphics.drawable.LayerDrawable +import com.datadog.reactnative.sessionreplay.utils.getReactBackgroundFromDrawable import com.facebook.react.views.view.ReactViewBackgroundDrawable import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat 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 e7f4aecfb..7437f5f78 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 @@ -1,10 +1,18 @@ +/* + * 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 com.datadog.android.sessionreplay.SessionReplayPrivacy import com.datadog.reactnative.sessionreplay.mappers.ReactTextMapper import com.datadog.reactnative.sessionreplay.mappers.ReactViewGroupMapper import com.datadog.tools.unit.GenericAssert.Companion.assertThat +import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerModule import com.facebook.react.views.text.ReactTextView import com.facebook.react.views.textinput.ReactEditText import com.facebook.react.views.view.ReactViewGroup @@ -16,6 +24,9 @@ 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.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @Extensions( @@ -28,10 +39,16 @@ internal class ReactNativeSessionReplayExtensionSupportTest { @Mock lateinit var mockReactContext: ReactContext + @Mock + lateinit var mockUiManagerModule: UIManagerModule + private lateinit var testedExtensionSupport: ReactNativeSessionReplayExtensionSupport @BeforeEach fun `set up`() { + whenever(mockReactContext.getNativeModule(any>())) + .doReturn(mockUiManagerModule) + testedExtensionSupport = ReactNativeSessionReplayExtensionSupport(mockReactContext) } @@ -50,4 +67,17 @@ internal class ReactNativeSessionReplayExtensionSupportTest { assertThat(allowMappers?.get(ReactEditText::class.java)) .isInstanceOf(ReactTextMapper::class.java) } + + @Test + fun `M return null W getCustomViewMappers() { 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/ReactTextPropertiesResolverTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolverTest.kt new file mode 100644 index 000000000..03f1a6b1a --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolverTest.kt @@ -0,0 +1,236 @@ +/* + * 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 +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.TEXT_ATTRIBUTES_FIELD_NAME +import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils +import com.datadog.reactnative.sessionreplay.utils.formatAsRgba +import com.datadog.tools.unit.GenericAssert.Companion.assertThat +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.Spacing +import com.facebook.react.uimanager.UIManagerModule +import com.facebook.react.views.text.TextAttributes +import com.facebook.react.views.view.ReactViewBackgroundDrawable +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.junit5.ForgeExtension +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.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class ReactTextPropertiesResolverTest { + private lateinit var testedResolver: ReactTextPropertiesResolver + + @Mock + lateinit var mockReactContext: ReactContext + + @Mock + lateinit var mockUiManagerModule: UIManagerModule + + @Mock + lateinit var mockReflectionUtils: ReflectionUtils + + @Mock + lateinit var mockWireframe: MobileSegment.Wireframe.TextWireframe + + @Mock + lateinit var mockTextView: TextView + + @Mock + lateinit var mockReactViewBackgroundDrawable: ReactViewBackgroundDrawable + + private lateinit var fakeWireframe: MobileSegment.Wireframe.TextWireframe + + @BeforeEach + fun `set up`(forge: Forge) { + fakeWireframe = MobileSegment.Wireframe.TextWireframe( + id = forge.aPositiveLong(), + x = forge.aPositiveLong(), + y = forge.aPositiveLong(), + width = forge.aPositiveLong(), + height = forge.aPositiveLong(), + clip = null, + shapeStyle = null, + border = null, + text = forge.anAsciiString(), + MobileSegment.TextStyle( + family = "Serif", + size = forge.aPositiveLong(), + "#000000" + ), + textPosition = null + ) + + testedResolver = ReactTextPropertiesResolver( + mockReactContext, + mockUiManagerModule, + mockReflectionUtils + ) + } + + @Test + fun `M get original wireframe W addRNProperties() { no react properties }`() { + // Given + whenever(mockTextView.background).thenReturn(null) + + // When + val result = testedResolver.addReactNativeProperties(mockWireframe, mockTextView, 0f) + + // Then + assertThat(result).isEqualTo(mockWireframe) + } + + @Test + fun `M add drawable properties W addRNProperties() { has reactBackgroundDrawable }`( + forge: Forge + ) { + // Given + whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + + val pixelDensity = 0f + val fakeBorderRadius = forge.aPositiveFloat() + val fakeBorderWidth = forge.aPositiveFloat() + val fakeBorderColor = forge.aPositiveInt() + whenever(mockTextView.alpha).thenReturn(0f) + whenever(mockTextView.background).thenReturn(mockReactViewBackgroundDrawable) + whenever(mockReactViewBackgroundDrawable.fullBorderRadius).thenReturn(fakeBorderRadius) + whenever(mockReactViewBackgroundDrawable.fullBorderWidth).thenReturn(fakeBorderWidth) + whenever(mockReactViewBackgroundDrawable.getBorderColor(Spacing.ALL)) + .thenReturn(fakeBorderColor) + + // When + val result = testedResolver.addReactNativeProperties( + fakeWireframe, + mockTextView, + pixelDensity + ) + + // Then + assertThat(result.shapeStyle?.cornerRadius).isEqualTo(fakeBorderRadius.toLong()) + assertThat(result.border?.width).isEqualTo(fakeBorderWidth.toLong()) + assertThat(result.border?.color).isEqualTo(formatAsRgba(fakeBorderColor)) + } + + // region addRNProperties + + @Test + fun `M resolve font family W addRNProperties()`() { + // Given + whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockTextView.background).thenReturn(null) + whenever(mockReflectionUtils.getReflectedFieldValue(FONT_FAMILY_FIELD_NAME)) + .thenReturn(ReactTextPropertiesResolver.MONOSPACE_FAMILY_NAME) + + // When + val result = testedResolver + .addReactNativeProperties(fakeWireframe, mockTextView, 0f) + + // Then + assertThat(result.textStyle.family) + .isEqualTo(ReactTextPropertiesResolver.MONOSPACE_FAMILY_NAME) + } + + @Test + fun `M fallback W addRNProperties() { cant resolve fontFamily }`() { + // Given + whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockTextView.background).thenReturn(null) + whenever(mockReflectionUtils.getReflectedFieldValue(FONT_FAMILY_FIELD_NAME)) + .thenReturn(null) + + // When + val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + + // Then + assertThat(result.textStyle.family).isEqualTo(fakeWireframe.textStyle.family) + } + + @Test + fun `M resolve font size W addRNProperties()`( + @Mock mockTextAttributes: TextAttributes, + @IntForgery fakeTextSize: Int + ) { + // Given + whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockTextView.background).thenReturn(null) + whenever(mockReflectionUtils.getReflectedFieldValue(TEXT_ATTRIBUTES_FIELD_NAME)) + .thenReturn(mockTextAttributes) + whenever(mockTextAttributes.effectiveFontSize).thenReturn(fakeTextSize) + + // When + val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + + // Then + assertThat(result.textStyle.size).isEqualTo(fakeTextSize.toLong()) + } + + @Test + fun `M fallback W addRNProperties() { cant resolve fontSize }`( + @Mock mockTextAttributes: TextAttributes + ) { + // Given + whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockTextView.background).thenReturn(null) + whenever(mockReflectionUtils.getReflectedFieldValue(TEXT_ATTRIBUTES_FIELD_NAME)) + .thenReturn(null) + + // When + val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + + // Then + assertThat(result.textStyle.size).isEqualTo(fakeWireframe.textStyle.size) + } + + @Test + fun `M resolve font color W addRNProperties()`( + @IntForgery fakeTextColor: Int + ) { + // Given + whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockTextView.background).thenReturn(null) + whenever(mockReflectionUtils.getReflectedFieldValue(COLOR_FIELD_NAME)) + .thenReturn(fakeTextColor) + + // When + val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + + // Then + assertThat(result.textStyle.color).isEqualTo(formatAsRgba(fakeTextColor)) + } + + @Test + fun `M fallback W addRNProperties() { cant resolve fontColor }`() { + // Given + whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockTextView.background).thenReturn(null) + whenever(mockReflectionUtils.getReflectedFieldValue(COLOR_FIELD_NAME)) + .thenReturn(null) + + // When + val result = testedResolver.addReactNativeProperties(fakeWireframe, mockTextView, 0f) + + // Then + assertThat(result.textStyle.color).isEqualTo(fakeWireframe.textStyle.color) + } + + // endregion +} diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapperTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapperTest.kt index 21f121eb0..bba372064 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapperTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapperTest.kt @@ -1,3 +1,9 @@ +/* + * 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.mappers import android.content.res.Resources @@ -9,13 +15,9 @@ import com.datadog.android.sessionreplay.internal.recorder.MappingContext import com.datadog.android.sessionreplay.internal.recorder.SystemInformation import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.reactnative.sessionreplay.ReactTextPropertiesResolver -import com.datadog.reactnative.sessionreplay.ReactTextShadowNodeUtils -import com.datadog.reactnative.sessionreplay.ReactTextShadowNodeUtils.Companion.typefaceMap import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerModule import fr.xgouchet.elmyr.Forge -import fr.xgouchet.elmyr.annotation.FloatForgery -import fr.xgouchet.elmyr.annotation.IntForgery -import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach @@ -23,12 +25,10 @@ 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.Spy import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.spy import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -38,7 +38,6 @@ import org.mockito.quality.Strictness ) @MockitoSettings(strictness = Strictness.LENIENT) internal class ReactTextMapperTest { - @Spy private lateinit var testedMapper: ReactTextMapper @Mock @@ -66,65 +65,33 @@ internal class ReactTextMapperTest { private lateinit var mockDisplayMetrics: DisplayMetrics @Mock - private lateinit var mockReactTextShadowNodeUtils: ReactTextShadowNodeUtils - - @IntForgery - private var fakeId: Int = 0 - - @FloatForgery - private var fakeX: Float = 0f - - @FloatForgery - private var fakeY: Float = 0f - - @IntForgery - private var fakeWidth: Int = 0 - - @IntForgery - private var fakeHeight: Int = 0 + private lateinit var mockUIManagerModule: UIManagerModule - @StringForgery - private var fakeText: String = "" - - private lateinit var expectedWireframe: MobileSegment.Wireframe.TextWireframe + @Mock + private lateinit var mockTextWireframe: MobileSegment.Wireframe.TextWireframe @BeforeEach fun `set up`(forge: Forge) { whenever(mockResources.displayMetrics).thenReturn(mockDisplayMetrics) whenever(mockTextView.resources).thenReturn(mockResources) - whenever(mockSystemInformation.screenDensity).thenReturn(1.0f) + whenever(mockSystemInformation.screenDensity).thenReturn(0f) whenever(mockMappingContext.systemInformation).thenReturn(mockSystemInformation) whenever(mockTextView.text).thenReturn(forge.aString()) whenever(mockTextView.typeface).thenReturn(Typeface.SANS_SERIF) - whenever(mockReactTextShadowNodeUtils.getFontFamily(any(), anyOrNull())) - .thenReturn(typefaceMap[Typeface.SANS_SERIF]) - - expectedWireframe = MobileSegment.Wireframe.TextWireframe( - id = fakeId.toLong(), - x = fakeX.toLong(), - y = fakeY.toLong(), - width = fakeWidth.toLong(), - height = fakeHeight.toLong(), - shapeStyle = null, - border = null, - text = fakeText, - textStyle = MobileSegment.TextStyle( - color = "blue", - size = 0, - family = "" - - ), - textPosition = MobileSegment.TextPosition( - padding = MobileSegment.Padding( - top = 0, - left = 0, - right = 0, - bottom = 0 - ), - alignment = MobileSegment.Alignment( - horizontal = MobileSegment.Horizontal.LEFT, - vertical = MobileSegment.Vertical.TOP - ) + + whenever( + mockReactTextPropertiesResolver.addReactNativeProperties( + originalWireframe = eq(mockTextWireframe), + view = eq(mockTextView), + pixelDensity = eq(0f) + ) + ).thenReturn(mockTextWireframe) + + testedMapper = spy( + ReactTextMapper( + reactContext = mockReactContext, + uiManagerModule = mockUIManagerModule, + reactTextPropertiesResolver = mockReactTextPropertiesResolver ) ) } @@ -134,9 +101,15 @@ internal class ReactTextMapperTest { @Mock mockImageWireframe: MobileSegment.Wireframe.ImageWireframe ) { // Given - doReturn(listOf(mockImageWireframe)) - .`when`(testedMapper) - .mapOnSuperclass(mockTextView, mockMappingContext, mockAsyncJobStatusCallback) + whenever( + testedMapper.mapOnSuperclass( + textView = eq(mockTextView), + mappingContext = eq(mockMappingContext), + asyncJobStatusCallback = eq(mockAsyncJobStatusCallback) + ) + ).thenReturn( + listOf(mockImageWireframe) + ) // When val result = testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback) @@ -146,30 +119,26 @@ internal class ReactTextMapperTest { } @Test - fun `M return textWireframe W map()`( - @Mock mockTextWireframe: MobileSegment.Wireframe.TextWireframe - ) { + fun `M return textWireframe W map()`() { // Given - testedMapper = ReactTextMapper( - mockReactContext, - mockReactTextShadowNodeUtils, - mockReactTextPropertiesResolver - ) - whenever( - mockReactTextPropertiesResolver.resolveShadowProperties( - view = any(), - pixelDensity = any(), - shapeStyle = anyOrNull(), - border = anyOrNull(), - textWireframe = any() + testedMapper.mapOnSuperclass( + textView = eq(mockTextView), + mappingContext = eq(mockMappingContext), + asyncJobStatusCallback = eq(mockAsyncJobStatusCallback) ) - ).thenReturn(mockTextWireframe) + ).thenReturn( + listOf(mockTextWireframe) + ) // When - val result = testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback) + val result = testedMapper.map( + mockTextView, + mockMappingContext, + mockAsyncJobStatusCallback + )[0] as MobileSegment.Wireframe.TextWireframe // Then - assertThat(result).isEqualTo(listOf(mockTextWireframe)) + assertThat(result.text).isEqualTo(mockTextWireframe.text) } }