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 3eefe6ffb..615a712b5 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,6 +6,8 @@ package com.datadog.reactnative.sessionreplay +import com.datadog.android.Datadog +import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.SessionReplayConfiguration import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.facebook.react.bridge.Promise @@ -27,9 +29,11 @@ class DdSessionReplayImplementation( * @param defaultPrivacyLevel The privacy level used for replay. */ fun enable(replaySampleRate: Double, defaultPrivacyLevel: String, promise: Promise) { + val sdkCore = Datadog.getInstance() as FeatureSdkCore + val logger = sdkCore.internalLogger val configuration = SessionReplayConfiguration.Builder(replaySampleRate.toFloat()) .setPrivacy(buildPrivacy(defaultPrivacyLevel)) - .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(reactContext)) + .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(logger, reactContext)) .build() sessionReplayProvider().enable(configuration) promise.resolve(null) 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 new file mode 100644 index 000000000..59931a090 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/NoopTextPropertiesResolver.kt @@ -0,0 +1,22 @@ +/* + * + * * 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 ea90458da..dfbd31165 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 @@ -7,9 +7,9 @@ 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.api.InternalLogger import com.datadog.android.sessionreplay.ExtensionSupport import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.datadog.android.sessionreplay.internal.recorder.OptionSelectorDetector @@ -23,6 +23,7 @@ import com.facebook.react.views.textinput.ReactEditText import com.facebook.react.views.view.ReactViewGroup internal class ReactNativeSessionReplayExtensionSupport( + private val logger: InternalLogger, private val reactContext: ReactContext, ) : ExtensionSupport { @@ -31,10 +32,12 @@ internal class ReactNativeSessionReplayExtensionSupport( return mapOf( SessionReplayPrivacy.ALLOW to mapOf( - ReactViewGroup::class.java to ReactViewGroupMapper() as WireframeMapper, - ReactTextView::class.java to ReactTextMapper(reactContext, uiManagerModule) as WireframeMapper, - ReactEditText::class.java to ReactTextMapper(reactContext, uiManagerModule) as WireframeMapper - ) + ReactViewGroup::class.java to ReactViewGroupMapper(logger), + ReactTextView::class.java to ReactTextMapper(logger, reactContext, uiManagerModule), + ReactEditText::class.java to ReactTextMapper(logger, reactContext, uiManagerModule) + ).map{ + it.key to it.value as WireframeMapper + }.toMap(), ) } @@ -46,15 +49,19 @@ internal class ReactNativeSessionReplayExtensionSupport( @VisibleForTesting internal fun getUiManagerModule(): UIManagerModule? { return try { - reactContext.getNativeModule(UIManagerModule::class.java) as UIManagerModule + reactContext.getNativeModule(UIManagerModule::class.java) } catch (e: IllegalStateException) { - Log.e(TAG, RESOLVE_UIMANAGERMODULE_ERROR, e) + logger.log( + level = InternalLogger.Level.WARN, + targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + messageBuilder = { RESOLVE_UIMANAGERMODULE_ERROR }, + throwable = e + ) return null } } internal companion object { - internal const val TAG = "ReactNativeSessionReplayExtensionSupport" - internal const val RESOLVE_UIMANAGERMODULE_ERROR = "Unable to resolve uiManagerModule" + 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 d6c2b73d5..4cbb3c6d6 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 @@ -6,14 +6,15 @@ package com.datadog.reactnative.sessionreplay +import android.view.Gravity import android.widget.TextView import androidx.annotation.VisibleForTesting +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.reactnative.sessionreplay.extensions.convertToDensityNormalized -import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils +import com.datadog.reactnative.sessionreplay.utils.DrawableUtils +import com.datadog.reactnative.sessionreplay.utils.ReactViewBackgroundDrawableUtils 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 @@ -21,24 +22,37 @@ import com.facebook.react.views.view.ReactViewBackgroundDrawable import java.util.Locale internal class ReactTextPropertiesResolver( - private val reactContext: ReactContext, - private val uiManagerModule: UIManagerModule?, - private val reflectionUtils: ReflectionUtils = ReflectionUtils(reactContext, uiManagerModule) -) { - internal fun addReactNativeProperties( + logger: InternalLogger, + reactContext: ReactContext, + uiManagerModule: UIManagerModule?, + private val shadowNodeWrapper: ShadowNodeWrapper = + ShadowNodeWrapper(logger, reactContext, uiManagerModule), + private val reactViewBackgroundDrawableUtils: ReactViewBackgroundDrawableUtils = + ReactViewBackgroundDrawableUtils(logger), + private val drawableUtils: DrawableUtils = DrawableUtils() +): TextPropertiesResolver { + override fun addReactNativeProperties( originalWireframe: MobileSegment.Wireframe.TextWireframe, view: TextView, pixelDensity: Float, ): MobileSegment.Wireframe.TextWireframe { - val reflectionUtilsSuccessfullyInitialized = reflectionUtils.initialize(view.id) - if (!reflectionUtilsSuccessfullyInitialized) { - return originalWireframe - } + val textWireframe = resolveDrawableProperties(view, pixelDensity, originalWireframe) + + val isInitializedForView = shadowNodeWrapper.initialize(view.id) + if (!isInitializedForView) return textWireframe + + val textStyle = resolveTextStyle(textWireframe, pixelDensity) + val textPosition = textWireframe.textPosition + val padding = textPosition?.padding + val alignment = resolveTextAlignment(view, textWireframe) - var textWireframe: MobileSegment.Wireframe.TextWireframe = - resolveDrawableProperties(view, pixelDensity, originalWireframe) - textWireframe = getReactNativeTextProperties(textWireframe, pixelDensity) - return textWireframe + return textWireframe.copy( + textStyle = textStyle, + textPosition = MobileSegment.TextPosition( + alignment = alignment, + padding = padding + ) + ) } private fun resolveDrawableProperties( @@ -47,7 +61,7 @@ internal class ReactTextPropertiesResolver( textWireframe: MobileSegment.Wireframe.TextWireframe ): MobileSegment.Wireframe.TextWireframe { val backgroundDrawable: ReactViewBackgroundDrawable = - getReactBackgroundFromDrawable(view.background) ?: return textWireframe + drawableUtils.getReactBackgroundFromDrawable(view.background) ?: return textWireframe var resultWireframe = textWireframe @@ -55,7 +69,8 @@ internal class ReactTextPropertiesResolver( val opacity = view.alpha val (shapeStyle, border) = - resolveShapeAndBorder(backgroundDrawable, opacity, pixelDensity) + reactViewBackgroundDrawableUtils + .resolveShapeAndBorder(backgroundDrawable, opacity, pixelDensity) if (shapeStyle != null || border != null) { resultWireframe = resultWireframe.copy( @@ -67,16 +82,24 @@ internal class ReactTextPropertiesResolver( return resultWireframe } - 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 resolveTextAlignment( + view: TextView, + textWireframe: MobileSegment.Wireframe.TextWireframe + ): MobileSegment.Alignment { + val gravity = view.gravity + val horizontal = textWireframe.textPosition?.alignment?.horizontal + val vertical = + when (gravity.and(Gravity.VERTICAL_GRAVITY_MASK)) { + Gravity.TOP -> MobileSegment.Vertical.TOP + Gravity.CENTER_VERTICAL, + Gravity.CENTER -> MobileSegment.Vertical.CENTER + Gravity.BOTTOM -> MobileSegment.Vertical.BOTTOM + else -> MobileSegment.Vertical.TOP + } + + return MobileSegment.Alignment( + horizontal = horizontal, + vertical = vertical ) } @@ -100,7 +123,8 @@ internal class ReactTextPropertiesResolver( } private fun getTextColor(): String? { - val resolvedColor = reflectionUtils.getReflectedFieldValue(COLOR_FIELD_NAME) as Int? + val resolvedColor = shadowNodeWrapper + .getDeclaredShadowNodeField(COLOR_FIELD_NAME) as Int? if (resolvedColor != null) { return formatAsRgba(resolvedColor) } @@ -109,7 +133,8 @@ internal class ReactTextPropertiesResolver( } private fun getFontSize(): Long? { - val textAttributes = reflectionUtils.getReflectedFieldValue(TEXT_ATTRIBUTES_FIELD_NAME) as? TextAttributes? + val textAttributes = shadowNodeWrapper + .getDeclaredShadowNodeField(TEXT_ATTRIBUTES_FIELD_NAME) as? TextAttributes? if (textAttributes != null) { return textAttributes.effectiveFontSize.toLong() } @@ -118,7 +143,8 @@ internal class ReactTextPropertiesResolver( } private fun getFontFamily(): String? { - val fontFamily = reflectionUtils.getReflectedFieldValue(FONT_FAMILY_FIELD_NAME) as? String + val fontFamily = shadowNodeWrapper + .getDeclaredShadowNodeField(FONT_FAMILY_FIELD_NAME) as? String if (fontFamily != null) { return resolveFontFamily(fontFamily.lowercase(Locale.US)) 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 new file mode 100644 index 000000000..2ef9b9db4 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ShadowNodeWrapper.kt @@ -0,0 +1,64 @@ +/* + * + * * 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.api.InternalLogger +import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.UIImplementation +import com.facebook.react.uimanager.UIManagerModule +import java.util.concurrent.CountDownLatch + +internal class ShadowNodeWrapper( + private val logger: InternalLogger, + private val reactContext: ReactContext, + private val uiManagerModule: UIManagerModule?, + private val reflectionUtils: ReflectionUtils = ReflectionUtils(logger) +) { + private var shadowNode: ReactShadowNode>? = null + + internal fun initialize(viewId: Int): Boolean { + shadowNode = getShadowNode(viewId) + return shadowNode != null + } + + internal fun getDeclaredShadowNodeField(fieldName: String): Any? { + return reflectionUtils.getDeclaredField( + shadowNode?.javaClass?.superclass as Class<*>, + shadowNode, + fieldName + ) + } + + private fun getShadowNode(viewId: Int): ReactShadowNode>? { + val countDownLatch = CountDownLatch(1) + var target: ReactShadowNode>? = null + val shadowNodeRunnable = Runnable { + val node = uiManagerModule?.resolveShadowNode(viewId) + if (node != null) { + target = node + countDownLatch.countDown() + } + } + synchronized(this) { + reactContext.runOnNativeModulesQueueThread(shadowNodeRunnable) + countDownLatch.await() + 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/main/kotlin/com/datadog/reactnative/sessionreplay/TextPropertiesResolver.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/TextPropertiesResolver.kt new file mode 100644 index 000000000..5d5bbb108 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/TextPropertiesResolver.kt @@ -0,0 +1,20 @@ +/* + * + * * 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 interface TextPropertiesResolver { + fun addReactNativeProperties( + originalWireframe: MobileSegment.Wireframe.TextWireframe, + view: TextView, + pixelDensity: Float, + ): MobileSegment.Wireframe.TextWireframe +} diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/IntExt.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/IntExt.kt deleted file mode 100644 index f47b7fa74..000000000 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/IntExt.kt +++ /dev/null @@ -1,15 +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.extensions - -internal fun Int.densityNormalized(density: Float): Int { - return if (density == 0f) { - this - } else { - (this / density).toInt() - } -} 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 6635d6661..751e283ab 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,30 +8,54 @@ package com.datadog.reactnative.sessionreplay.mappers import android.widget.TextView import androidx.annotation.VisibleForTesting +import com.datadog.android.api.InternalLogger 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.NoopTextPropertiesResolver import com.datadog.reactnative.sessionreplay.ReactTextPropertiesResolver +import com.datadog.reactnative.sessionreplay.TextPropertiesResolver import com.facebook.react.bridge.ReactContext 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() { +internal class ReactTextMapper(): TextViewMapper() { + private var reactTextPropertiesResolver: TextPropertiesResolver = + NoopTextPropertiesResolver() + + internal constructor( + logger: InternalLogger, + reactContext: ReactContext, + uiManagerModule: UIManagerModule? + ): this() { + if (uiManagerModule == null) { + this.reactTextPropertiesResolver = NoopTextPropertiesResolver() + } else { + this.reactTextPropertiesResolver = ReactTextPropertiesResolver( + logger = logger, + reactContext = reactContext, + uiManagerModule = uiManagerModule + ) + } + } + + @VisibleForTesting + internal constructor( + reactTextPropertiesResolver: TextPropertiesResolver + ): this() { + this.reactTextPropertiesResolver = reactTextPropertiesResolver + } + override fun map( view: TextView, mappingContext: MappingContext, asyncJobStatusCallback: AsyncJobStatusCallback ): List { - val result: MutableList = mutableListOf() + val result = mutableListOf() val wireframes = mapOnSuperclass(view, mappingContext, asyncJobStatusCallback) val pixelDensity = mappingContext.systemInformation.screenDensity - wireframes.forEach{ originalWireframe -> + wireframes.forEach { originalWireframe -> if (originalWireframe !is MobileSegment.Wireframe.TextWireframe) { result.add(originalWireframe) } else { @@ -43,7 +67,7 @@ internal class ReactTextMapper( } } - return result.toList() + return result } @VisibleForTesting 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 de2653b9a..0202ca130 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 @@ -6,17 +6,22 @@ package com.datadog.reactnative.sessionreplay.mappers +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.internal.AsyncJobStatusCallback 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.utils.getReactBackgroundFromDrawable -import com.datadog.reactnative.sessionreplay.utils.resolveShapeAndBorder -import com.facebook.react.views.view.ReactViewBackgroundDrawable +import com.datadog.reactnative.sessionreplay.utils.DrawableUtils +import com.datadog.reactnative.sessionreplay.utils.ReactViewBackgroundDrawableUtils import com.facebook.react.views.view.ReactViewGroup -internal class ReactViewGroupMapper : +internal class ReactViewGroupMapper( + logger: InternalLogger, + private val reactViewBackgroundDrawableUtils: ReactViewBackgroundDrawableUtils = + ReactViewBackgroundDrawableUtils(logger), + private val drawableUtils: DrawableUtils = DrawableUtils() +) : BaseWireframeMapper(), TraverseAllChildrenMapper { @@ -27,15 +32,15 @@ internal class ReactViewGroupMapper : ): List { val pixelDensity = mappingContext.systemInformation.screenDensity val viewGlobalBounds = resolveViewGlobalBounds(view, pixelDensity) - val backgroundDrawable: ReactViewBackgroundDrawable? = - getReactBackgroundFromDrawable(view.background) + val backgroundDrawable = drawableUtils.getReactBackgroundFromDrawable(view.background) // view.alpha is the value of the opacity prop on the js side val opacity = view.alpha val (shapeStyle, border) = if (backgroundDrawable != null) { - resolveShapeAndBorder(backgroundDrawable, opacity, pixelDensity) + reactViewBackgroundDrawableUtils + .resolveShapeAndBorder(backgroundDrawable, opacity, pixelDensity) } else { null to null } diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/DrawableUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/DrawableUtils.kt index be63ccb99..bf6ac4983 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/DrawableUtils.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/DrawableUtils.kt @@ -11,23 +11,25 @@ import android.graphics.drawable.InsetDrawable import android.graphics.drawable.LayerDrawable import com.facebook.react.views.view.ReactViewBackgroundDrawable -internal fun getReactBackgroundFromDrawable(drawable: Drawable?): ReactViewBackgroundDrawable? { - if (drawable is ReactViewBackgroundDrawable) { - return drawable - } +internal class DrawableUtils { + internal fun getReactBackgroundFromDrawable(drawable: Drawable?): ReactViewBackgroundDrawable? { + if (drawable is ReactViewBackgroundDrawable) { + return drawable + } - if (drawable is InsetDrawable) { - return getReactBackgroundFromDrawable(drawable.drawable) - } + if (drawable is InsetDrawable) { + return getReactBackgroundFromDrawable(drawable.drawable) + } - if (drawable is LayerDrawable) { - for (layerNumber in 0 until drawable.numberOfLayers) { - val layer = drawable.getDrawable(layerNumber) - if (layer is ReactViewBackgroundDrawable) { - return layer + if (drawable is LayerDrawable) { + for (layerNumber in 0 until drawable.numberOfLayers) { + val layer = drawable.getDrawable(layerNumber) + if (layer is ReactViewBackgroundDrawable) { + return layer + } } } - } - return null + return null + } } 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 index ec0e6566e..bb6778416 100644 --- 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 @@ -6,43 +6,64 @@ package com.datadog.reactnative.sessionreplay.utils +import com.datadog.android.api.InternalLogger 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) +internal class ReactViewBackgroundDrawableUtils( + logger: InternalLogger, + private val reflectionUtils: ReflectionUtils = ReflectionUtils(logger) +) { + internal fun resolveShapeAndBorder( + drawable: ReactViewBackgroundDrawable, + opacity: Float, + pixelDensity: Float + ): Pair { + val borderProps = resolveBorder(drawable, pixelDensity) + val backgroundColor = getBackgroundColor(drawable) + val colorHexString = if (backgroundColor != null) { + formatAsRgba(backgroundColor) + } else { + return null to borderProps + } + + val cornerRadius = + drawable.fullBorderRadius.toLong().convertToDensityNormalized(pixelDensity) + + return MobileSegment.ShapeStyle( + colorHexString, + opacity, + cornerRadius + ) to borderProps + } + + private fun getBackgroundColor( + backgroundDrawable: ReactViewBackgroundDrawable, + ): Int? { + return reflectionUtils.getDeclaredField( + backgroundDrawable.javaClass, + backgroundDrawable, + COLOR_FIELD_NAME + ) as Int? + } + + 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 + ) + } + + private companion object { + private const val COLOR_FIELD_NAME = "mColor" } -} - -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 - ) -} +} \ No newline at end of file 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 index 3922e7b56..77236adde 100644 --- 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 @@ -6,58 +6,41 @@ 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 +import com.datadog.android.api.InternalLogger +import java.lang.reflect.Field internal class ReflectionUtils( - private val reactContext: ReactContext, - private val uiManagerModule: UIManagerModule?, + private val logger: InternalLogger ) { - private var shadowNode: ReactShadowNode>? = null + internal fun getDeclaredField(className: Class<*>, instance: Any?, fieldName: String): Any? { + val declaredField = searchForField(className, fieldName) ?: return null - internal fun initialize(viewId: Int): Boolean { - shadowNode = getShadowNode(viewId) - return shadowNode != null + declaredField.let { + it.isAccessible = true + return it.get(instance) + } } - internal fun getReflectedFieldValue(fieldName: String): Any? { + private fun searchForField(className: Class<*>, fieldName: String): Field? { try { - shadowNode?.javaClass?.superclass?.getDeclaredField(fieldName)?.let { - it.isAccessible = true - return it.get(shadowNode) + return className.getDeclaredField(fieldName) + } catch (e: NoSuchFieldException) { + if (className.superclass != null) { + searchForField(className.superclass, fieldName) + } else { + logger.log( + level = InternalLogger.Level.WARN, + target = InternalLogger.Target.MAINTAINER, + messageBuilder = { RESOLVE_FIELD_ERROR }, + throwable = e + ) } - } 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) - } + private companion object { + private const val RESOLVE_FIELD_ERROR = "Failed to resolve the declared field via reflection." } } 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 d5a7bc71e..5e2668bc2 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 @@ -9,7 +9,7 @@ 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.datadog.reactnative.sessionreplay.utils.DrawableUtils import com.facebook.react.views.view.ReactViewBackgroundDrawable import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -29,6 +29,8 @@ import org.mockito.quality.Strictness ) @MockitoSettings(strictness = Strictness.LENIENT) internal class DrawableUtilsTest { + private lateinit var testedDrawableUtils: DrawableUtils + @Mock private lateinit var mockReactViewBackgroundDrawable: ReactViewBackgroundDrawable @@ -43,35 +45,38 @@ internal class DrawableUtilsTest { @BeforeEach fun `set up`() { - whenever(mockLayerDrawable.numberOfLayers).thenReturn(1) + whenever(mockLayerDrawable.numberOfLayers).thenReturn(3) whenever(mockLayerDrawable.getDrawable(0)).thenReturn(mockReactViewBackgroundDrawable) + + testedDrawableUtils = DrawableUtils() } @Test - fun `M return drawable W getReactBackgroundFromDrawable { ReactViewBackgroundDrawable}`() { + fun `M return drawable W getReactBackgroundFromDrawable { ReactViewBackgroundDrawable }`() { // When - val result = getReactBackgroundFromDrawable(mockReactViewBackgroundDrawable) + val result = testedDrawableUtils + .getReactBackgroundFromDrawable(mockReactViewBackgroundDrawable) // Then assertThat(result).isEqualTo(mockReactViewBackgroundDrawable) } @Test - fun `M return drawable W getReactBackgroundFromDrawable { InsetDrawable}`() { + fun `M return drawable W getReactBackgroundFromDrawable { InsetDrawable }`() { // Given whenever(mockInsetDrawable.drawable).thenReturn(mockReactViewBackgroundDrawable) // When - val result = getReactBackgroundFromDrawable(mockInsetDrawable) + val result = testedDrawableUtils.getReactBackgroundFromDrawable(mockInsetDrawable) // Then assertThat(result).isEqualTo(mockReactViewBackgroundDrawable) } @Test - fun `M return drawable W getReactBackgroundFromDrawable { LayerDrawable}`() { + fun `M return drawable W getReactBackgroundFromDrawable { LayerDrawable }`() { // When - val result = getReactBackgroundFromDrawable(mockLayerDrawable) + val result = testedDrawableUtils.getReactBackgroundFromDrawable(mockLayerDrawable) // Then assertThat(result).isEqualTo(mockReactViewBackgroundDrawable) @@ -80,7 +85,16 @@ internal class DrawableUtilsTest { @Test fun `M return null W getReactBackgroundFromDrawable { drawable is not supported }`() { // When - val result = getReactBackgroundFromDrawable(mockColorDrawable) + val result = testedDrawableUtils.getReactBackgroundFromDrawable(mockColorDrawable) + + // Then + assertThat(result).isNull() + } + + @Test + fun `M return null W getReactBackgroundFromDrawable { null drawable }`() { + // When + val result = testedDrawableUtils.getReactBackgroundFromDrawable(null) // Then assertThat(result).isNull() 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 7437f5f78..83d283b2f 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 @@ -6,6 +6,7 @@ package com.datadog.reactnative.sessionreplay +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.datadog.reactnative.sessionreplay.mappers.ReactTextMapper import com.datadog.reactnative.sessionreplay.mappers.ReactViewGroupMapper @@ -37,10 +38,13 @@ import org.mockito.quality.Strictness internal class ReactNativeSessionReplayExtensionSupportTest { @Mock - lateinit var mockReactContext: ReactContext + private lateinit var mockReactContext: ReactContext @Mock - lateinit var mockUiManagerModule: UIManagerModule + private lateinit var mockUiManagerModule: UIManagerModule + + @Mock + private lateinit var mockLogger: InternalLogger private lateinit var testedExtensionSupport: ReactNativeSessionReplayExtensionSupport @@ -49,22 +53,26 @@ internal class ReactNativeSessionReplayExtensionSupportTest { whenever(mockReactContext.getNativeModule(any>())) .doReturn(mockUiManagerModule) - testedExtensionSupport = ReactNativeSessionReplayExtensionSupport(mockReactContext) + testedExtensionSupport = ReactNativeSessionReplayExtensionSupport( + logger = mockLogger, + reactContext = mockReactContext + ) } @Test fun `M get custom view mappers W getCustomViewMappers()`() { // When val customViewMappers = testedExtensionSupport.getCustomViewMappers() - val allowMappers = customViewMappers.get(SessionReplayPrivacy.ALLOW) + val allowMappers = customViewMappers[SessionReplayPrivacy.ALLOW] // Then - assertThat(allowMappers?.size).isEqualTo(3) - assertThat(allowMappers?.get(ReactViewGroup::class.java)) + check(allowMappers != null) + assertThat(allowMappers.size).isEqualTo(3) + assertThat(allowMappers.get(ReactViewGroup::class.java)) .isInstanceOf(ReactViewGroupMapper::class.java) - assertThat(allowMappers?.get(ReactTextView::class.java)) + assertThat(allowMappers.get(ReactTextView::class.java)) .isInstanceOf(ReactTextMapper::class.java) - assertThat(allowMappers?.get(ReactEditText::class.java)) + assertThat(allowMappers.get(ReactEditText::class.java)) .isInstanceOf(ReactTextMapper::class.java) } 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 index 03f1a6b1a..a9d4d4ee0 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/ReactTextPropertiesResolverTest.kt @@ -7,20 +7,24 @@ package com.datadog.reactnative.sessionreplay 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.TEXT_ATTRIBUTES_FIELD_NAME -import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils +import com.datadog.reactnative.sessionreplay.utils.DrawableUtils +import com.datadog.reactnative.sessionreplay.utils.ReactViewBackgroundDrawableUtils import com.datadog.reactnative.sessionreplay.utils.formatAsRgba +import com.datadog.reactnative.tools.unit.forge.ForgeConfigurator 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.Forgery import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -29,6 +33,7 @@ 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 @@ -37,6 +42,7 @@ import org.mockito.quality.Strictness ExtendWith(ForgeExtension::class) ) @MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) internal class ReactTextPropertiesResolverTest { private lateinit var testedResolver: ReactTextPropertiesResolver @@ -46,49 +52,44 @@ internal class ReactTextPropertiesResolverTest { @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 mockLogger: InternalLogger + + @Mock + lateinit var mockDrawableUtils: DrawableUtils + @Mock lateinit var mockReactViewBackgroundDrawable: ReactViewBackgroundDrawable + @Mock + lateinit var mockReactViewBackgroundDrawableUtils: ReactViewBackgroundDrawableUtils + + @Mock + lateinit var mockShadowNodeWrapper: ShadowNodeWrapper + + @Forgery 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 + logger = mockLogger, + reactContext = mockReactContext, + uiManagerModule = mockUiManagerModule, + shadowNodeWrapper = mockShadowNodeWrapper, + reactViewBackgroundDrawableUtils = mockReactViewBackgroundDrawableUtils, + drawableUtils = mockDrawableUtils ) } @Test - fun `M get original wireframe W addRNProperties() { no react properties }`() { + fun `M get original wireframe W addReactNativeProperties() { no react properties }`() { // Given whenever(mockTextView.background).thenReturn(null) @@ -100,22 +101,36 @@ internal class ReactTextPropertiesResolverTest { } @Test - fun `M add drawable properties W addRNProperties() { has reactBackgroundDrawable }`( + fun `M add drawable properties W addReactNativeProperties() { 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) + whenever( + mockDrawableUtils.getReactBackgroundFromDrawable( + eq(mockTextView.background) + ) + ).thenReturn(mockReactViewBackgroundDrawable) + whenever( + mockReactViewBackgroundDrawableUtils.resolveShapeAndBorder( + drawable = eq(mockReactViewBackgroundDrawable), + opacity = eq(0f), + pixelDensity = eq(0f) + ) + ).thenReturn( + MobileSegment.ShapeStyle( + backgroundColor = formatAsRgba(fakeBorderColor), + opacity = 0f, + cornerRadius = fakeBorderRadius.toLong() + ) to MobileSegment.ShapeBorder( + color = formatAsRgba(fakeBorderColor), + width = fakeBorderWidth.toLong() + ) + ) // When val result = testedResolver.addReactNativeProperties( @@ -130,14 +145,14 @@ internal class ReactTextPropertiesResolverTest { assertThat(result.border?.color).isEqualTo(formatAsRgba(fakeBorderColor)) } - // region addRNProperties + // region addReactNativeProperties @Test - fun `M resolve font family W addRNProperties()`() { + fun `M resolve font family W addReactNativeProperties()`() { // Given - whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockShadowNodeWrapper.initialize(mockTextView.id)).thenReturn(true) whenever(mockTextView.background).thenReturn(null) - whenever(mockReflectionUtils.getReflectedFieldValue(FONT_FAMILY_FIELD_NAME)) + whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(FONT_FAMILY_FIELD_NAME)) .thenReturn(ReactTextPropertiesResolver.MONOSPACE_FAMILY_NAME) // When @@ -150,11 +165,11 @@ internal class ReactTextPropertiesResolverTest { } @Test - fun `M fallback W addRNProperties() { cant resolve fontFamily }`() { + fun `M fallback W addReactNativeProperties() { cant resolve fontFamily }`() { // Given - whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockShadowNodeWrapper.initialize(mockTextView.id)).thenReturn(true) whenever(mockTextView.background).thenReturn(null) - whenever(mockReflectionUtils.getReflectedFieldValue(FONT_FAMILY_FIELD_NAME)) + whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(FONT_FAMILY_FIELD_NAME)) .thenReturn(null) // When @@ -165,14 +180,14 @@ internal class ReactTextPropertiesResolverTest { } @Test - fun `M resolve font size W addRNProperties()`( + fun `M resolve font size W addReactNativeProperties()`( @Mock mockTextAttributes: TextAttributes, @IntForgery fakeTextSize: Int ) { // Given - whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockShadowNodeWrapper.initialize(mockTextView.id)).thenReturn(true) whenever(mockTextView.background).thenReturn(null) - whenever(mockReflectionUtils.getReflectedFieldValue(TEXT_ATTRIBUTES_FIELD_NAME)) + whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(TEXT_ATTRIBUTES_FIELD_NAME)) .thenReturn(mockTextAttributes) whenever(mockTextAttributes.effectiveFontSize).thenReturn(fakeTextSize) @@ -184,13 +199,13 @@ internal class ReactTextPropertiesResolverTest { } @Test - fun `M fallback W addRNProperties() { cant resolve fontSize }`( + fun `M fallback W addReactNativeProperties() { cant resolve fontSize }`( @Mock mockTextAttributes: TextAttributes ) { // Given - whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockShadowNodeWrapper.initialize(mockTextView.id)).thenReturn(true) whenever(mockTextView.background).thenReturn(null) - whenever(mockReflectionUtils.getReflectedFieldValue(TEXT_ATTRIBUTES_FIELD_NAME)) + whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(TEXT_ATTRIBUTES_FIELD_NAME)) .thenReturn(null) // When @@ -201,13 +216,13 @@ internal class ReactTextPropertiesResolverTest { } @Test - fun `M resolve font color W addRNProperties()`( + fun `M resolve font color W addReactNativeProperties()`( @IntForgery fakeTextColor: Int ) { // Given - whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockShadowNodeWrapper.initialize(mockTextView.id)).thenReturn(true) whenever(mockTextView.background).thenReturn(null) - whenever(mockReflectionUtils.getReflectedFieldValue(COLOR_FIELD_NAME)) + whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(COLOR_FIELD_NAME)) .thenReturn(fakeTextColor) // When @@ -218,11 +233,11 @@ internal class ReactTextPropertiesResolverTest { } @Test - fun `M fallback W addRNProperties() { cant resolve fontColor }`() { + fun `M fallback W addReactNativeProperties() { cant resolve fontColor }`() { // Given - whenever(mockReflectionUtils.initialize(mockTextView.id)).thenReturn(true) + whenever(mockShadowNodeWrapper.initialize(mockTextView.id)).thenReturn(true) whenever(mockTextView.background).thenReturn(null) - whenever(mockReflectionUtils.getReflectedFieldValue(COLOR_FIELD_NAME)) + whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(COLOR_FIELD_NAME)) .thenReturn(null) // When 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 bba372064..29ff84190 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 @@ -15,8 +15,6 @@ 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.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.UIManagerModule import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -40,9 +38,6 @@ import org.mockito.quality.Strictness internal class ReactTextMapperTest { private lateinit var testedMapper: ReactTextMapper - @Mock - private lateinit var mockReactContext: ReactContext - @Mock private lateinit var mockReactTextPropertiesResolver: ReactTextPropertiesResolver @@ -64,9 +59,6 @@ internal class ReactTextMapperTest { @Mock private lateinit var mockDisplayMetrics: DisplayMetrics - @Mock - private lateinit var mockUIManagerModule: UIManagerModule - @Mock private lateinit var mockTextWireframe: MobileSegment.Wireframe.TextWireframe @@ -89,8 +81,6 @@ internal class ReactTextMapperTest { testedMapper = spy( ReactTextMapper( - reactContext = mockReactContext, - uiManagerModule = mockUIManagerModule, reactTextPropertiesResolver = mockReactTextPropertiesResolver ) ) @@ -131,6 +121,14 @@ internal class ReactTextMapperTest { listOf(mockTextWireframe) ) + whenever( + mockReactTextPropertiesResolver.addReactNativeProperties( + originalWireframe = eq(mockTextWireframe), + view = eq(mockTextView), + pixelDensity = eq(0f) + ) + ).thenReturn(mockTextWireframe) + // When val result = testedMapper.map( mockTextView, diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapperTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapperTest.kt new file mode 100644 index 000000000..b8e1dcf80 --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapperTest.kt @@ -0,0 +1,136 @@ +/* + * + * * 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.graphics.drawable.ColorDrawable +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.internal.AsyncJobStatusCallback +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.utils.DrawableUtils +import com.datadog.reactnative.sessionreplay.utils.ReactViewBackgroundDrawableUtils +import com.facebook.react.views.view.ReactViewBackgroundDrawable +import com.facebook.react.views.view.ReactViewGroup +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 ReactViewGroupMapperTest { + private lateinit var testedMapper: ReactViewGroupMapper + + @Mock + private lateinit var mockLogger: InternalLogger + + @Mock + private lateinit var mockReactViewBackgroundDrawableUtils: ReactViewBackgroundDrawableUtils + + @Mock + private lateinit var mockReactViewGroup: ReactViewGroup + + @Mock + private lateinit var mockMappingContext: MappingContext + + @Mock + private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback + + @Mock + private lateinit var mockSystemInformation: SystemInformation + + @Mock + private lateinit var mockDrawableUtils: DrawableUtils + + @Mock + private lateinit var mockReactViewBackgroundDrawable: ReactViewBackgroundDrawable + + @Mock + private lateinit var mockShapeStyle: MobileSegment.ShapeStyle + + @Mock + private lateinit var mockShapeBorder: MobileSegment.ShapeBorder + + @BeforeEach + fun `set up`() { + whenever(mockMappingContext.systemInformation).thenReturn(mockSystemInformation) + whenever(mockSystemInformation.screenDensity).thenReturn(0f) + + testedMapper = ReactViewGroupMapper( + logger = mockLogger, + reactViewBackgroundDrawableUtils = mockReactViewBackgroundDrawableUtils, + drawableUtils = mockDrawableUtils + ) + } + + @Test + fun `M return null shapestyle + border W map() { other drawable }`( + @Mock mockColorDrawable: ColorDrawable + ) { + // Given + whenever(mockReactViewGroup.background).thenReturn(mockColorDrawable) + whenever( + mockDrawableUtils.getReactBackgroundFromDrawable( + drawable = eq(mockColorDrawable) + ) + ).thenReturn(null) + + // When + val result = testedMapper.map( + view = mockReactViewGroup, + mappingContext = mockMappingContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + )[0] as MobileSegment.Wireframe.ShapeWireframe + + // Then + assertThat(result.shapeStyle).isNull() + assertThat(result.border).isNull() + } + + @Test + fun `M return shapestyle + border W map() { ReactViewBackgroundDrawable }`() { + // Given + whenever(mockReactViewGroup.background).thenReturn(mockReactViewBackgroundDrawable) + whenever( + mockDrawableUtils.getReactBackgroundFromDrawable( + drawable = eq(mockReactViewBackgroundDrawable) + ) + ).thenReturn(mockReactViewBackgroundDrawable) + whenever( + mockReactViewBackgroundDrawableUtils.resolveShapeAndBorder( + drawable = eq(mockReactViewBackgroundDrawable), + pixelDensity = eq(0f), + opacity = eq(0f) + ) + ).thenReturn(mockShapeStyle to mockShapeBorder) + + // When + val result = testedMapper.map( + view = mockReactViewGroup, + mappingContext = mockMappingContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + )[0] as MobileSegment.Wireframe.ShapeWireframe + + // Then + assertThat(result.shapeStyle).isEqualTo(mockShapeStyle) + assertThat(result.border).isEqualTo(mockShapeBorder) + } +} diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/ForgeConfigurator.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/ForgeConfigurator.kt new file mode 100644 index 000000000..92de66e96 --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/ForgeConfigurator.kt @@ -0,0 +1,24 @@ +/* + * + * * 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.tools.unit.forge + +import com.datadog.tools.unit.forge.BaseConfigurator +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.jvm.useJvmFactories + +internal class ForgeConfigurator : BaseConfigurator() { + override fun configure(forge: Forge) { + super.configure(forge) + + forge.addFactory(TextWireframeForgeryFactory()) + forge.addFactory(WireframeClipForgeryFactory()) + + forge.useJvmFactories() + } +} diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/TextWireframeForgeryFactory.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/TextWireframeForgeryFactory.kt new file mode 100644 index 000000000..e3cda3176 --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/TextWireframeForgeryFactory.kt @@ -0,0 +1,64 @@ +/* + * + * * 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.tools.unit.forge + +import com.datadog.android.sessionreplay.model.MobileSegment +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class TextWireframeForgeryFactory : + ForgeryFactory { + override fun getForgery(forge: Forge): MobileSegment.Wireframe.TextWireframe { + return MobileSegment.Wireframe.TextWireframe( + id = forge.aPositiveInt().toLong(), + x = forge.aPositiveLong(), + y = forge.aPositiveLong(), + width = forge.aPositiveLong(strict = true), + height = forge.aPositiveLong(strict = true), + text = forge.aString(), + shapeStyle = forge.aNullable { + MobileSegment.ShapeStyle( + forge.aStringMatching("#[0-9A-F]{6}FF"), + opacity = forge.aFloat(min = 0f, max = 1f), + cornerRadius = forge.aPositiveLong() + ) + }, + textStyle = MobileSegment.TextStyle( + family = forge.aString(), + size = forge.aPositiveLong(strict = true), + color = forge.aStringMatching("#[0-9A-F]{6}FF") + ), + textPosition = forge.aNullable { + MobileSegment.TextPosition( + padding = forge.aNullable { + MobileSegment.Padding( + forge.aNullable { aPositiveLong() }, + forge.aNullable { aPositiveLong() }, + forge.aNullable { aPositiveLong() }, + forge.aNullable { aPositiveLong() } + ) + }, + alignment = forge.aNullable { + MobileSegment.Alignment( + horizontal = forge.aNullable { + forge.aValueFrom(MobileSegment.Horizontal::class.java) + }, + vertical = forge.aNullable { + forge.aValueFrom(MobileSegment.Vertical::class.java) + } + ) + } + ) + }, + clip = forge.aNullable { + getForgery() + } + ) + } +} diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/WireframeClipForgeryFactory.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/WireframeClipForgeryFactory.kt new file mode 100644 index 000000000..35d00b028 --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/WireframeClipForgeryFactory.kt @@ -0,0 +1,25 @@ +/* + * + * * 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.tools.unit.forge + +import com.datadog.android.sessionreplay.model.MobileSegment +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class WireframeClipForgeryFactory : + ForgeryFactory { + override fun getForgery(forge: Forge): MobileSegment.WireframeClip { + return MobileSegment.WireframeClip( + top = forge.aNullable { aLong(min = 0, max = 100) }, + bottom = forge.aNullable { aLong(min = 0, max = 100) }, + left = forge.aNullable { aLong(min = 0, max = 100) }, + right = forge.aNullable { aLong(min = 0, max = 100) } + ) + } +}