Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Android SR for ReactTextView and ReactEditText #569

Merged
merged 1 commit into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
louiszawadzki marked this conversation as resolved.
Show resolved Hide resolved
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()
0xnm marked this conversation as resolved.
Show resolved Hide resolved

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
}

jonathanmos marked this conversation as resolved.
Show resolved Hide resolved
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"
Comment on lines +187 to +189
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if RN app is using ProGuard, then all this will be obfuscated and will have a different name. Do we account for that? Maybe it is worth to ship proguard rules then with our library?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProGuard obfuscation is not enabled by default on RN projects, so I think we can find projects without it for private beta.
Still I believe big companies will have it enabled, so we should have it supported before public beta.

In any case I think it's worth making a separate PR/JIRA for this, what do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally. We can ship custom ProGuard rules with our package, which will prevent classes/properties we need from obfuscation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've opened a Jira for this


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