Skip to content
Draft
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
1 change: 1 addition & 0 deletions detekt_custom_safe_calls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,7 @@ datadog:
- "kotlin.Pair.constructor(com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext, com.google.gson.JsonArray)"
- "kotlin.Pair.constructor(com.datadog.android.sessionreplay.model.MobileSegment, com.google.gson.JsonObject)"
- "kotlin.Pair.constructor(com.google.gson.JsonObject, kotlin.Long)"
- "kotlin.Pair.constructor(kotlin.Float, kotlin.Float)"
- "kotlin.Pair.constructor(kotlin.Int, kotlin.Int)"
- "kotlin.Pair.constructor(kotlin.Long, kotlin.Long)"
- "kotlin.Triple.constructor(kotlin.String?, kotlin.String?, kotlin.String?)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@
package com.datadog.android.sessionreplay.compose.internal.data

import android.graphics.Bitmap
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale

internal data class BitmapInfo(
val bitmap: Bitmap,
val isContextualImage: Boolean
val isContextualImage: Boolean,
val contentScale: ContentScale? = null,
val alignment: Alignment? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,53 @@

package com.datadog.android.sessionreplay.compose.internal.mappers.semantics

import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.SemanticsNode
import com.datadog.android.sessionreplay.compose.internal.data.BitmapInfo
import com.datadog.android.sessionreplay.compose.internal.data.SemanticsWireframe
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
import com.datadog.android.sessionreplay.utils.GlobalBounds

internal class ImageSemanticsNodeMapper(
colorStringFormatter: ColorStringFormatter,
private val semanticsUtils: SemanticsUtils
) : AbstractSemanticsNodeMapper(colorStringFormatter) {
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {

override fun map(
semanticsNode: SemanticsNode,
parentContext: UiContext,
asyncJobStatusCallback: AsyncJobStatusCallback
): SemanticsWireframe {
val bounds = resolveBounds(semanticsNode)
val rawBitmapInfo = semanticsUtils.resolveSemanticsPainter(semanticsNode)
val containerBounds = resolveBounds(semanticsNode)
val bitmapInfo = semanticsUtils.resolveSemanticsPainter(semanticsNode)
val containerFrames = resolveModifierWireframes(semanticsNode).toMutableList()
val imagePrivacy =
semanticsUtils.getImagePrivacyOverride(semanticsNode) ?: parentContext.imagePrivacy

val imageWireframe = rawBitmapInfo?.bitmap?.let { bitmap ->
val imageWireframe = if (bitmapInfo != null) {
val scaledImageInfo = calculateScaledImageBounds(
containerBounds = containerBounds,
bitmapInfo = bitmapInfo,
density = parentContext.density
)
parentContext.imageWireframeHelper.createImageWireframeByBitmap(
id = semanticsNode.id.toLong(),
globalBounds = bounds,
bitmap = bitmap,
globalBounds = scaledImageInfo.bounds,
bitmap = bitmapInfo.bitmap,
density = parentContext.density,
isContextualImage = rawBitmapInfo.isContextualImage,
isContextualImage = bitmapInfo.isContextualImage,
imagePrivacy = imagePrivacy,
asyncJobStatusCallback = asyncJobStatusCallback,
clipping = null,
clipping = scaledImageInfo.clipping,
shapeStyle = null,
border = null
)
} else {
null
}

imageWireframe?.let {
Expand All @@ -52,4 +63,201 @@ internal class ImageSemanticsNodeMapper(
uiContext = null
)
}

internal fun calculateScaledImageBounds(
containerBounds: GlobalBounds,
bitmapInfo: BitmapInfo,
density: Float
): ScaledImageInfo {
val contentScale = bitmapInfo.contentScale
val alignment = bitmapInfo.alignment ?: Alignment.Center

val bitmapWidthDp = bitmapInfo.bitmap.width / density
val bitmapHeightDp = bitmapInfo.bitmap.height / density
val containerWidthDp = containerBounds.width.toFloat()
val containerHeightDp = containerBounds.height.toFloat()

if (hasInvalidDimensions(bitmapWidthDp, bitmapHeightDp, containerWidthDp, containerHeightDp)) {
return ScaledImageInfo(bounds = containerBounds, clipping = null)
}

val scaledSize = calculateScaledSize(
contentScale = contentScale,
bitmapWidthDp = bitmapWidthDp,
bitmapHeightDp = bitmapHeightDp,
containerWidthDp = containerWidthDp,
containerHeightDp = containerHeightDp
)

val (offsetX, offsetY) = calculateAlignmentOffset(
alignment = alignment,
scaledWidth = scaledSize.width,
scaledHeight = scaledSize.height,
containerWidth = containerWidthDp,
containerHeight = containerHeightDp
)

val imageBounds = GlobalBounds(
x = containerBounds.x + offsetX.toLong(),
y = containerBounds.y + offsetY.toLong(),
width = scaledSize.width.toLong(),
height = scaledSize.height.toLong()
)

val clipping = calculateClipping(
containerBounds = containerBounds,
imageBounds = imageBounds
)

return ScaledImageInfo(bounds = imageBounds, clipping = clipping)
}

private fun calculateScaledSize(
contentScale: ContentScale?,
bitmapWidthDp: Float,
bitmapHeightDp: Float,
containerWidthDp: Float,
containerHeightDp: Float
): ScaledSize {
val scaleX = containerWidthDp / bitmapWidthDp
val scaleY = containerHeightDp / bitmapHeightDp

return when (contentScale) {
ContentScale.Crop -> {
val scaleFactor = maxOf(scaleX, scaleY)
ScaledSize(
width = bitmapWidthDp * scaleFactor,
height = bitmapHeightDp * scaleFactor
)
}
ContentScale.Fit -> {
val scaleFactor = minOf(scaleX, scaleY)
ScaledSize(
width = bitmapWidthDp * scaleFactor,
height = bitmapHeightDp * scaleFactor
)
}
ContentScale.FillHeight -> {
ScaledSize(
width = bitmapWidthDp * scaleY,
height = containerHeightDp
)
}
ContentScale.FillWidth -> {
ScaledSize(
width = containerWidthDp,
height = bitmapHeightDp * scaleX
)
}
ContentScale.Inside -> {
if (bitmapWidthDp <= containerWidthDp && bitmapHeightDp <= containerHeightDp) {
ScaledSize(width = bitmapWidthDp, height = bitmapHeightDp)
} else {
val scaleFactor = minOf(scaleX, scaleY)
ScaledSize(
width = bitmapWidthDp * scaleFactor,
height = bitmapHeightDp * scaleFactor
)
}
}
ContentScale.None -> {
ScaledSize(width = bitmapWidthDp, height = bitmapHeightDp)
}
ContentScale.FillBounds -> {
ScaledSize(width = containerWidthDp, height = containerHeightDp)
}
else -> {
val scaleFactor = minOf(scaleX, scaleY)
ScaledSize(
width = bitmapWidthDp * scaleFactor,
height = bitmapHeightDp * scaleFactor
)
}
}
}

private fun calculateAlignmentOffset(
alignment: Alignment,
scaledWidth: Float,
scaledHeight: Float,
containerWidth: Float,
containerHeight: Float
): Pair<Float, Float> {
val horizontalSpace = containerWidth - scaledWidth
val verticalSpace = containerHeight - scaledHeight

val offsetX = when (alignment) {
Alignment.TopStart, Alignment.CenterStart, Alignment.BottomStart -> 0f
Alignment.TopCenter, Alignment.Center, Alignment.BottomCenter -> horizontalSpace / 2f
Alignment.TopEnd, Alignment.CenterEnd, Alignment.BottomEnd -> horizontalSpace
else -> horizontalSpace / 2f
}

val offsetY = when (alignment) {
Alignment.TopStart, Alignment.TopCenter, Alignment.TopEnd -> 0f
Alignment.CenterStart, Alignment.Center, Alignment.CenterEnd -> verticalSpace / 2f
Alignment.BottomStart, Alignment.BottomCenter, Alignment.BottomEnd -> verticalSpace
else -> verticalSpace / 2f
}

return Pair(offsetX, offsetY)
}

private fun calculateClipping(
containerBounds: GlobalBounds,
imageBounds: GlobalBounds
): MobileSegment.WireframeClip? {
val left = if (imageBounds.x < containerBounds.x) {
containerBounds.x - imageBounds.x
} else {
0L
}
val top = if (imageBounds.y < containerBounds.y) {
containerBounds.y - imageBounds.y
} else {
0L
}
val right = if (imageBounds.x + imageBounds.width > containerBounds.x + containerBounds.width) {
(imageBounds.x + imageBounds.width) - (containerBounds.x + containerBounds.width)
} else {
0L
}
val bottom = if (imageBounds.y + imageBounds.height > containerBounds.y + containerBounds.height) {
(imageBounds.y + imageBounds.height) - (containerBounds.y + containerBounds.height)
} else {
0L
}

val needsClipping = hasClipping(left, top, right, bottom)
return if (needsClipping) {
MobileSegment.WireframeClip(
left = left,
top = top,
right = right,
bottom = bottom
)
} else {
null
}
}

private fun hasInvalidDimensions(
bitmapWidth: Float,
bitmapHeight: Float,
containerWidth: Float,
containerHeight: Float
): Boolean {
return bitmapWidth <= 0 || bitmapHeight <= 0 || containerWidth <= 0 || containerHeight <= 0
}

private fun hasClipping(left: Long, top: Long, right: Long, bottom: Long): Boolean {
return left > 0 || top > 0 || right > 0 || bottom > 0
}

internal data class ScaledSize(val width: Float, val height: Float)

internal data class ScaledImageInfo(
val bounds: GlobalBounds,
val clipping: MobileSegment.WireframeClip?
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ internal object ComposeReflection {

val PainterElementClass = getClassSafe("androidx.compose.ui.draw.PainterElement")
val PainterField = PainterElementClass?.getDeclaredFieldSafe("painter")
val ContentScaleField = PainterElementClass?.getDeclaredFieldSafe("contentScale")
val AlignmentField = PainterElementClass?.getDeclaredFieldSafe("alignment")

val VectorPainterClass = getClassSafe("androidx.compose.ui.graphics.vector.VectorPainter")
val VectorField = VectorPainterClass?.getDeclaredFieldSafe("vector")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import android.text.StaticLayout
import android.view.View
import androidx.compose.animation.core.AnimationState
import androidx.compose.runtime.Composition
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorProducer
Expand All @@ -19,17 +20,20 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.VectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.text.MultiParagraph
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.AlignmentField
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.AsyncImagePainterClass
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.BitmapField
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.ChildFieldOfModifierNode
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.CompositionField
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.ContentPainterElementClass
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.ContentPainterModifierClass
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.ContentScaleField
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.GetInnerLayerCoordinatorMethod
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.GetInteropViewMethod
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.HeadFieldOfNodeChain
Expand Down Expand Up @@ -184,6 +188,20 @@ internal class ReflectionUtils {
return modifier?.let { PainterField?.getSafe(it) as? Painter }
}

fun getContentScale(semanticsNode: SemanticsNode): ContentScale? {
val modifier = semanticsNode.layoutInfo.getModifierInfo().firstOrNull {
PainterElementClass?.isInstance(it.modifier) == true
}?.modifier
return modifier?.let { ContentScaleField?.getSafe(it) as? ContentScale }
}

fun getAlignment(semanticsNode: SemanticsNode): Alignment? {
val modifier = semanticsNode.layoutInfo.getModifierInfo().firstOrNull {
PainterElementClass?.isInstance(it.modifier) == true
}?.modifier
return modifier?.let { AlignmentField?.getSafe(it) as? Alignment }
}

fun getAsyncImagePainter(semanticsNode: SemanticsNode): Painter? {
// Check if Coil AsyncImagePainter is present first to optimize the performance
// by skipping the modifier iteration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package com.datadog.android.sessionreplay.compose.internal.utils

import android.graphics.Bitmap
import android.os.Build
import android.view.View
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -262,8 +263,29 @@ internal class SemanticsUtils(
sendBitmapInfoTelemetry(it, isContextualImage)
}

return bitmap?.let {
BitmapInfo(it, isContextualImage)
val contentScale = reflectionUtils.getContentScale(semanticsNode)
val alignment = reflectionUtils.getAlignment(semanticsNode)

// Use raw bitmap without copying for:
// - HARDWARE: copying is slow and may violate StrictMode#noteSlowCall
// - ALPHA_8: cannot be copied to ARGB_8888 directly (copy returns null)
val skipCopy = bitmap?.config == Bitmap.Config.ALPHA_8 ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap?.config == Bitmap.Config.HARDWARE)

val finalBitmap = if (skipCopy) {
bitmap
} else {
@Suppress("UnsafeThirdPartyFunctionCall") // isMutable is always false
bitmap?.copy(Bitmap.Config.ARGB_8888, false)
}

return finalBitmap?.let {
BitmapInfo(
bitmap = it,
isContextualImage = isContextualImage,
contentScale = contentScale,
alignment = alignment
)
}
}

Expand Down
Loading
Loading