Skip to content

Commit

Permalink
Android: added ReactImageViewMapper for SR Image Recording
Browse files Browse the repository at this point in the history
  • Loading branch information
marco-saia-datadog committed Dec 18, 2024
1 parent 53dca44 commit f604781
Show file tree
Hide file tree
Showing 12 changed files with 557 additions and 18 deletions.
8 changes: 4 additions & 4 deletions packages/core/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,10 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
compileOnly "com.squareup.okhttp3:okhttp:3.12.13"

implementation "com.datadoghq:dd-sdk-android-rum:2.14.0"
implementation "com.datadoghq:dd-sdk-android-logs:2.14.0"
implementation "com.datadoghq:dd-sdk-android-trace:2.14.0"
implementation "com.datadoghq:dd-sdk-android-webview:2.14.0"
implementation "com.datadoghq:dd-sdk-android-rum:2.16.1"
implementation "com.datadoghq:dd-sdk-android-logs:2.16.1"
implementation "com.datadoghq:dd-sdk-android-trace:2.16.1"
implementation "com.datadoghq:dd-sdk-android-webview:2.16.1"
implementation "com.google.code.gson:gson:2.10.0"
testImplementation "org.junit.platform:junit-platform-launcher:1.6.2"
testImplementation "org.junit.jupiter:junit-jupiter-api:5.6.2"
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native-session-replay/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ dependencies {
api "com.facebook.react:react-android:$reactNativeVersion"
}
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "com.datadoghq:dd-sdk-android-session-replay:2.14.0"
implementation "com.datadoghq:dd-sdk-android-session-replay:2.16.1"
implementation project(path: ':datadog_mobile-react-native')

