From b27e59a26c53a615ef340d72ee0e7dfb192a9f1f 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 | 4 +- .../sessionreplay/DrawableUtils.kt | 27 ++ ...eactNativeSessionReplayExtensionSupport.kt | 19 +- .../sessionreplay/ReactTextShadowNodeUtils.kt | 81 ++++ .../sessionreplay/extensions/IntExt.kt | 15 + .../sessionreplay/{ => extensions}/LongExt.kt | 2 +- .../ReactViewBackgroundDrawableExt.kt | 39 ++ .../sessionreplay/mappers/ReactTextMapper.kt | 202 ++++++++ .../{ => mappers}/ReactViewGroupMapper.kt | 52 +- .../sessionreplay/DdSessionReplay.kt | 2 +- .../DdSessionReplayImplementationTest.kt | 6 +- .../sessionreplay/DrawableUtilsTest.kt | 82 ++++ ...NativeSessionReplayExtensionSupportTest.kt | 50 ++ .../mappers/ReactTextMapperTest.kt | 449 ++++++++++++++++++ 14 files changed, 979 insertions(+), 51 deletions(-) create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtils.kt create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextShadowNodeUtils.kt create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/IntExt.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/extensions/ReactViewBackgroundDrawableExt.kt create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapper.kt rename packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/{ => mappers}/ReactViewGroupMapper.kt (50%) 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/mappers/ReactTextMapperTest.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..3eefe6ffb 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 @@ -9,12 +9,14 @@ package com.datadog.reactnative.sessionreplay 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() } @@ -27,7 +29,7 @@ class DdSessionReplayImplementation( fun enable(replaySampleRate: Double, defaultPrivacyLevel: String, promise: Promise) { val configuration = SessionReplayConfiguration.Builder(replaySampleRate.toFloat()) .setPrivacy(buildPrivacy(defaultPrivacyLevel)) - .addExtensionSupport(ReactNativeSessionReplayExtensionSupport()) + .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(reactContext)) .build() sessionReplayProvider().enable(configuration) promise.resolve(null) diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtils.kt new file mode 100644 index 000000000..ad6705833 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtils.kt @@ -0,0 +1,27 @@ +package com.datadog.reactnative.sessionreplay + +import android.graphics.drawable.Drawable +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 + } + + 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/ReactNativeSessionReplayExtensionSupport.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupport.kt index 52de3854b..d79a958e6 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 @@ -11,14 +11,25 @@ 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.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, +) : ExtensionSupport { override fun getCustomViewMappers(): Map, WireframeMapper>> { - return mapOf(SessionReplayPrivacy.ALLOW to mapOf( - ReactViewGroup::class.java to ReactViewGroupMapper() as WireframeMapper - )) + return mapOf( + SessionReplayPrivacy.ALLOW to mapOf( + ReactViewGroup::class.java to ReactViewGroupMapper() as WireframeMapper, + ReactTextView::class.java to ReactTextMapper(reactContext) as WireframeMapper, + ReactEditText::class.java to ReactTextMapper(reactContext) as WireframeMapper + ) + ) } override fun getOptionSelectorDetectors(): List { diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextShadowNodeUtils.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextShadowNodeUtils.kt new file mode 100644 index 000000000..0276bb39d --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactTextShadowNodeUtils.kt @@ -0,0 +1,81 @@ +package com.datadog.reactnative.sessionreplay + +import android.util.Log +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.UIImplementation +import com.facebook.react.uimanager.UIManagerModule +import com.facebook.react.views.text.ReactTextShadowNode +import com.facebook.react.views.text.TextAttributes +import okhttp3.internal.notify +import okhttp3.internal.wait + +internal class ReactTextShadowNodeUtils( + private val reactContext: ReactContext +) { + internal fun getFontSize(shadowNode: ReactTextShadowNode): Int { + shadowNode.javaClass.superclass?.getDeclaredField("mTextAttributes").let { + it?.isAccessible = true + val textAttributes = it?.get(shadowNode) as TextAttributes + return textAttributes.effectiveFontSize + } + } + + internal fun getFontFamily(shadowNode: ReactTextShadowNode): String? { + shadowNode.javaClass.superclass.getDeclaredField("mFontFamily").let { + it.isAccessible = true + return it.get(shadowNode)?.toString() + } + } + + internal fun getGravity(viewId: Int): Int? { + val shadowNode = getShadowNode(viewId) as? ReactTextShadowNode ?: return null + shadowNode.javaClass.superclass.getDeclaredField("mTextAlign").let { + it.isAccessible = true + return it.getInt(shadowNode) + } + } + + internal fun getColor(shadowNode: ReactTextShadowNode): Int { + shadowNode.javaClass.superclass.getDeclaredField("mColor").let { + it.isAccessible = true + return it.getInt(shadowNode) + } + } + + internal fun getShadowNode(viewId: Int): ReactShadowNode>? { + val uiManagerModule = try { + reactContext.getNativeModule(UIManagerModule::class.java) as UIManagerModule + } catch (e: IllegalStateException) { + Log.e( + ReactTextShadowNodeUtils::class.java.canonicalName, + "Unable to resolve uiManagerModule", + e + ) + return null + } + var target: ReactShadowNode>? = null + val shadowNodeRunnable = Runnable { + val node = uiManagerModule.resolveShadowNode(viewId) + if (node != null) { + target = node + } + synchronized(this) { + this.notify() + } + } + synchronized(this) { + reactContext.runOnNativeModulesQueueThread(shadowNodeRunnable) + this.wait() + return target + } + } + + private fun UIManagerModule.resolveShadowNode(tag: Int): ReactShadowNode>? { + javaClass.getDeclaredField("mUIImplementation").let { + it.isAccessible = true + val value = it.get(this) as UIImplementation + return value.resolveShadowNode(tag) + } + } +} 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 new file mode 100644 index 000000000..f47b7fa74 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/IntExt.kt @@ -0,0 +1,15 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.reactnative.sessionreplay.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/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/extensions/ReactViewBackgroundDrawableExt.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/ReactViewBackgroundDrawableExt.kt new file mode 100644 index 000000000..83f5a19b1 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/extensions/ReactViewBackgroundDrawableExt.kt @@ -0,0 +1,39 @@ +package com.datadog.reactnative.sessionreplay.extensions + +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.reactnative.sessionreplay.formatAsRgba +import com.facebook.react.uimanager.Spacing +import com.facebook.react.views.view.ReactViewBackgroundDrawable + +internal fun ReactViewBackgroundDrawable.resolveShapeAndBorder(opacity: Float, pixelDensity: Float): Pair { + val borderProps = resolveBorder(this, pixelDensity) + val colorHexString = formatAsRgba(this.getBackgroundColor()) + val cornerRadius = this.fullBorderRadius.toLong().convertToDensityNormalized(pixelDensity) + + return MobileSegment.ShapeStyle( + colorHexString, + opacity, + cornerRadius + ) to borderProps +} + +private fun ReactViewBackgroundDrawable.getBackgroundColor(): Int { + javaClass.getDeclaredField("mColor").let { + it.isAccessible = true + return it.getInt(this) + } +} + +private fun resolveBorder(backgroundDrawable: ReactViewBackgroundDrawable?, pixelDensity: Float): MobileSegment.ShapeBorder? { + if (backgroundDrawable == null) { + return null + } + + val borderWidth = backgroundDrawable.fullBorderWidth.toLong().convertToDensityNormalized(pixelDensity) + val borderColor = formatAsRgba(backgroundDrawable.getBorderColor(Spacing.ALL)) + + return MobileSegment.ShapeBorder( + color = borderColor, + width = borderWidth + ) +} diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapper.kt new file mode 100644 index 000000000..17fe87242 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapper.kt @@ -0,0 +1,202 @@ +/* + * 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.Typeface +import android.view.Gravity +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.ReactTextShadowNodeUtils +import com.datadog.reactnative.sessionreplay.extensions.convertToDensityNormalized +import com.datadog.reactnative.sessionreplay.extensions.densityNormalized +import com.datadog.reactnative.sessionreplay.formatAsRgba +import com.datadog.reactnative.sessionreplay.extensions.resolveShapeAndBorder +import com.datadog.reactnative.sessionreplay.getReactBackgroundFromDrawable +import com.facebook.react.bridge.ReactContext +import com.facebook.react.views.text.ReactTextShadowNode +import com.facebook.react.views.view.ReactViewBackgroundDrawable + +internal class ReactTextMapper( + private val reactContext: ReactContext, + private val reactTextShadowNodeUtils: ReactTextShadowNodeUtils = ReactTextShadowNodeUtils(reactContext), +): TextViewMapper() { + + override fun map( + view: TextView, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): List { + val wireframes = super.map(view, mappingContext, asyncJobStatusCallback) + val textWireframe = wireframes.find { + it is MobileSegment.Wireframe.TextWireframe + } as? MobileSegment.Wireframe.TextWireframe ?: return emptyList() + + val shadowNode = reactTextShadowNodeUtils.getShadowNode(view.id) as? ReactTextShadowNode + val pixelDensity = mappingContext.systemInformation.screenDensity + + // view.alpha is the value of the opacity prop on the js side + val opacity = view.alpha + + val backgroundDrawable: ReactViewBackgroundDrawable? = + getReactBackgroundFromDrawable(view.background) + + val (shapeStyle, border) = + backgroundDrawable?.resolveShapeAndBorder(opacity, pixelDensity) + ?: view.background?.resolveShapeStyleAndBorder(opacity) + ?: (null to null) + + val textStyle = resolveTextStyle(view, pixelDensity, shadowNode) + val textPosition = resolveTextPosition(view, pixelDensity) + + return listOf( + textWireframe.copy( + shapeStyle = shapeStyle, + border = border, + textStyle = textStyle, + textPosition = textPosition + ) + ) + } + + private fun resolveTextPosition( + view: TextView, + pixelsDensity: Float + ): MobileSegment.TextPosition { + return MobileSegment.TextPosition( + resolvePadding(view, pixelsDensity), + resolveAlignment(view) + ) + } + + private fun resolveTextStyle( + view: TextView, + pixelsDensity: Float, + shadowNode: ReactTextShadowNode? + ): MobileSegment.TextStyle { + return MobileSegment.TextStyle( + family = getFontFamily(view, shadowNode), + size = resolveFontSize(view, shadowNode).convertToDensityNormalized(pixelsDensity), + color = resolveTextColor(view, shadowNode) + ) + } + + private fun getFontFamily(view: TextView, shadowNode: ReactTextShadowNode?): String { + if (shadowNode != null) { + val shadowFontFamily = + reactTextShadowNodeUtils.getFontFamily(shadowNode) + return shadowFontFamily ?: resolveFontFamily(view.typeface) + } + + return resolveFontFamily(view.typeface) + } + + private fun resolvePadding(textView: TextView, pixelsDensity: Float): MobileSegment.Padding { + return MobileSegment.Padding( + top = textView.totalPaddingTop.densityNormalized(pixelsDensity).toLong(), + bottom = textView.totalPaddingBottom.densityNormalized(pixelsDensity).toLong(), + left = textView.totalPaddingStart.densityNormalized(pixelsDensity).toLong(), + right = textView.totalPaddingEnd.densityNormalized(pixelsDensity).toLong() + ) + } + + private fun resolveAlignment( + textView: TextView, + ): MobileSegment.Alignment { + return when (textView.textAlignment) { + TextView.TEXT_ALIGNMENT_GRAVITY -> resolveAlignmentFromGravity(textView) + TextView.TEXT_ALIGNMENT_CENTER -> MobileSegment.Alignment( + horizontal = MobileSegment.Horizontal.CENTER, + vertical = MobileSegment.Vertical.CENTER + ) + + TextView.TEXT_ALIGNMENT_TEXT_END, + TextView.TEXT_ALIGNMENT_VIEW_END -> MobileSegment.Alignment( + horizontal = MobileSegment.Horizontal.RIGHT, + vertical = MobileSegment.Vertical.CENTER + ) + + TextView.TEXT_ALIGNMENT_TEXT_START, + TextView.TEXT_ALIGNMENT_VIEW_START -> MobileSegment.Alignment( + horizontal = MobileSegment.Horizontal.LEFT, + vertical = MobileSegment.Vertical.CENTER + ) + + else -> MobileSegment.Alignment( + horizontal = MobileSegment.Horizontal.LEFT, + vertical = MobileSegment.Vertical.CENTER + ) + } + } + + private fun resolveAlignmentFromGravity( + view: TextView + ): MobileSegment.Alignment { + val shadowGravity = reactTextShadowNodeUtils.getGravity(view.id) + ?: view.gravity + + val horizontalAlignment = when (shadowGravity.and(Gravity.HORIZONTAL_GRAVITY_MASK)) { + Gravity.START, + Gravity.LEFT -> MobileSegment.Horizontal.LEFT + + Gravity.END, + Gravity.RIGHT -> MobileSegment.Horizontal.RIGHT + + Gravity.CENTER, + Gravity.CENTER_HORIZONTAL -> MobileSegment.Horizontal.CENTER + + else -> MobileSegment.Horizontal.LEFT + } + val verticalAlignment = when (shadowGravity.and(Gravity.VERTICAL_GRAVITY_MASK)) { + Gravity.TOP -> MobileSegment.Vertical.TOP + Gravity.BOTTOM -> MobileSegment.Vertical.BOTTOM + + Gravity.CENTER_VERTICAL, + Gravity.CENTER -> MobileSegment.Vertical.CENTER + + else -> MobileSegment.Vertical.CENTER + } + + return MobileSegment.Alignment(horizontalAlignment, verticalAlignment) + } + + private fun resolveFontSize(view: TextView, shadowNode: ReactTextShadowNode?): Long { + if (shadowNode != null) { + return reactTextShadowNodeUtils.getFontSize(shadowNode).toLong() + } + return view.textSize.toLong() + } + + private fun resolveTextColor(view: TextView, shadowNode: ReactTextShadowNode?): String { + if (shadowNode != null) { + return formatAsRgba(reactTextShadowNodeUtils.getColor(shadowNode)) + } + + return formatAsRgba(view.currentTextColor) + } + + private fun resolveFontFamily(typeface: Typeface?): String = + typefaceMap[typeface] + ?: SANS_SERIF_FAMILY_NAME + + internal companion object { + @VisibleForTesting + internal const val SANS_SERIF_FAMILY_NAME = "roboto, sans-serif" + private const val SERIF_FAMILY_NAME = "serif" + private const val MONOSPACE_FAMILY_NAME = "monospace" + + @VisibleForTesting + internal val typefaceMap: Map = mapOf( + Typeface.SANS_SERIF to SANS_SERIF_FAMILY_NAME, + Typeface.MONOSPACE to MONOSPACE_FAMILY_NAME, + Typeface.SERIF to SERIF_FAMILY_NAME + ) + } +} diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactViewGroupMapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt similarity index 50% rename from packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactViewGroupMapper.kt rename to packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt index de5596165..952555156 100644 --- 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/mappers/ReactViewGroupMapper.kt @@ -4,15 +4,15 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.reactnative.sessionreplay +package com.datadog.reactnative.sessionreplay.mappers -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.datadog.reactnative.sessionreplay.extensions.resolveShapeAndBorder +import com.datadog.reactnative.sessionreplay.getReactBackgroundFromDrawable import com.facebook.react.views.view.ReactViewBackgroundDrawable import com.facebook.react.views.view.ReactViewGroup @@ -26,28 +26,17 @@ internal class ReactViewGroupMapper : asyncJobStatusCallback: AsyncJobStatusCallback ): List { val pixelDensity = mappingContext.systemInformation.screenDensity - - val viewGlobalBounds = resolveViewGlobalBounds( - view, - pixelDensity - ) - - val backgroundDrawable = view.background + val viewGlobalBounds = resolveViewGlobalBounds(view, pixelDensity) + val backgroundDrawable: ReactViewBackgroundDrawable? = + 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 is ReactViewBackgroundDrawable) { - resolveRNShapeStyleAndBorder( - view = view, - backgroundDrawable = backgroundDrawable, - opacity = opacity, - pixelDensity = pixelDensity - ) - } else { - backgroundDrawable?.resolveShapeStyleAndBorder(opacity) ?: (null to null) - } + backgroundDrawable?.resolveShapeAndBorder(opacity, pixelDensity) + ?: view.background?.resolveShapeStyleAndBorder(opacity) + ?: (null to null) return listOf( MobileSegment.Wireframe.ShapeWireframe( @@ -61,27 +50,4 @@ internal class ReactViewGroupMapper : ) ) } - - @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/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/DdSessionReplayImplementationTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt index b346f4f9a..2c3f8ca01 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 @@ -10,6 +10,7 @@ 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.Promise +import com.facebook.react.bridge.ReactContext import fr.xgouchet.elmyr.annotation.DoubleForgery import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.StringForgery @@ -38,12 +39,15 @@ internal class DdSessionReplayImplementationTest { @Mock lateinit var mockPromise: Promise + @Mock + lateinit var mockReactContext: ReactContext + @Mock lateinit var mockSessionReplay: SessionReplayWrapper @BeforeEach fun `set up`() { - testedSessionReplay = DdSessionReplayImplementation { mockSessionReplay } + testedSessionReplay = DdSessionReplayImplementation(reactContext = 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..cd33d7f3b --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DrawableUtilsTest.kt @@ -0,0 +1,82 @@ +package com.datadog.reactnative.sessionreplay + +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.LayerDrawable +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 { + @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(1) + whenever(mockLayerDrawable.getDrawable(0)).thenReturn(mockReactViewBackgroundDrawable) + } + + @Test + fun `M return drawable W getReactBackgroundFromDrawable { drawable is ReactViewBackgroundDrawable}`() { + // When + val result = getReactBackgroundFromDrawable(mockReactViewBackgroundDrawable) + + // Then + assertThat(result).isEqualTo(mockReactViewBackgroundDrawable) + } + + @Test + fun `M return drawable W getReactBackgroundFromDrawable { drawable is InsetDrawable}`() { + // Given + whenever(mockInsetDrawable.drawable).thenReturn(mockReactViewBackgroundDrawable) + + // When + val result = getReactBackgroundFromDrawable(mockInsetDrawable) + + // Then + assertThat(result).isEqualTo(mockReactViewBackgroundDrawable) + } + + @Test + fun `M return drawable W getReactBackgroundFromDrawable { drawable is LayerDrawable}`() { + // When + val result = getReactBackgroundFromDrawable(mockLayerDrawable) + + // Then + assertThat(result).isEqualTo(mockReactViewBackgroundDrawable) + } + + @Test + fun `M return null W getReactBackgroundFromDrawable { drawable is not supported }`() { + // When + val result = getReactBackgroundFromDrawable(mockColorDrawable) + + // 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..0757a4c19 --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupportTest.kt @@ -0,0 +1,50 @@ +package com.datadog.reactnative.sessionreplay + +import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.reactnative.sessionreplay.mappers.ReactTextMapper +import com.datadog.reactnative.sessionreplay.mappers.ReactViewGroupMapper +import com.datadog.tools.unit.GenericAssert.Companion.assertThat +import com.facebook.react.bridge.ReactContext +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.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.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class ReactNativeSessionReplayExtensionSupportTest { + + @Mock + lateinit var mockReactContext: ReactContext + + private lateinit var testedExtensionSupport: ReactNativeSessionReplayExtensionSupport + + @BeforeEach + fun `set up`() { + testedExtensionSupport = ReactNativeSessionReplayExtensionSupport(mockReactContext) + } + + @Test + fun `M get custom view mappers W getCustomViewMappers()`() { + // When + val customViewMappers = testedExtensionSupport.getCustomViewMappers() + val allowMappers = customViewMappers.get(SessionReplayPrivacy.ALLOW) + + // Then + assertThat(allowMappers?.size).isEqualTo(3) + assertThat(allowMappers?.get(ReactViewGroup::class.java)).isInstanceOf(ReactViewGroupMapper::class.java) + assertThat(allowMappers?.get(ReactTextView::class.java)).isInstanceOf(ReactTextMapper::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/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..affbedfed --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactTextMapperTest.kt @@ -0,0 +1,449 @@ +package com.datadog.reactnative.sessionreplay.mappers + +import android.content.res.Resources +import android.graphics.Typeface +import android.util.DisplayMetrics +import android.view.Gravity +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.ReactTextShadowNodeUtils +import com.datadog.reactnative.sessionreplay.mappers.ReactTextMapper.Companion.SANS_SERIF_FAMILY_NAME +import com.datadog.reactnative.sessionreplay.mappers.ReactTextMapper.Companion.typefaceMap +import com.facebook.react.bridge.ReactContext +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +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.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 mockReactContext: ReactContext + + @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 mockReactTextShadowNodeUtils: ReactTextShadowNodeUtils + + @IntForgery + private var fakeId: Int = 0 + + @FloatForgery + private var fakeX: Float = 0f + + @FloatForgery + private var fakeY: Float = 0f + + @IntForgery + private var fakeWidth: Int = 0 + + @IntForgery + private var fakeHeight: Int = 0 + + @StringForgery + private var fakeText: String = "" + + private lateinit var expectedWireframe: MobileSegment.Wireframe.TextWireframe + + @BeforeEach + fun `set up`(forge: Forge) { + whenever(mockResources.displayMetrics).thenReturn(mockDisplayMetrics) + whenever(mockTextView.resources).thenReturn(mockResources) + whenever(mockSystemInformation.screenDensity).thenReturn(1.0f) + whenever(mockMappingContext.systemInformation).thenReturn(mockSystemInformation) + whenever(mockTextView.text).thenReturn(forge.aString()) + + expectedWireframe = MobileSegment.Wireframe.TextWireframe( + id = fakeId.toLong(), + x = fakeX.toLong(), + y = fakeY.toLong(), + width = fakeWidth.toLong(), + height = fakeHeight.toLong(), + shapeStyle = null, + border = null, + text = fakeText, + textStyle = MobileSegment.TextStyle( + color = "blue", + size = 0, + family = "", + + ), + textPosition = MobileSegment.TextPosition( + padding = MobileSegment.Padding( + top = 0, + left = 0, + right = 0, + bottom = 0 + ), + alignment = MobileSegment.Alignment( + horizontal = MobileSegment.Horizontal.LEFT, + vertical = MobileSegment.Vertical.TOP + ) + ) + ) + + testedMapper = ReactTextMapper( + reactContext = mockReactContext, + reactTextShadowNodeUtils = mockReactTextShadowNodeUtils + ) + } + + @Test + fun `M return CENTER-CENTER W map { TEXT_ALIGNMENT_CENTER }`() { + // Given + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_CENTER) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.CENTER) + assertThat(textPosition?.alignment?.vertical).isEqualTo(MobileSegment.Vertical.CENTER) + } + + @Test + fun `M return RIGHT-CENTER W map { TEXT_ALIGNMENT_TEXT_END }`() { + // Given + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_TEXT_END) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.RIGHT) + assertThat(textPosition?.alignment?.vertical).isEqualTo(MobileSegment.Vertical.CENTER) + } + + @Test + fun `M return RIGHT-CENTER W map { TEXT_ALIGNMENT_VIEW_END }`() { + // Given + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_VIEW_END) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.RIGHT) + assertThat(textPosition?.alignment?.vertical).isEqualTo(MobileSegment.Vertical.CENTER) + } + + @Test + fun `M return LEFT-CENTER W map { TEXT_ALIGNMENT_TEXT_START }`() { + // Given + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_TEXT_START) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.LEFT) + assertThat(textPosition?.alignment?.vertical).isEqualTo(MobileSegment.Vertical.CENTER) + } + + @Test + fun `M return LEFT-CENTER W map { TEXT_ALIGNMENT_VIEW_START }`() { + // Given + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_VIEW_START) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.LEFT) + assertThat(textPosition?.alignment?.vertical).isEqualTo(MobileSegment.Vertical.CENTER) + } + + @Test + fun `M return LEFT W map { shadow gravity LEFT }`() { + // Given + whenever(mockReactTextShadowNodeUtils.getGravity(any())).thenReturn( + Gravity.LEFT) + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.LEFT) + } + + @Test + fun `M return LEFT W map { shadow gravity START }`() { + // Given + whenever(mockReactTextShadowNodeUtils.getGravity(any())).thenReturn( + Gravity.START) + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.LEFT) + } + + @Test + fun `M return RIGHT W map { shadow gravity RIGHT }`() { + // Given + whenever(mockReactTextShadowNodeUtils.getGravity(any())).thenReturn( + Gravity.RIGHT) + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.RIGHT) + } + + @Test + fun `M return RIGHT W map { shadow gravity END }`() { + // Given + whenever(mockReactTextShadowNodeUtils.getGravity(any())).thenReturn( + Gravity.END) + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.RIGHT) + } + + @Test + fun `M return CENTER W map { shadow gravity CENTER_HORIZONTAL }`() { + // Given + whenever(mockReactTextShadowNodeUtils.getGravity(any())).thenReturn( + Gravity.CENTER_HORIZONTAL) + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.CENTER) + } + + @Test + fun `M return CENTER W map { shadow gravity CENTER_VERTICAL }`() { + // Given + whenever(mockReactTextShadowNodeUtils.getGravity(any())).thenReturn( + Gravity.CENTER_VERTICAL) + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.vertical).isEqualTo(MobileSegment.Vertical.CENTER) + } + + @Test + fun `M return TOP W map { shadow gravity TOP }`() { + // Given + whenever(mockReactTextShadowNodeUtils.getGravity(any())).thenReturn( + Gravity.TOP) + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.vertical).isEqualTo(MobileSegment.Vertical.TOP) + } + + @Test + fun `M return BOTTOM W map { shadow gravity BOTTOM }`() { + // Given + whenever(mockReactTextShadowNodeUtils.getGravity(any())).thenReturn( + Gravity.BOTTOM) + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.vertical).isEqualTo(MobileSegment.Vertical.BOTTOM) + } + + @Test + fun `M return CENTER W map { shadow gravity horizontal CENTER }`() { + // Given + whenever(mockReactTextShadowNodeUtils.getGravity(any())).thenReturn( + Gravity.CENTER) + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.CENTER) + } + + @Test + fun `M return CENTER W map { shadow gravity vertical CENTER }`() { + // Given + whenever(mockReactTextShadowNodeUtils.getGravity(any())).thenReturn( + Gravity.CENTER) + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.vertical).isEqualTo(MobileSegment.Vertical.CENTER) + } + + @Test + fun `M fallback to view gravity W map { shadow gravity null }`() { + // Given + whenever(mockReactTextShadowNodeUtils.getGravity(any())).thenReturn( + null) + whenever(mockTextView.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) + whenever(mockTextView.gravity).thenReturn(Gravity.CENTER) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textPosition = wireframe.textPosition + assertThat(textPosition?.alignment?.horizontal).isEqualTo(MobileSegment.Horizontal.CENTER) + assertThat(textPosition?.alignment?.vertical).isEqualTo(MobileSegment.Vertical.CENTER) + } + + // endregion + + // region text style + + @Test + fun `M fallback to textview font family W map { no shadow node }`(forge: Forge) { + // Given + val fakeTypeface = forge.aKeyFrom(typefaceMap) + whenever(mockTextView.typeface).thenReturn(fakeTypeface) + whenever(mockReactTextShadowNodeUtils.getShadowNode(any())).thenReturn(null) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textStyle = wireframe.textStyle + assertThat(textStyle.family).isEqualTo(typefaceMap[fakeTypeface]) + } + + @Test + fun `M fallback to currentTextColor W map { no shadow node }`(forge: Forge) { + // Given + val fakeColor = 16711680 + whenever(mockTextView.currentTextColor).thenReturn(fakeColor) + whenever(mockReactTextShadowNodeUtils.getShadowNode(any())).thenReturn(null) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textStyle = wireframe.textStyle + assertThat(textStyle.color).isEqualTo("#ff0000") + } + + @Test + fun `M fallback to view textSize W map { no shadow node }`(forge: Forge) { + // Given + val fakeTextSize = forge.aFloat() + whenever(mockTextView.textSize).thenReturn(fakeTextSize) + whenever(mockReactTextShadowNodeUtils.getShadowNode(any())).thenReturn(null) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textStyle = wireframe.textStyle + assertThat(textStyle.size).isEqualTo(fakeTextSize.toLong()) + } + + @Test + fun `M default to SANS_SERIF W map { unknown typeface }`(@Mock unknownTypeface: Typeface) { + // Given + whenever(mockTextView.typeface).thenReturn(unknownTypeface) + whenever(mockReactTextShadowNodeUtils.getFontFamily(any())).thenReturn(null) + + // When + val wireframe = + testedMapper.map(mockTextView, mockMappingContext, mockAsyncJobStatusCallback)[0] + + // Then + val textStyle = wireframe.textStyle + assertThat(textStyle.family).isEqualTo(SANS_SERIF_FAMILY_NAME) + } + + // endregion +}