Skip to content

Commit

Permalink
Merge pull request #569 from DataDog/jmoskovich/rum-1817/record-andro…
Browse files Browse the repository at this point in the history
…id-text

Implement Android SR for ReactTextView and ReactEditText
  • Loading branch information
jonathanmos authored Dec 21, 2023
2 parents 6b321db + 7cc8400 commit 13c2ee0
Show file tree
Hide file tree
Showing 25 changed files with 1,489 additions and 96 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionReplayPrivacy, Map<Class<*>, WireframeMapper<View, *>>> {
return mapOf(SessionReplayPrivacy.ALLOW to mapOf(
ReactViewGroup::class.java to ReactViewGroupMapper() as WireframeMapper<View, *>
))
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<View, *>
}
)
}

override fun getOptionSelectorDetectors(): List<OptionSelectorDetector> {
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"
}
}
Original file line number Diff line number Diff line change
@@ -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<MobileSegment.TextStyle, MobileSegment.TextPosition>? {
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<MobileSegment.ShapeStyle?, MobileSegment.ShapeBorder?>? {
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"
}
}
Loading

0 comments on commit 13c2ee0

Please sign in to comment.