From 7cc840062c1f9b660552aaf18f46a8d398825842 Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 11 Dec 2023 00:23:56 +0200 Subject: [PATCH] Implement Android SR for ReactTextView and ReactEditText --- .../DdSessionReplayImplementation.kt | 8 +- .../NoopTextPropertiesResolver.kt | 22 ++ ...eactNativeSessionReplayExtensionSupport.kt | 46 ++- .../ReactTextPropertiesResolver.kt | 196 +++++++++++++ .../sessionreplay/ReactViewGroupMapper.kt | 87 ------ .../sessionreplay/ShadowNodeWrapper.kt | 70 +++++ .../sessionreplay/TextPropertiesResolver.kt | 20 ++ .../sessionreplay/{ => extensions}/LongExt.kt | 2 +- .../sessionreplay/mappers/ReactTextMapper.kt | 72 +++++ .../mappers/ReactViewGroupMapper.kt | 58 ++++ .../sessionreplay/{ => utils}/ColorUtils.kt | 2 +- .../sessionreplay/utils/DrawableUtils.kt | 35 +++ .../utils/ReactViewBackgroundDrawableUtils.kt | 66 +++++ .../sessionreplay/utils/ReflectionUtils.kt | 30 ++ .../sessionreplay/DdSessionReplay.kt | 2 +- .../sessionreplay/ColorUtilsTest.kt | 1 + .../DdSessionReplayImplementationTest.kt | 18 +- .../sessionreplay/DrawableUtilsTest.kt | 102 +++++++ ...NativeSessionReplayExtensionSupportTest.kt | 91 ++++++ .../ReactTextPropertiesResolverTest.kt | 271 ++++++++++++++++++ .../mappers/ReactTextMapperTest.kt | 142 +++++++++ .../mappers/ReactViewGroupMapperTest.kt | 131 +++++++++ .../tools/unit/forge/ForgeConfigurator.kt | 24 ++ .../unit/forge/TextWireframeForgeryFactory.kt | 64 +++++ .../unit/forge/WireframeClipForgeryFactory.kt | 25 ++ 25 files changed, 1489 insertions(+), 96 deletions(-) create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/NoopTextPropertiesResolver.kt create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolver.kt delete mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactViewGroupMapper.kt create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ShadowNodeWrapper.kt create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/TextPropertiesResolver.kt rename packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/{ => extensions}/LongExt.kt (88%) create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapper.kt create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt rename packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/{ => utils}/ColorUtils.kt (93%) create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/DrawableUtils.kt 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/DrawableUtilsTest.kt create mode 100644 packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupportTest.kt create mode 100644 packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolverTest.kt create mode 100644 packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapperTest.kt create mode 100644 packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapperTest.kt create mode 100644 packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/ForgeConfigurator.kt create mode 100644 packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/TextWireframeForgeryFactory.kt create mode 100644 packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/tools/unit/forge/WireframeClipForgeryFactory.kt 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 972676c15..fd807598d 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt @@ -6,15 +6,19 @@ 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 +import com.facebook.react.bridge.ReactContext import java.util.Locale /** * The entry point to use Datadog's Session Replay feature. */ class DdSessionReplayImplementation( + private val reactContext: ReactContext, private val sessionReplayProvider: () -> SessionReplayWrapper = { SessionReplaySDKWrapper() } @@ -25,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()) + .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(reactContext, logger)) .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 52de3854b..c415e9734 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,21 +7,59 @@ package com.datadog.reactnative.sessionreplay 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 import com.datadog.android.sessionreplay.internal.recorder.mapper.WireframeMapper +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 -internal class ReactNativeSessionReplayExtensionSupport : ExtensionSupport { +internal class ReactNativeSessionReplayExtensionSupport( + private val reactContext: ReactContext, + private val logger: InternalLogger +) : ExtensionSupport { override fun getCustomViewMappers(): Map, WireframeMapper>> { - return mapOf(SessionReplayPrivacy.ALLOW to mapOf( - ReactViewGroup::class.java to ReactViewGroupMapper() as WireframeMapper - )) + val uiManagerModule = getUiManagerModule() + + return mapOf( + SessionReplayPrivacy.ALLOW to mapOf( + ReactViewGroup::class.java to ReactViewGroupMapper(), + ReactTextView::class.java to ReactTextMapper(reactContext, uiManagerModule), + ReactEditText::class.java to ReactTextMapper(reactContext, uiManagerModule) + ).mapValues{ + it.value as WireframeMapper + } + ) } override fun getOptionSelectorDetectors(): List { return listOf() } + + @VisibleForTesting + internal fun getUiManagerModule(): UIManagerModule? { + return try { + reactContext.getNativeModule(UIManagerModule::class.java) + } catch (e: IllegalStateException) { + logger.log( + level = InternalLogger.Level.WARN, + targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + messageBuilder = { RESOLVE_UIMANAGERMODULE_ERROR }, + throwable = e + ) + return null + } + } + + internal companion object { + internal const val RESOLVE_UIMANAGERMODULE_ERROR = "Unable to resolve UIManagerModule" + } } diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolver.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolver.kt new file mode 100644 index 000000000..07fd80020 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolver.kt @@ -0,0 +1,196 @@ +/* + * 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.utils.DrawableUtils +import com.datadog.reactnative.sessionreplay.utils.ReactViewBackgroundDrawableUtils +import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils +import com.datadog.reactnative.sessionreplay.utils.formatAsRgba +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerModule +import com.facebook.react.views.text.TextAttributes +import 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(), + private val reactViewBackgroundDrawableUtils: ReactViewBackgroundDrawableUtils = + ReactViewBackgroundDrawableUtils(), + private val drawableUtils: DrawableUtils = DrawableUtils() +): TextPropertiesResolver { + override fun addReactNativeProperties( + originalWireframe: MobileSegment.Wireframe.TextWireframe, + view: TextView, + pixelDensity: Float, + ): MobileSegment.Wireframe.TextWireframe { + val (shapeStyle, border) = resolveShapeStyleAndBorder(view, pixelDensity) + ?: (originalWireframe.shapeStyle to originalWireframe.border) + + val (textStyle, textPosition) = resolveTextStyleAndPosition( + originalWireframe, + view, + pixelDensity + ) ?: (originalWireframe.textStyle to originalWireframe.textPosition) + + // nothing changed, return the original wireframe + @Suppress("ComplexCondition") + if (shapeStyle == originalWireframe.shapeStyle + && border == originalWireframe.border + && textStyle == originalWireframe.textStyle + && textPosition == originalWireframe.textPosition + ) { + return originalWireframe + } + + return originalWireframe.copy( + shapeStyle = shapeStyle, + border = border, + textStyle = textStyle, + textPosition = textPosition + ) + } + + private fun resolveTextStyleAndPosition( + originalWireframe: MobileSegment.Wireframe.TextWireframe, + view: TextView, + pixelDensity: Float, + ): + Pair? { + val shadowNodeWrapper: ShadowNodeWrapper = + ShadowNodeWrapper.getShadowNodeWrapper( + reactContext = reactContext, + uiManagerModule = uiManagerModule, + reflectionUtils = reflectionUtils, + viewId = view.id) ?: return null + + val textStyle = resolveTextStyle(originalWireframe, pixelDensity, shadowNodeWrapper) + val alignment = resolveTextAlignment(view, originalWireframe) + + val textPosition = MobileSegment.TextPosition( + alignment = alignment, + padding = originalWireframe.textPosition?.padding + ) + + return textStyle to textPosition + } + + private fun resolveShapeStyleAndBorder( + view: TextView, + pixelDensity: Float, + ): Pair? { + val backgroundDrawable: ReactViewBackgroundDrawable = + drawableUtils.getReactBackgroundFromDrawable(view.background) ?: return null + + // view.alpha is the value of the opacity prop on the js side + val opacity = view.alpha + + val (shapeStyle, border) = + reactViewBackgroundDrawableUtils + .resolveShapeAndBorder(backgroundDrawable, opacity, pixelDensity) + + return shapeStyle to border + } + + 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 + ) + } + + private fun resolveTextStyle( + textWireframe: MobileSegment.Wireframe.TextWireframe, + pixelsDensity: Float, + shadowNodeWrapper: ShadowNodeWrapper + ): MobileSegment.TextStyle { + val fontFamily = getFontFamily(shadowNodeWrapper) + ?: textWireframe.textStyle.family + val fontSize = getFontSize(shadowNodeWrapper) + ?.convertToDensityNormalized(pixelsDensity) + ?: textWireframe.textStyle.size + val fontColor = getTextColor(shadowNodeWrapper) + ?: textWireframe.textStyle.color + + return MobileSegment.TextStyle( + family = fontFamily, + size = fontSize, + color = fontColor + ) + } + + private fun getTextColor(shadowNodeWrapper: ShadowNodeWrapper): String? { + val resolvedColor = shadowNodeWrapper + .getDeclaredShadowNodeField(COLOR_FIELD_NAME) as Int? + if (resolvedColor != null) { + return formatAsRgba(resolvedColor) + } + + return null + } + + private fun getFontSize(shadowNodeWrapper: ShadowNodeWrapper): Long? { + val textAttributes = shadowNodeWrapper + .getDeclaredShadowNodeField(TEXT_ATTRIBUTES_FIELD_NAME) as? TextAttributes? + if (textAttributes != null) { + return textAttributes.effectiveFontSize.toLong() + } + + return null + } + + private fun getFontFamily(shadowNodeWrapper: ShadowNodeWrapper): String? { + val fontFamily = shadowNodeWrapper + .getDeclaredShadowNodeField(FONT_FAMILY_FIELD_NAME) as? String + + if (fontFamily != null) { + return resolveFontFamily(fontFamily.lowercase(Locale.US)) + } + + return null + } + + private fun resolveFontFamily(typefaceName: String): String = + 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 + } + + @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 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/ReactViewGroupMapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactViewGroupMapper.kt deleted file mode 100644 index de5596165..000000000 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactViewGroupMapper.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.reactnative.sessionreplay - -import android.annotation.SuppressLint -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.facebook.react.uimanager.Spacing -import com.facebook.react.views.view.ReactViewBackgroundDrawable -import com.facebook.react.views.view.ReactViewGroup - -internal class ReactViewGroupMapper : - BaseWireframeMapper(), - TraverseAllChildrenMapper { - - override fun map( - view: ReactViewGroup, - mappingContext: MappingContext, - asyncJobStatusCallback: AsyncJobStatusCallback - ): List { - val pixelDensity = mappingContext.systemInformation.screenDensity - - val viewGlobalBounds = resolveViewGlobalBounds( - view, - pixelDensity - ) - - val backgroundDrawable = view.background - - // view.alpha is the value of the opacity prop on the js side - val opacity = view.alpha - - val (shapeStyle, border) = - if (backgroundDrawable is ReactViewBackgroundDrawable) { - resolveRNShapeStyleAndBorder( - view = view, - backgroundDrawable = backgroundDrawable, - opacity = opacity, - pixelDensity = pixelDensity - ) - } else { - backgroundDrawable?.resolveShapeStyleAndBorder(opacity) ?: (null to null) - } - - return listOf( - MobileSegment.Wireframe.ShapeWireframe( - resolveViewId(view), - viewGlobalBounds.x, - viewGlobalBounds.y, - viewGlobalBounds.width, - viewGlobalBounds.height, - shapeStyle = shapeStyle, - border = border - ) - ) - } - - @SuppressLint("VisibleForTests") - private fun resolveRNShapeStyleAndBorder( - view: ReactViewGroup, - backgroundDrawable: ReactViewBackgroundDrawable, - opacity: Float, - pixelDensity: Float - ): Pair { - val backgroundColor = view.backgroundColor - val colorHexString = formatAsRgba(backgroundColor) - val cornerRadius = backgroundDrawable.fullBorderRadius.toLong().convertToDensityNormalized(pixelDensity) - val borderWidth = backgroundDrawable.fullBorderWidth.toLong().convertToDensityNormalized(pixelDensity) - val borderColor = formatAsRgba(backgroundDrawable.getBorderColor(Spacing.ALL)) - - return MobileSegment.ShapeStyle( - backgroundColor = colorHexString, - opacity = opacity, - cornerRadius = cornerRadius - ) to MobileSegment.ShapeBorder( - color = borderColor, - width = borderWidth - ) - } -} 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..98da40a7a --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ShadowNodeWrapper.kt @@ -0,0 +1,70 @@ +/* + * + * * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * * This product includes software developed at Datadog (https://www.datadoghq.com/). + * * Copyright 2016-Present Datadog, Inc. + * + */ + +package com.datadog.reactnative.sessionreplay + +import androidx.annotation.VisibleForTesting +import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.UIImplementation +import com.facebook.react.uimanager.UIManagerModule +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +internal class ShadowNodeWrapper( + private val shadowNode: ReactShadowNode>?, + private val reflectionUtils: ReflectionUtils = ReflectionUtils() +) { + internal fun getDeclaredShadowNodeField(fieldName: String): Any? { + return shadowNode?.let { + reflectionUtils.getDeclaredField( + shadowNode, + fieldName + ) + } + } + + internal companion object { + internal fun getShadowNodeWrapper( + reactContext: ReactContext, + uiManagerModule: UIManagerModule, + reflectionUtils: ReflectionUtils, + viewId: Int + ): ShadowNodeWrapper? { + val countDownLatch = CountDownLatch(1) + var target: ReactShadowNode>? = null + + val shadowNodeRunnable = Runnable { + val node = resolveShadowNode(reflectionUtils, uiManagerModule, viewId) + if (node != null) { + target = node + } + + countDownLatch.countDown() + } + + reactContext.runOnNativeModulesQueueThread(shadowNodeRunnable) + countDownLatch.await(5, TimeUnit.SECONDS) + + if (target == null) { + return null + } + + return ShadowNodeWrapper(reflectionUtils = reflectionUtils, shadowNode = target) + } + + private fun resolveShadowNode(reflectionUtils: ReflectionUtils, uiManagerModule: UIManagerModule, tag: Int): ReactShadowNode>? { + val uiManagerImplementation = reflectionUtils.getDeclaredField(uiManagerModule, UI_IMPLEMENTATION_FIELD_NAME) as UIImplementation? + return uiManagerImplementation?.resolveShadowNode(tag) + } + + @VisibleForTesting + internal const val UI_IMPLEMENTATION_FIELD_NAME = "mUIImplementation" + } +} diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/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/LongExt.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/LongExt.kt similarity index 88% rename from packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/LongExt.kt rename to packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/LongExt.kt index af671d01e..7750479af 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/LongExt.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/LongExt.kt @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.reactnative.sessionreplay +package com.datadog.reactnative.sessionreplay.extensions internal fun Long.convertToDensityNormalized(density: Float): Long { return if (density == 0f) { 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 new file mode 100644 index 000000000..6b5330912 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapper.kt @@ -0,0 +1,72 @@ +/* + * 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.widget.TextView +import androidx.annotation.VisibleForTesting +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 reactTextPropertiesResolver: TextPropertiesResolver = + NoopTextPropertiesResolver() +): TextViewMapper() { + + internal constructor( + reactContext: ReactContext, + uiManagerModule: UIManagerModule? + ): this( + reactTextPropertiesResolver = if (uiManagerModule == null) { + NoopTextPropertiesResolver() + } else { + ReactTextPropertiesResolver( + reactContext = reactContext, + uiManagerModule = uiManagerModule + ) + } + ) + + override fun map( + view: TextView, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): List { + val result = mutableListOf() + val wireframes = mapOnSuperclass(view, mappingContext, asyncJobStatusCallback) + val pixelDensity = mappingContext.systemInformation.screenDensity + + wireframes.forEach { originalWireframe -> + if (originalWireframe !is MobileSegment.Wireframe.TextWireframe) { + result.add(originalWireframe) + } else { + result.add(reactTextPropertiesResolver.addReactNativeProperties( + originalWireframe = originalWireframe, + view = view, + pixelDensity = pixelDensity, + )) + } + } + + return result + } + + @VisibleForTesting + 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 new file mode 100644 index 000000000..260939884 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt @@ -0,0 +1,58 @@ +/* + * 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 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.DrawableUtils +import com.datadog.reactnative.sessionreplay.utils.ReactViewBackgroundDrawableUtils +import com.facebook.react.views.view.ReactViewGroup + +internal class ReactViewGroupMapper( + private val reactViewBackgroundDrawableUtils: ReactViewBackgroundDrawableUtils = + ReactViewBackgroundDrawableUtils(), + private val drawableUtils: DrawableUtils = DrawableUtils() +) : + BaseWireframeMapper(), + TraverseAllChildrenMapper { + + override fun map( + view: ReactViewGroup, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): List { + val pixelDensity = mappingContext.systemInformation.screenDensity + val viewGlobalBounds = resolveViewGlobalBounds(view, pixelDensity) + 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) { + reactViewBackgroundDrawableUtils + .resolveShapeAndBorder(backgroundDrawable, opacity, pixelDensity) + } else { + null to null + } + + return listOf( + MobileSegment.Wireframe.ShapeWireframe( + resolveViewId(view), + viewGlobalBounds.x, + viewGlobalBounds.y, + viewGlobalBounds.width, + viewGlobalBounds.height, + shapeStyle = shapeStyle, + border = border + ) + ) + } +} 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/utils/DrawableUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/DrawableUtils.kt new file mode 100644 index 000000000..bf6ac4983 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/DrawableUtils.kt @@ -0,0 +1,35 @@ +/* + * 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 +import android.graphics.drawable.LayerDrawable +import com.facebook.react.views.view.ReactViewBackgroundDrawable + +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 LayerDrawable) { + for (layerNumber in 0 until drawable.numberOfLayers) { + val layer = drawable.getDrawable(layerNumber) + if (layer is ReactViewBackgroundDrawable) { + return layer + } + } + } + + 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 new file mode 100644 index 000000000..fabacf4ba --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ReactViewBackgroundDrawableUtils.kt @@ -0,0 +1,66 @@ +/* + * 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 class ReactViewBackgroundDrawableUtils( + private val reflectionUtils: ReflectionUtils = ReflectionUtils() +) { + 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, + 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" + } +} \ 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 new file mode 100644 index 000000000..16cb92819 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/utils/ReflectionUtils.kt @@ -0,0 +1,30 @@ +/* + * 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 java.lang.reflect.Field + +internal class ReflectionUtils { + internal fun getDeclaredField(instance: Any, fieldName: String): Any? { + val classInstance = instance.javaClass + val declaredField = searchForField(classInstance, fieldName) + + return declaredField?.let { + it.isAccessible = true + it.get(instance) + } + } + + private fun searchForField(className: Class<*>, fieldName: String): Field? { + return className.declaredFields.firstOrNull { it.name == fieldName } + ?: if (className.superclass != null) { + searchForField(className.superclass, fieldName) + } else { + null + } + } +} diff --git a/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt b/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt index 2d0c11cf1..e37cfff57 100644 --- a/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt +++ b/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt @@ -18,7 +18,7 @@ class DdSessionReplay( reactContext: ReactApplicationContext ) : ReactContextBaseJavaModule(reactContext) { - private val implementation = DdSessionReplayImplementation() + private val implementation = DdSessionReplayImplementation(reactContext) override fun getName(): String = DdSessionReplayImplementation.NAME 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 b346f4f9a..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,7 +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 @@ -22,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( @@ -38,12 +44,22 @@ internal class DdSessionReplayImplementationTest { @Mock lateinit var mockPromise: Promise + @Mock + lateinit var mockReactContext: ReactContext + @Mock lateinit var mockSessionReplay: SessionReplayWrapper + @Mock + lateinit var mockUiManagerModule: UIManagerModule + @BeforeEach fun `set up`() { - testedSessionReplay = DdSessionReplayImplementation { mockSessionReplay } + whenever(mockReactContext.getNativeModule(any>())) + .doReturn(mockUiManagerModule) + + testedSessionReplay = + DdSessionReplayImplementation(mockReactContext) { mockSessionReplay } } @AfterEach 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 new file mode 100644 index 000000000..5e2668bc2 --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtilsTest.kt @@ -0,0 +1,102 @@ +/* + * 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.DrawableUtils +import com.facebook.react.views.view.ReactViewBackgroundDrawable +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.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class DrawableUtilsTest { + private lateinit var testedDrawableUtils: DrawableUtils + + @Mock + private lateinit var mockReactViewBackgroundDrawable: ReactViewBackgroundDrawable + + @Mock + private lateinit var mockInsetDrawable: InsetDrawable + + @Mock + private lateinit var mockLayerDrawable: LayerDrawable + + @Mock + private lateinit var mockColorDrawable: ColorDrawable + + @BeforeEach + fun `set up`() { + whenever(mockLayerDrawable.numberOfLayers).thenReturn(3) + whenever(mockLayerDrawable.getDrawable(0)).thenReturn(mockReactViewBackgroundDrawable) + + testedDrawableUtils = DrawableUtils() + } + + @Test + fun `M return drawable W getReactBackgroundFromDrawable { ReactViewBackgroundDrawable }`() { + // When + val result = testedDrawableUtils + .getReactBackgroundFromDrawable(mockReactViewBackgroundDrawable) + + // Then + assertThat(result).isEqualTo(mockReactViewBackgroundDrawable) + } + + @Test + fun `M return drawable W getReactBackgroundFromDrawable { InsetDrawable }`() { + // Given + whenever(mockInsetDrawable.drawable).thenReturn(mockReactViewBackgroundDrawable) + + // When + val result = testedDrawableUtils.getReactBackgroundFromDrawable(mockInsetDrawable) + + // Then + assertThat(result).isEqualTo(mockReactViewBackgroundDrawable) + } + + @Test + fun `M return drawable W getReactBackgroundFromDrawable { LayerDrawable }`() { + // When + val result = testedDrawableUtils.getReactBackgroundFromDrawable(mockLayerDrawable) + + // Then + assertThat(result).isEqualTo(mockReactViewBackgroundDrawable) + } + + @Test + fun `M return null W getReactBackgroundFromDrawable { drawable is not supported }`() { + // When + 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 new file mode 100644 index 000000000..93c931c03 --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupportTest.kt @@ -0,0 +1,91 @@ +/* + * 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.android.sessionreplay.SessionReplayPrivacy +import com.datadog.reactnative.sessionreplay.mappers.ReactTextMapper +import com.datadog.reactnative.sessionreplay.mappers.ReactViewGroupMapper +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 +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.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class ReactNativeSessionReplayExtensionSupportTest { + + @Mock + private lateinit var mockReactContext: ReactContext + + @Mock + private lateinit var mockUiManagerModule: UIManagerModule + + @Mock + private lateinit var mockLogger: InternalLogger + + private lateinit var testedExtensionSupport: ReactNativeSessionReplayExtensionSupport + + @BeforeEach + fun `set up`() { + whenever(mockReactContext.getNativeModule(any>())) + .doReturn(mockUiManagerModule) + + testedExtensionSupport = ReactNativeSessionReplayExtensionSupport( + logger = mockLogger, + reactContext = mockReactContext + ) + } + + @Test + fun `M get custom view mappers W getCustomViewMappers()`() { + // When + val customViewMappers = testedExtensionSupport.getCustomViewMappers() + val allowMappers = customViewMappers[SessionReplayPrivacy.ALLOW] + + // Then + check(allowMappers != null) + assertThat(allowMappers).hasSize(3) + assertThat(allowMappers[ReactViewGroup::class.java]) + .isInstanceOf(ReactViewGroupMapper::class.java) + assertThat(allowMappers[ReactTextView::class.java]) + .isInstanceOf(ReactTextMapper::class.java) + assertThat(allowMappers[ReactEditText::class.java]) + .isInstanceOf(ReactTextMapper::class.java) + } + + @Test + fun `M return null W getUiManagerModule() { cannot get uiManagerModule }`() { + // Given + whenever(mockReactContext.getNativeModule(any>())) + .thenThrow(IllegalStateException()) + + // When + val uiManagerModule = testedExtensionSupport.getUiManagerModule() + + // Then + assertThat(uiManagerModule).isNull() + } +} diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolverTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolverTest.kt new file mode 100644 index 000000000..66f1e9780 --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactTextPropertiesResolverTest.kt @@ -0,0 +1,271 @@ +/* + * 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.MONOSPACE_FAMILY_NAME +import com.datadog.reactnative.sessionreplay.ReactTextPropertiesResolver.Companion.TEXT_ATTRIBUTES_FIELD_NAME +import com.datadog.reactnative.sessionreplay.ShadowNodeWrapper.Companion.UI_IMPLEMENTATION_FIELD_NAME +import com.datadog.reactnative.sessionreplay.utils.DrawableUtils +import com.datadog.reactnative.sessionreplay.utils.ReactViewBackgroundDrawableUtils +import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils +import com.datadog.reactnative.sessionreplay.utils.formatAsRgba +import com.datadog.reactnative.tools.unit.forge.ForgeConfigurator +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.UIImplementation +import com.facebook.react.uimanager.UIManagerModule +import com.facebook.react.views.text.TextAttributes +import com.facebook.react.views.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.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.any +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) +@ForgeConfiguration(ForgeConfigurator::class) +internal class ReactTextPropertiesResolverTest { + private lateinit var testedResolver: ReactTextPropertiesResolver + + @Mock + lateinit var mockReactContext: ReactContext + + @Mock + lateinit var mockUiManagerModule: UIManagerModule + + @Mock + lateinit var mockWireframe: MobileSegment.Wireframe.TextWireframe + + @Mock + lateinit var mockTextView: TextView + + @Mock + lateinit var mockDrawableUtils: DrawableUtils + + @Mock + lateinit var mockReactViewBackgroundDrawable: ReactViewBackgroundDrawable + + @Mock + lateinit var mockReactViewBackgroundDrawableUtils: ReactViewBackgroundDrawableUtils + + @Mock + lateinit var mockShadowNodeWrapper: ShadowNodeWrapper + + @Mock + lateinit var mockReflectionUtils: ReflectionUtils + + @Forgery + private lateinit var fakeWireframe: MobileSegment.Wireframe.TextWireframe + + @Mock + private lateinit var mockUiImplementation: UIImplementation + + @Mock + private lateinit var mockShadowNode: ReactShadowNode> + + @BeforeEach + fun `set up`(forge: Forge) { + whenever( + mockReflectionUtils.getDeclaredField(mockUiManagerModule, UI_IMPLEMENTATION_FIELD_NAME) + ).thenReturn(mockUiImplementation) + + whenever( + mockUiImplementation.resolveShadowNode( + eq(mockTextView.id) + ) + ).thenReturn(mockShadowNode) + + whenever(mockReactContext.runOnNativeModulesQueueThread(any())).thenAnswer { + (it.arguments[0] as Runnable).run() + } + + testedResolver = ReactTextPropertiesResolver( + reactContext = mockReactContext, + uiManagerModule = mockUiManagerModule, + reactViewBackgroundDrawableUtils = mockReactViewBackgroundDrawableUtils, + drawableUtils = mockDrawableUtils, + reflectionUtils = mockReflectionUtils + ) + } + + // region addReactNativeProperties + + @Test + fun `M get original wireframe W addReactNativeProperties() { no react properties }`() { + // Given + whenever(mockTextView.background).thenReturn(null) + whenever(mockUiImplementation.resolveShadowNode(any())).thenReturn(null) + + // When + val result = testedResolver.addReactNativeProperties(mockWireframe, mockTextView, 0f) + + // Then + assertThat(result).isEqualTo(mockWireframe) + } + + @Test + fun `M add drawable properties W addReactNativeProperties() { has reactBackgroundDrawable }`( + forge: Forge + ) { + // Given + val pixelDensity = 0f + val fakeBorderRadius = forge.aPositiveFloat() + val fakeBorderWidth = forge.aPositiveFloat() + val fakeBorderColor = forge.aPositiveInt() + whenever(mockTextView.background).thenReturn(mockReactViewBackgroundDrawable) + 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( + 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)) + } + + @Test + fun `M resolve font family W addReactNativeProperties()`() { + // Given + whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, FONT_FAMILY_FIELD_NAME)) + .thenReturn(MONOSPACE_FAMILY_NAME) + + whenever(mockTextView.background).thenReturn(null) + + // When + val result = testedResolver + .addReactNativeProperties(fakeWireframe, mockTextView, 0f) + + // Then + assertThat(result.textStyle.family) + .isEqualTo(MONOSPACE_FAMILY_NAME) + } + + @Test + fun `M fallback W addReactNativeProperties() { cannot resolve fontFamily }`() { + // Given + whenever(mockTextView.background).thenReturn(null) + whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(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 addReactNativeProperties()`( + @Mock mockTextAttributes: TextAttributes, + @IntForgery fakeTextSize: Int + ) { + // Given + whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, TEXT_ATTRIBUTES_FIELD_NAME)) + .thenReturn(mockTextAttributes) + whenever(mockTextView.background).thenReturn(null) + 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 addReactNativeProperties() { cannot resolve fontSize }`( + @Mock mockTextAttributes: TextAttributes + ) { + // Given + whenever(mockTextView.background).thenReturn(null) + whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(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 addReactNativeProperties()`( + @IntForgery fakeTextColor: Int + ) { + // Given + whenever(mockTextView.background).thenReturn(null) + whenever(mockReflectionUtils.getDeclaredField(mockShadowNode, 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 addReactNativeProperties() { cannot resolve fontColor }`() { + // Given + whenever(mockTextView.background).thenReturn(null) + whenever(mockShadowNodeWrapper.getDeclaredShadowNodeField(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 new file mode 100644 index 000000000..29ff84190 --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapperTest.kt @@ -0,0 +1,142 @@ +/* + * 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 +import android.graphics.Typeface +import android.util.DisplayMetrics +import android.widget.TextView +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.ReactTextPropertiesResolver +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.eq +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class ReactTextMapperTest { + private lateinit var testedMapper: ReactTextMapper + + @Mock + private lateinit var mockReactTextPropertiesResolver: ReactTextPropertiesResolver + + @Mock + private lateinit var mockMappingContext: MappingContext + + @Mock + private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback + + @Mock + private lateinit var mockTextView: TextView + + @Mock + private lateinit var mockSystemInformation: SystemInformation + + @Mock + private lateinit var mockResources: Resources + + @Mock + private lateinit var mockDisplayMetrics: DisplayMetrics + + @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(0f) + whenever(mockMappingContext.systemInformation).thenReturn(mockSystemInformation) + whenever(mockTextView.text).thenReturn(forge.aString()) + whenever(mockTextView.typeface).thenReturn(Typeface.SANS_SERIF) + + whenever( + mockReactTextPropertiesResolver.addReactNativeProperties( + originalWireframe = eq(mockTextWireframe), + view = eq(mockTextView), + pixelDensity = eq(0f) + ) + ).thenReturn(mockTextWireframe) + + testedMapper = spy( + ReactTextMapper( + reactTextPropertiesResolver = mockReactTextPropertiesResolver + ) + ) + } + + @Test + fun `M return wireframe W map() { even if not TextWireframeType }`( + @Mock mockImageWireframe: MobileSegment.Wireframe.ImageWireframe + ) { + // Given + whenever( + testedMapper.mapOnSuperclass( + textView = eq(mockTextView), + mappingContext = eq(mockMappingContext), + asyncJobStatusCallback = eq(mockAsyncJobStatusCallback) + ) + ).thenReturn( + listOf(mockImageWireframe) + ) + + // When + val result = testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback) + + // Then + assertThat(result).contains(mockImageWireframe) + } + + @Test + fun `M return textWireframe W map()`() { + // Given + whenever( + testedMapper.mapOnSuperclass( + textView = eq(mockTextView), + mappingContext = eq(mockMappingContext), + asyncJobStatusCallback = eq(mockAsyncJobStatusCallback) + ) + ).thenReturn( + listOf(mockTextWireframe) + ) + + whenever( + mockReactTextPropertiesResolver.addReactNativeProperties( + originalWireframe = eq(mockTextWireframe), + view = eq(mockTextView), + pixelDensity = eq(0f) + ) + ).thenReturn(mockTextWireframe) + + // When + val result = testedMapper.map( + mockTextView, + mockMappingContext, + mockAsyncJobStatusCallback + )[0] as MobileSegment.Wireframe.TextWireframe + + // Then + assertThat(result.text).isEqualTo(mockTextWireframe.text) + } +} 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..61ac82e98 --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapperTest.kt @@ -0,0 +1,131 @@ +/* + * + * * 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.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 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( + 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) } + ) + } +}