testImplementation "org.junit.platform:junit-platform-launcher:1.6.2"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ import com.datadog.android.api.InternalLogger
import com.datadog.android.sessionreplay.ExtensionSupport
import com.datadog.android.sessionreplay.MapperTypeWrapper
import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector
import com.datadog.android.sessionreplay.utils.DrawableToColorMapper
import com.datadog.reactnative.sessionreplay.mappers.ReactEditTextMapper
import com.datadog.reactnative.sessionreplay.mappers.ReactNativeImageViewMapper
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.image.ReactImageView
import com.facebook.react.views.text.ReactTextView
import com.facebook.react.views.textinput.ReactEditText
import com.facebook.react.views.view.ReactViewGroup
Expand All @@ -24,21 +27,21 @@ internal class ReactNativeSessionReplayExtensionSupport(
private val reactContext: ReactContext,
private val logger: InternalLogger
) : ExtensionSupport {
override fun name(): String {
return ReactNativeSessionReplayExtensionSupport::class.java.simpleName
}

override fun getCustomViewMappers(): List<MapperTypeWrapper<*>> {
val uiManagerModule = getUiManagerModule()

return listOf(
MapperTypeWrapper(ReactImageView::class.java, ReactNativeImageViewMapper()),
MapperTypeWrapper(ReactViewGroup::class.java, ReactViewGroupMapper()),
MapperTypeWrapper(ReactTextView::class.java, ReactTextMapper(reactContext, uiManagerModule)),
MapperTypeWrapper(ReactEditText::class.java, ReactEditTextMapper(reactContext, uiManagerModule)),
)
}

override fun getOptionSelectorDetectors(): List<OptionSelectorDetector> {
return listOf()
}

@VisibleForTesting
internal fun getUiManagerModule(): UIManagerModule? {
return try {
Expand All @@ -54,6 +57,14 @@ internal class ReactNativeSessionReplayExtensionSupport(
}
}

override fun getOptionSelectorDetectors(): List<OptionSelectorDetector> {
return listOf()
}

override fun getCustomDrawableMapper(): List<DrawableToColorMapper> {
return emptyList()
}

internal companion object {
internal const val RESOLVE_UIMANAGERMODULE_ERROR = "Unable to resolve UIManagerModule"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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.extensions.densityNormalized
import com.datadog.reactnative.sessionreplay.utils.DrawableUtils
import com.datadog.reactnative.sessionreplay.utils.ReflectionUtils
import com.datadog.reactnative.sessionreplay.utils.formatAsRgba
Expand Down Expand Up @@ -134,7 +134,7 @@ internal class ReactTextPropertiesResolver(
val fontFamily = getFontFamily(shadowNodeWrapper)
?: textWireframe.textStyle.family
val fontSize = getFontSize(shadowNodeWrapper)
?.convertToDensityNormalized(pixelsDensity)
?.densityNormalized(pixelsDensity)
?: textWireframe.textStyle.size
val fontColor = getTextColor(shadowNodeWrapper)
?: textWireframe.textStyle.color
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* 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 {
if (density == 0f) {
return this
}
return (this / density).toInt()
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

package com.datadog.reactnative.sessionreplay.extensions

internal fun Long.convertToDensityNormalized(density: Float): Long {
internal fun Long.densityNormalized(density: Float): Long {
return if (density == 0f) {
this
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* 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

import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.VectorDrawable
import android.widget.ImageView
import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
import androidx.core.graphics.drawable.toBitmapOrNull
import com.facebook.drawee.drawable.ArrayDrawable
import com.facebook.drawee.drawable.ForwardingDrawable
import com.facebook.drawee.drawable.RoundedBitmapDrawable
import com.facebook.drawee.drawable.ScaleTypeDrawable
import com.facebook.drawee.drawable.ScalingUtils

internal fun ScaleTypeDrawable.imageViewScaleType(): ImageView.ScaleType? {
return when (this.scaleType) {
ScalingUtils.ScaleType.CENTER -> ImageView.ScaleType.CENTER
ScalingUtils.ScaleType.CENTER_CROP -> ImageView.ScaleType.CENTER_CROP
ScalingUtils.ScaleType.CENTER_INSIDE -> ImageView.ScaleType.CENTER_INSIDE
ScalingUtils.ScaleType.FIT_CENTER -> ImageView.ScaleType.FIT_CENTER
ScalingUtils.ScaleType.FIT_START -> ImageView.ScaleType.FIT_START
ScalingUtils.ScaleType.FIT_END -> ImageView.ScaleType.FIT_END
ScalingUtils.ScaleType.FIT_XY -> ImageView.ScaleType.FIT_XY
else -> null
}
}

internal fun ArrayDrawable.getScaleTypeDrawable(): ScaleTypeDrawable? {
for (i in 0 until this.numberOfLayers) {
try {
(this.getDrawable(i) as? ScaleTypeDrawable)?.let {
return it
}
} catch(_: IllegalArgumentException) { }
}

return null
}

internal fun ArrayDrawable.getDrawableOrNull(index: Int): Drawable? {
return try {
this.getDrawable(index)
} catch (_: IllegalArgumentException) {
null
}
}

internal fun ForwardingDrawable.tryToExtractBitmap(resources: Resources): Bitmap? {
val forwardedDrawable = this.drawable
return if (forwardedDrawable != null) {
forwardedDrawable.tryToExtractBitmap(resources)
} else {
this.toBitmapOrNull(
this.intrinsicWidth,
this.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
}
}

internal fun RoundedBitmapDrawable.tryToExtractBitmap(): Bitmap? {
val privateBitmap = try {
val field = RoundedBitmapDrawable::class.java.getDeclaredField("mBitmap")
field.isAccessible = true
field.get(this) as? Bitmap
} catch (_: NoSuchFieldException) {
null
} catch (_: IllegalAccessException) {
null
} catch (_: Exception) {
null
}

return privateBitmap ?: this.toBitmapOrNull(
this.intrinsicWidth,
this.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
}

internal fun BitmapDrawable.tryToExtractBitmap(resources: Resources): Bitmap? {
if (this.bitmap != null) {
return this.bitmap
}

if (this.constantState != null) {
val copy = this.constantState?.newDrawable(resources)
return (copy as? BitmapDrawable)?.bitmap ?: copy?.toBitmapOrNull(
this.intrinsicWidth,
this.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
}

return null
}

internal fun ArrayDrawable.tryToExtractBitmap(resources: Resources): Bitmap? {
var width = 0
var height = 0
for (index in 0 until this.numberOfLayers) {
val drawable = this.getDrawableOrNull(index) ?: continue

if (drawable is ScaleTypeDrawable) {
return drawable.tryToExtractBitmap(resources)
}

if (drawable.intrinsicWidth * drawable.intrinsicHeight > width * height) {
width = drawable.intrinsicWidth
height = drawable.intrinsicHeight
}
}

return if (width > 0 && height > 0)
this.toBitmapOrNull(width, height, Bitmap.Config.ARGB_8888)
else
null
}

internal fun Drawable.tryToExtractBitmap(
resources: Resources
): Bitmap? {
when (this) {
is ArrayDrawable -> {
return this.tryToExtractBitmap(resources)
}
is ForwardingDrawable -> {
return this.tryToExtractBitmap(resources)
}
is RoundedBitmapDrawable -> {
return this.tryToExtractBitmap()
}
is BitmapDrawable -> {
return this.tryToExtractBitmap(resources)
}
is VectorDrawable, is ShapeDrawable, is DrawerArrowDrawable -> {
return this.toBitmapOrNull(
this.intrinsicWidth,
this.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
}
else -> return null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* 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.api.InternalLogger
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.recorder.MappingContext
import com.datadog.android.sessionreplay.recorder.mapper.BaseAsyncBackgroundWireframeMapper
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.DefaultColorStringFormatter
import com.datadog.android.sessionreplay.utils.DefaultViewBoundsResolver
import com.datadog.android.sessionreplay.utils.DefaultViewIdentifierResolver
import com.datadog.android.sessionreplay.utils.DrawableToColorMapper
import com.datadog.reactnative.sessionreplay.extensions.densityNormalized
import com.datadog.reactnative.sessionreplay.extensions.getScaleTypeDrawable
import com.datadog.reactnative.sessionreplay.extensions.imageViewScaleType
import com.datadog.reactnative.sessionreplay.resources.ReactDrawableCopier
import com.datadog.reactnative.sessionreplay.utils.ImageViewUtils
import com.facebook.drawee.drawable.FadeDrawable
import com.facebook.react.views.image.ReactImageView

internal class ReactNativeImageViewMapper: BaseAsyncBackgroundWireframeMapper<ReactImageView>(
viewIdentifierResolver = DefaultViewIdentifierResolver,
colorStringFormatter = DefaultColorStringFormatter,
viewBoundsResolver = DefaultViewBoundsResolver,
drawableToColorMapper = DrawableToColorMapper.getDefault()
) {
private val drawableCopier = ReactDrawableCopier()

override fun map(
view: ReactImageView,
mappingContext: MappingContext,
asyncJobStatusCallback: AsyncJobStatusCallback,
internalLogger: InternalLogger
): List<MobileSegment.Wireframe> {
val wireframes = mutableListOf<MobileSegment.Wireframe>()
wireframes.addAll(super.map(view, mappingContext, asyncJobStatusCallback, internalLogger))

val drawable = view.drawable?.current ?: return wireframes

val parentRect = ImageViewUtils.resolveParentRectAbsPosition(view)
val scaleType = (drawable as? FadeDrawable)
?.getScaleTypeDrawable()
?.imageViewScaleType() ?: view.scaleType
val contentRect = ImageViewUtils.resolveContentRectWithScaling(view, drawable, scaleType)

val resources = view.resources
val density = resources.displayMetrics.density

val clipping = if (view.cropToPadding) {
ImageViewUtils.calculateClipping(parentRect, contentRect, density)
} else {
null
}

val contentXPosInDp = contentRect.left.densityNormalized(density).toLong()
val contentYPosInDp = contentRect.top.densityNormalized(density).toLong()
val contentWidthPx = contentRect.width()
val contentHeightPx = contentRect.height()

// resolve foreground
mappingContext.imageWireframeHelper.createImageWireframeByDrawable(
view = view,
imagePrivacy = mappingContext.imagePrivacy,
currentWireframeIndex = wireframes.size,
x = contentXPosInDp,
y = contentYPosInDp,
width = contentWidthPx,
height = contentHeightPx,
usePIIPlaceholder = true,
drawable = drawable,
drawableCopier = drawableCopier,
asyncJobStatusCallback = asyncJobStatusCallback,
clipping = clipping,
shapeStyle = null,
border = null,
prefix = "drawable",
customResourceIdCacheKey = generateUUID(view)
)?.let {
wireframes.add(it)
}

return wireframes
}

private fun generateUUID(reactImageView: ReactImageView): String {
val source = reactImageView.imageSource?.source ?:
System.identityHashCode(reactImageView).toString()
val drawableType = reactImageView.drawable.current::class.java.name
return "${drawableType}-${source}"
}
}

Loading

0 comments on commit f604781

Please sign in to comment.