From a0e962da1ffa9bd7ea42cf3a99da4ce4c5054a5c Mon Sep 17 00:00:00 2001 From: luyi Date: Mon, 21 Oct 2024 16:23:48 +0200 Subject: [PATCH] wip --- .../consumer-rules.pro | 16 ++- .../semantics/AbstractSemanticsNodeMapper.kt | 37 +++-- .../semantics/ButtonSemanticsNodeMapper.kt | 36 +---- .../semantics/ImageSemanticsNodeMapper.kt | 23 ++- .../internal/reflection/ComposeReflection.kt | 59 +++++++- .../compose/internal/utils/BackgroundInfo.kt | 15 ++ .../compose/internal/utils/SemanticsUtils.kt | 132 +++++++++++++++--- .../AbstractSemanticsNodeMapperTest.kt | 24 ++-- .../ButtonSemanticsNodeMapperTest.kt | 41 +++--- .../test/elmyr/GlobalBoundsForgeryFactory.kt | 22 +++ .../SessionReplayComposeForgeConfigurator.kt | 1 + .../android/sample/compose/ImageSample.kt | 125 +++++++++++------ 12 files changed, 384 insertions(+), 147 deletions(-) create mode 100644 features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/BackgroundInfo.kt create mode 100644 features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/GlobalBoundsForgeryFactory.kt diff --git a/features/dd-sdk-android-session-replay-compose/consumer-rules.pro b/features/dd-sdk-android-session-replay-compose/consumer-rules.pro index 0c5f49e839..3925cd8e1d 100644 --- a/features/dd-sdk-android-session-replay-compose/consumer-rules.pro +++ b/features/dd-sdk-android-session-replay-compose/consumer-rules.pro @@ -7,7 +7,7 @@ -keepnames class androidx.compose.runtime.Composition -keepnames class androidx.compose.ui.platform.ComposeView -keepnames class androidx.compose.material.DefaultButtonColors --keepclassmembers class androidx.compose.ui.platform.WrappedComposition { +-keep class androidx.compose.ui.platform.WrappedComposition { ; } -keepclassmembers class androidx.compose.ui.platform.AbstractComposeView { @@ -22,8 +22,8 @@ -keepclassmembers class androidx.compose.foundation.BackgroundElement { ; } --keepclassmembers class androidx.compose.ui.node.LayoutNode { - ; +-keep class androidx.compose.ui.node.LayoutNode { + *; } -keepclassmembers class androidx.compose.ui.draw.PainterElement { ; @@ -43,10 +43,16 @@ -keepclassmembers class androidx.compose.ui.graphics.AndroidImageBitmap { ; } --keepclassmembers class coil.compose.ContentPainterModifier { +-keep class coil.compose.ContentPainterModifier { ; } --keepclassmembers class coil.compose.AsyncImagePainter { +-keep class coil.compose.AsyncImagePainter { ; } +-keepclassmembers class androidx.compose.foundation.layout.PaddingElement{ + ; +} +-keepclassmembers class "androidx.compose.ui.graphics.GraphicsLayerElement"{ + ; +} diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt index 41f03c99f4..72fa527554 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt @@ -9,22 +9,43 @@ package com.datadog.android.sessionreplay.compose.internal.mappers.semantics import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.semantics.SemanticsNode +import com.datadog.android.sessionreplay.compose.internal.utils.BackgroundInfo +import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils +import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.ColorStringFormatter import com.datadog.android.sessionreplay.utils.GlobalBounds import kotlin.math.roundToInt internal abstract class AbstractSemanticsNodeMapper( - private val colorStringFormatter: ColorStringFormatter + private val colorStringFormatter: ColorStringFormatter, + private val semanticsUtils: SemanticsUtils = SemanticsUtils() ) : SemanticsNodeMapper { protected fun resolveBounds(semanticsNode: SemanticsNode): GlobalBounds { - val rect = semanticsNode.boundsInRoot - val density = semanticsNode.layoutInfo.density.density - val width = ((rect.right - rect.left) / density).toLong() - val height = ((rect.bottom - rect.top) / density).toLong() - val x = (rect.left / density).toLong() - val y = (rect.top / density).toLong() - return GlobalBounds(x, y, width, height) + return semanticsUtils.resolveInnerBounds(semanticsNode) + } + + protected fun resolveModifierWireframes(semanticsNode: SemanticsNode): List { + return semanticsUtils.resolveBackgroundInfo(semanticsNode).map { + convertBackgroundInfoToWireframes(backgroundInfo = it) + } + } + + private fun convertBackgroundInfoToWireframes( + backgroundInfo: BackgroundInfo + ): MobileSegment.Wireframe { + val shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = backgroundInfo.color?.let { convertColor(it) }, + cornerRadius = backgroundInfo.cornerRadius + ) + return MobileSegment.Wireframe.ShapeWireframe( + id = semanticsUtils.resolveBackgroundInfoId(backgroundInfo), + x = backgroundInfo.globalBounds.x, + y = backgroundInfo.globalBounds.y, + width = backgroundInfo.globalBounds.width, + height = backgroundInfo.globalBounds.height, + shapeStyle = shapeStyle + ) } protected fun convertColor(color: Long): String? { diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt index 7f915e5244..9a122910ce 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt @@ -7,53 +7,25 @@ package com.datadog.android.sessionreplay.compose.internal.mappers.semantics import androidx.compose.ui.semantics.SemanticsNode -import androidx.compose.ui.unit.Density 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 ButtonSemanticsNodeMapper( colorStringFormatter: ColorStringFormatter, - private val semanticsUtils: SemanticsUtils = SemanticsUtils() -) : AbstractSemanticsNodeMapper(colorStringFormatter) { + semanticsUtils: SemanticsUtils, +) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) { override fun map( semanticsNode: SemanticsNode, parentContext: UiContext, asyncJobStatusCallback: AsyncJobStatusCallback ): SemanticsWireframe { - val density = semanticsNode.layoutInfo.density - val bounds = resolveBounds(semanticsNode) - val buttonStyle = resolveSemanticsButtonStyle(semanticsNode, bounds, density) return SemanticsWireframe( - wireframes = MobileSegment.Wireframe.ShapeWireframe( - id = semanticsNode.id.toLong(), - x = bounds.x, - y = bounds.y, - width = bounds.width, - height = bounds.height, - shapeStyle = buttonStyle - ).let { listOf(it) }, - uiContext = parentContext.copy( - parentContentColor = buttonStyle.backgroundColor ?: parentContext.parentContentColor - ) - ) - } - - private fun resolveSemanticsButtonStyle( - semanticsNode: SemanticsNode, - globalBounds: GlobalBounds, - density: Density - ): MobileSegment.ShapeStyle { - val color = semanticsUtils.resolveSemanticsModifierColor(semanticsNode) - val cornerRadius = semanticsUtils.resolveSemanticsModifierCornerRadius(semanticsNode, globalBounds, density) - return MobileSegment.ShapeStyle( - backgroundColor = color?.let { convertColor(it) }, - cornerRadius = cornerRadius + wireframes = resolveModifierWireframes(semanticsNode), + uiContext = parentContext ) } } diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ImageSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ImageSemanticsNodeMapper.kt index 8023ce5ab1..06449d5b72 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ImageSemanticsNodeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ImageSemanticsNodeMapper.kt @@ -37,6 +37,7 @@ internal class ImageSemanticsNodeMapper( ): SemanticsWireframe { val bounds = resolveBounds(semanticsNode) val bitmapInfo = resolveSemanticsPainter(semanticsNode) + val containerFrames = resolveModifierWireframes(semanticsNode).toMutableList() val imageWireframe = if (bitmapInfo != null) { parentContext.imageWireframeHelper.createImageWireframeByBitmap( id = semanticsNode.id.toLong(), @@ -54,8 +55,11 @@ internal class ImageSemanticsNodeMapper( } else { null } + imageWireframe?.let { + containerFrames.add(it) + } return SemanticsWireframe( - wireframes = listOfNotNull(imageWireframe), + wireframes = containerFrames, uiContext = null ) } @@ -72,6 +76,10 @@ internal class ImageSemanticsNodeMapper( } } // TODO RUM-6535: support more painters. + if (ComposeReflection.AsyncImagePainterClass?.isInstance(painter) == true) { + isContextualImage = true + painter = PainterFieldOfAsyncImagePainter?.getSafe(painter) as? Painter + } val bitmap = when (painter) { is BitmapPainter -> tryParseBitmapPainterToBitmap(painter) is VectorPainter -> tryParseVectorPainterToBitmap(painter) @@ -82,7 +90,7 @@ internal class ImageSemanticsNodeMapper( val newBitmap = bitmap?.let { @Suppress("UnsafeThirdPartyFunctionCall") // isMutable is always false - it.copy(it.config, false) + it.copy(Bitmap.Config.ARGB_8888, false) } return newBitmap?.let { BitmapInfo(it, isContextualImage) @@ -93,19 +101,24 @@ internal class ImageSemanticsNodeMapper( val vector = ComposeReflection.VectorField?.getSafe(vectorPainter) val cacheDrawScope = ComposeReflection.CacheDrawScopeField?.getSafe(vector) val mCachedImage = ComposeReflection.CachedImageField?.getSafe(cacheDrawScope) - return BitmapField?.getSafe(mCachedImage) as? Bitmap + return mCachedImage?.let { + BitmapField?.getSafe(it) as? Bitmap + } + } private fun tryParseBitmapPainterToBitmap(bitmapPainter: BitmapPainter): Bitmap? { val image = ImageField?.getSafe(bitmapPainter) - return BitmapField?.getSafe(image) as? Bitmap + return image?.let { + BitmapField?.getSafe(image) as? Bitmap + } } private fun tryParseLocalImagePainter(semanticsNode: SemanticsNode): Painter? { val modifier = semanticsNode.layoutInfo.getModifierInfo().firstOrNull { PainterElementClass?.isInstance(it.modifier) == true }?.modifier - return PainterField?.getSafe(modifier) as? Painter + return modifier?.let { PainterField?.getSafe(it) as? Painter } } private fun tryParseAsyncImagePainter(semanticsNode: SemanticsNode): Painter? { diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt index 77efe4a434..52a485385a 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt @@ -10,6 +10,7 @@ import com.datadog.android.Datadog import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore import java.lang.reflect.Field +import java.lang.reflect.Method internal object ComposeReflection { val WrappedCompositionClass = getClassSafe("androidx.compose.ui.platform.WrappedComposition") @@ -36,7 +37,9 @@ internal object ComposeReflection { val OwnerField = WrappedCompositionClass?.getDeclaredFieldSafe("owner") val LayoutNodeClass = getClassSafe("androidx.compose.ui.node.LayoutNode") - val LayoutNodeOwnerField = LayoutNodeClass?.getDeclaredFieldSafe("owner") + + val GetInnerLayerCoordinatorMethod = LayoutNodeClass?.getDeclaredMethodSafe("getInnerLayerCoordinator") + val AndroidComposeViewClass = getClassSafe("androidx.compose.ui.platform.AndroidComposeView") val SemanticsOwner = AndroidComposeViewClass?.getDeclaredFieldSafe("semanticsOwner") @@ -45,7 +48,15 @@ internal object ComposeReflection { val BackgroundElementClass = getClassSafe("androidx.compose.foundation.BackgroundElement") val ColorField = BackgroundElementClass?.getDeclaredFieldSafe("color") - val ShapeField = BackgroundElementClass?.getDeclaredFieldSafe("shape") + + val PaddingElementClass = getClassSafe("androidx.compose.foundation.layout.PaddingElement") + val StartField = PaddingElementClass?.getDeclaredFieldSafe("start") + val EndField = PaddingElementClass?.getDeclaredFieldSafe("end") + val BottomField = PaddingElementClass?.getDeclaredFieldSafe("bottom") + val TopField = PaddingElementClass?.getDeclaredFieldSafe("top") + + val GraphicsLayerElementClass = getClassSafe("androidx.compose.ui.graphics.GraphicsLayerElement") + val ClipShapeField = GraphicsLayerElementClass?.getDeclaredFieldSafe("shape") val PainterElementClass = getClassSafe("androidx.compose.ui.draw.PainterElement") val PainterField = PainterElementClass?.getDeclaredFieldSafe("painter") @@ -77,6 +88,11 @@ internal fun Field.accessible(): Field { return this } +internal fun Method.accessible(): Method { + isAccessible = true + return this +} + @Suppress("TooGenericExceptionCaught") internal fun Field.getSafe(target: Any?): Any? { return try { @@ -186,3 +202,42 @@ internal fun Class<*>.getDeclaredFieldSafe(fieldName: String): Field? { null } } + +@Suppress("TooGenericExceptionCaught") +internal fun Class<*>.getDeclaredMethodSafe(methodName: String): Method? { + return try { + getDeclaredMethod(methodName).accessible() + } catch (e: SecurityException) { + (Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + { + "Unable to get method $methodName through reflection" + }, + e + ) + null + } catch (e: NullPointerException) { + (Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + { + "Unable to get method $methodName through reflection, name is null" + }, + e + ) + null + } catch (e: NoSuchMethodException) { + (Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + { + "Unable to get field $methodName through reflection, " + + "either because of obfuscation or dependency version mismatch" + }, + e + ) + null + } +} + diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/BackgroundInfo.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/BackgroundInfo.kt new file mode 100644 index 0000000000..898e6a51fc --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/BackgroundInfo.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.android.sessionreplay.compose.internal.utils + +import com.datadog.android.sessionreplay.utils.GlobalBounds + +internal data class BackgroundInfo( + val globalBounds: GlobalBounds = GlobalBounds(0L, 0L, 0L, 0L), + val color: Long? = null, + val cornerRadius: Float = 0f +) diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt index 98f5dbc737..c766cf6fda 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt @@ -6,17 +6,19 @@ package com.datadog.android.sessionreplay.compose.internal.utils -import android.annotation.SuppressLint import android.view.View import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composition import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.unit.Density import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.CompositionField +import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.GetInnerLayerCoordinatorMethod import com.datadog.android.sessionreplay.compose.internal.reflection.getSafe import com.datadog.android.sessionreplay.utils.GlobalBounds @@ -35,32 +37,116 @@ internal class SemanticsUtils { return null } - internal fun resolveSemanticsModifierColor( - semanticsNode: SemanticsNode - ): Long? { - val modifier = resolveSemanticsModifier(semanticsNode) - return ComposeReflection.ColorField?.getSafe(modifier) as? Long + internal fun resolveOuterBounds(semanticsNode: SemanticsNode): GlobalBounds { + var currentBounds = resolveInnerBounds(semanticsNode) + semanticsNode.layoutInfo.getModifierInfo().filter { + (ComposeReflection.PaddingElementClass?.isInstance(it.modifier) == true) + }.forEach { + val top = ComposeReflection.TopField?.getSafe(it.modifier) as? Float ?: 0.0f + val start = ComposeReflection.StartField?.getSafe(it.modifier) as? Float ?: 0.0f + val end = ComposeReflection.EndField?.getSafe(it.modifier) as? Float ?: 0.0f + val bottom = ComposeReflection.BottomField?.getSafe(it.modifier) as? Float ?: 0.0f + currentBounds = GlobalBounds( + x = currentBounds.x - start.toLong(), + y = currentBounds.y - top.toLong(), + width = currentBounds.width + (end + start).toLong(), + height = currentBounds.height + (bottom + top).toLong(), + ) + } + return currentBounds } - internal fun resolveSemanticsModifierCornerRadius( - semanticsNode: SemanticsNode, - globalBounds: GlobalBounds, - density: Density - ): Float? { - val size = Size(globalBounds.width.toFloat(), globalBounds.height.toFloat()) - val modifier = resolveSemanticsModifier(semanticsNode) - val shape = ComposeReflection.ShapeField?.getSafe(modifier) as? RoundedCornerShape - return shape?.let { - // We only have a single value for corner radius, so we default to using the - // top left (i.e.: topStart) corner's value and apply it to all corners - it.topStart.toPx(size, density) / density.density + internal fun resolveInnerBounds(semanticsNode: SemanticsNode): GlobalBounds { + val offset = semanticsNode.positionInRoot + // Resolve the measured size. + val size = resolveInnerSize(semanticsNode) + val density = semanticsNode.layoutInfo.density.density + val width = (size.width / density).toLong() + val height = (size.height / density).toLong() + val x = (offset.x / density).toLong() + val y = (offset.y / density).toLong() + return GlobalBounds(x, y, width, height) + } + + private fun resolveInnerSize(semanticsNode: SemanticsNode): Size { + val innerLayerCoordinator = GetInnerLayerCoordinatorMethod?.invoke(semanticsNode.layoutInfo) + val placeable = innerLayerCoordinator as? Placeable + val height = placeable?.height ?: 0 + val width = placeable?.width ?: 0 + return Size(width = width.toFloat(), height = height.toFloat()) + } + + internal fun resolveBackgroundInfo(semanticsNode: SemanticsNode): List { + val backgroundInfoList = mutableListOf() + // CurrentBackgroundInfo is to store bounds, color and shape information in sequence of modifiers. + var currentBackgroundInfo = BackgroundInfo() + var currentBounds: GlobalBounds = resolveOuterBounds(semanticsNode) + // If the currentBounds is already invalid, return with the existing wireframes + if (!isGlobalBoundsValid(globalBounds = currentBounds)) { + return backgroundInfoList + } + val density = semanticsNode.layoutInfo.density + // Iterate all the modifiers in user calling sequence, when meet: + // -> clip(): calculate the corner radius and update `currentBackgroundInfo` + // -> padding(): shrink the bounds from the previous bounds and update `currentBackgroundInfo` + // -> background(): retrieve the color and use `currentBackgroundInfo` to generate wireframes, + // then reset `currentBackgroundInfo`. + semanticsNode.layoutInfo.getModifierInfo().forEach { modifierInfo -> + if (ComposeReflection.BackgroundElementClass?.isInstance(modifierInfo.modifier) == true) { + val color = ComposeReflection.ColorField?.getSafe(modifierInfo.modifier) as? Long + currentBackgroundInfo = currentBackgroundInfo.copy(globalBounds = currentBounds, color = color) + backgroundInfoList.add(currentBackgroundInfo) + currentBackgroundInfo = BackgroundInfo() + } else if (ComposeReflection.PaddingElementClass?.isInstance(modifierInfo.modifier) == true) { + currentBounds = shrinkInnerBounds(modifierInfo.modifier, currentBounds) + currentBackgroundInfo = currentBackgroundInfo.copy(globalBounds = currentBounds) + } else if (ComposeReflection.GraphicsLayerElementClass?.isInstance(modifierInfo.modifier) == true) { + val cornerRadius = resolveCornerRadius(modifierInfo.modifier, currentBounds, density) + currentBackgroundInfo = currentBackgroundInfo.copy(cornerRadius = cornerRadius) + } } + return backgroundInfoList } - @SuppressLint("ModifierFactoryExtensionFunction") - internal fun resolveSemanticsModifier(semanticsNode: SemanticsNode): Modifier? { - return semanticsNode.layoutInfo.getModifierInfo().firstOrNull { - ComposeReflection.BackgroundElementClass?.isInstance(it.modifier) == true - }?.modifier + internal fun resolveBackgroundInfoId(backgroundInfo: BackgroundInfo): Long { + return System.identityHashCode(backgroundInfo).toLong() + } + + private fun shrinkInnerBounds( + modifier: Modifier, + currentBounds: GlobalBounds + ): GlobalBounds { + val top = ComposeReflection.TopField?.getSafe(modifier) as? Float ?: 0.0f + val start = ComposeReflection.StartField?.getSafe(modifier) as? Float ?: 0.0f + val end = ComposeReflection.EndField?.getSafe(modifier) as? Float ?: 0.0f + val bottom = ComposeReflection.BottomField?.getSafe(modifier) as? Float ?: 0.0f + return GlobalBounds( + x = currentBounds.x + start.toLong(), + y = currentBounds.y + top.toLong(), + width = currentBounds.width - (end + start).toLong(), + height = currentBounds.height - (bottom + top).toLong(), + ) + } + + private fun isGlobalBoundsValid(globalBounds: GlobalBounds): Boolean { + return (globalBounds.width > 0 && globalBounds.height > 0) + } + + internal fun resolveCornerRadius(modifier: Modifier, currentBounds: GlobalBounds, density: Density): Float { + val shape = ComposeReflection.ClipShapeField?.getSafe(modifier) as? Shape + return shape?.let { shape -> + val size = Size( + currentBounds.width.toFloat() * density.density, + currentBounds.height.toFloat() * density.density, + ) + // We only have a single value for corner radius, so we default to using the + // top left (i.e.: topStart) corner's value and apply it to all corners + //it.topStart.toPx(size, density) / density.density + if (shape is RoundedCornerShape) { + shape.topStart.toPx(size, density) / density.density + } else { + 0f + } + } ?: 0f } } diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapperTest.kt index d7b294d34f..314b58e738 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapperTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapperTest.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.Density import com.datadog.android.sessionreplay.compose.internal.data.ComposeWireframe 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.compose.test.elmyr.SessionReplayComposeForgeConfigurator import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import com.datadog.android.sessionreplay.utils.ColorStringFormatter @@ -50,7 +51,7 @@ internal open class AbstractCompositionGroupMapperTest { private lateinit var testedMapper: StubAbstractSemanticsNodeMapper @Forgery - private lateinit var fakeWireframe: ComposeWireframe + private lateinit var fakeGlobalBounds: GlobalBounds @IntForgery var fakeSemanticsId: Int = 0 @@ -66,6 +67,9 @@ internal open class AbstractCompositionGroupMapperTest { @Mock lateinit var mockDensity: Density + @Mock + lateinit var mockSemanticsUtils: SemanticsUtils + @FloatForgery var fakeDensity = 0f @@ -77,23 +81,20 @@ internal open class AbstractCompositionGroupMapperTest { right = forge.aFloat(), bottom = forge.aFloat() ) - testedMapper = StubAbstractSemanticsNodeMapper(mockColorStringFormatter) + testedMapper = StubAbstractSemanticsNodeMapper(mockSemanticsUtils, mockColorStringFormatter) } @Test fun `M return correct bound W resolveBounds`() { // Given - testedMapper.mappedWireframe = fakeWireframe - val mockNode = mockSemanticsNodeWithBound() + val semanticsNode = mock() + whenever(mockSemanticsUtils.resolveInnerBounds(semanticsNode)) doReturn fakeGlobalBounds // When - val result = testedMapper.stubResolveBounds(mockNode) + val result = testedMapper.stubResolveBounds(semanticsNode) // Then - assertThat(result.x).isEqualTo((fakeBounds.left / fakeDensity).toLong()) - assertThat(result.y).isEqualTo((fakeBounds.top / fakeDensity).toLong()) - assertThat(result.height).isEqualTo((fakeBounds.size.height / fakeDensity).toLong()) - assertThat(result.width).isEqualTo((fakeBounds.size.width / fakeDensity).toLong()) + assertThat(result).isEqualTo(fakeGlobalBounds) } protected fun mockSemanticsNodeWithBound(additionalMock: SemanticsNode.() -> Unit = {}): SemanticsNode { @@ -118,7 +119,7 @@ internal open class AbstractCompositionGroupMapperTest { } private fun convertColorIntAlpha(color: Long): Pair { - val c = Color(color) + val c = Color(color shr 32) return Pair(c.toArgb(), (c.alpha * MAX_ALPHA).roundToInt()) } @@ -128,8 +129,9 @@ internal open class AbstractCompositionGroupMapperTest { } internal class StubAbstractSemanticsNodeMapper( + semanticsUtils: SemanticsUtils, colorStringFormatter: ColorStringFormatter -) : AbstractSemanticsNodeMapper(colorStringFormatter) { +) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) { var mappedWireframe: ComposeWireframe? = null diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt index aab9e31eca..c8986242ee 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt @@ -6,13 +6,14 @@ package com.datadog.android.sessionreplay.compose.internal.mappers.semantics -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.semantics.SemanticsNode import com.datadog.android.sessionreplay.compose.internal.data.UiContext -import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils +import com.datadog.android.sessionreplay.compose.internal.utils.BackgroundInfo import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.GlobalBounds import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.FloatForgery import fr.xgouchet.elmyr.annotation.Forgery @@ -28,9 +29,7 @@ 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.doReturn -import org.mockito.kotlin.eq import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -47,13 +46,10 @@ internal class ButtonSemanticsNodeMapperTest : AbstractCompositionGroupMapperTes @Mock private lateinit var mockSemanticsNode: SemanticsNode - @Mock - private lateinit var mockSemanticsUtils: SemanticsUtils - @Mock private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback - @LongForgery(min = 0L, max = 0xffffff) + @LongForgery(min = 0xffffff) var fakeBackgroundColor: Long = 0L @FloatForgery @@ -86,16 +82,19 @@ internal class ButtonSemanticsNodeMapperTest : AbstractCompositionGroupMapperTes fun `M return the correct wireframe W map`() { // Given val mockSemanticsNode = mockSemanticsNode() - whenever(mockSemanticsUtils.resolveSemanticsModifierColor(mockSemanticsNode)).thenReturn( - Color(fakeBackgroundColor).value.toLong() + val fakeBackgroundInfo = BackgroundInfo( + globalBounds = rectToBounds(fakeBounds, fakeDensity), + color = fakeBackgroundColor, + cornerRadius = fakeCornerRadius ) - whenever( - mockSemanticsUtils.resolveSemanticsModifierCornerRadius( - eq(mockSemanticsNode), - any(), - eq(mockDensity) - ) - ).thenReturn(fakeCornerRadius) + whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn rectToBounds( + fakeBounds, + fakeDensity + ) + whenever(mockSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode)) doReturn listOf( + fakeBackgroundInfo + ) + whenever(mockSemanticsUtils.resolveBackgroundInfoId(fakeBackgroundInfo)) doReturn fakeSemanticsId.toLong() // When val actual = testedButtonSemanticsNodeMapper.map( @@ -118,4 +117,12 @@ internal class ButtonSemanticsNodeMapperTest : AbstractCompositionGroupMapperTes ) assertThat(actual.wireframes).contains(expected) } + + private fun rectToBounds(rect: Rect, density: Float): GlobalBounds { + val width = ((rect.right - rect.left) / density).toLong() + val height = ((rect.bottom - rect.top) / density).toLong() + val x = (rect.left / density).toLong() + val y = (rect.top / density).toLong() + return GlobalBounds(x, y, width, height) + } } diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/GlobalBoundsForgeryFactory.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/GlobalBoundsForgeryFactory.kt new file mode 100644 index 0000000000..4d94b4205c --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/GlobalBoundsForgeryFactory.kt @@ -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.android.sessionreplay.compose.test.elmyr + +import com.datadog.android.sessionreplay.utils.GlobalBounds +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class GlobalBoundsForgeryFactory : ForgeryFactory { + override fun getForgery(forge: Forge): GlobalBounds { + return GlobalBounds( + x = forge.aLong(min = 128L, max = 65536L), + y = forge.aLong(min = 128L, max = 65536L), + width = forge.aLong(min = 32L, max = 65536L), + height = forge.aLong(min = 32L, max = 65536L) + ) + } +} diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/SessionReplayComposeForgeConfigurator.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/SessionReplayComposeForgeConfigurator.kt index a678e55701..9c44c18637 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/SessionReplayComposeForgeConfigurator.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/SessionReplayComposeForgeConfigurator.kt @@ -33,5 +33,6 @@ class SessionReplayComposeForgeConfigurator : BaseConfigurator() { forge.addFactory(ComposeWireframeForgeryFactory()) forge.addFactory(MappingContextForgeryFactory()) forge.addFactory(SystemInformationForgeryFactory()) + forge.addFactory(GlobalBoundsForgeryFactory()) } } diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/ImageSample.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/ImageSample.kt index 81ac78b6a7..a4ec54c329 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/ImageSample.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/ImageSample.kt @@ -8,12 +8,12 @@ package com.datadog.android.sample.compose import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -33,54 +34,90 @@ private const val SMALL_IMAGE_URL = "https://picsum.photos/100/100" @Composable internal fun ImageSample() { - Column(modifier = Modifier.fillMaxSize()) { - Row(verticalAlignment = Alignment.CenterVertically) { - Image( - modifier = Modifier.imageModifier(), - painter = painterResource(R.drawable.ic_dd_icon_rgb), - contentDescription = "purple dog" - ) - DescriptionText("Image") + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + LocalImageNoBackground() } - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - modifier = Modifier.imageModifier().background(Color.DarkGray), - painter = painterResource(R.drawable.ic_dd_icon_red), - tint = Color.Red, - contentDescription = "red dog" - ) - DescriptionText("Icon") + item { + LocalIconDoubleBackground() } - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton( - modifier = Modifier - .padding(16.dp) - .clip(RoundedCornerShape(4.dp)) - .background(Color.Black), - onClick = {} - ) { - Icon( - modifier = Modifier - .padding(16.dp) - .width(64.dp).height(64.dp), - painter = painterResource(R.drawable.ic_dd_icon_white), - tint = Color.White, - contentDescription = "white dog" - ) - } - DescriptionText("Icon Button") + item { + IconButtonSingleBackground() + } + item { + CoilImage() } + } +} + +@Composable +private fun LocalImageNoBackground() { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + modifier = Modifier.imageModifier(), + painter = painterResource(R.drawable.ic_dd_icon_rgb), + contentDescription = "purple dog" + ) + DescriptionText("Image") + } +} - Row(verticalAlignment = Alignment.CenterVertically) { - AsyncImage( - modifier = Modifier.imageModifier(), - model = SMALL_IMAGE_URL, - contentDescription = "Network Image" +@Composable +private fun LocalIconDoubleBackground() { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + modifier = Modifier + .padding(16.dp) + .clip(CircleShape) + .background(Color.Green) + .padding(32.dp) + .size(128.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.DarkGray) + .padding(4.dp), + painter = painterResource(R.drawable.ic_dd_icon_red), + tint = Color.Red, + contentDescription = "red dog" + ) + DescriptionText("Icon") + } +} + +@Composable +private fun IconButtonSingleBackground() { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + modifier = Modifier + .padding(16.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.Black), + onClick = {} + ) { + Icon( + modifier = Modifier + .padding(16.dp) + .size(160.dp), + painter = painterResource(R.drawable.ic_dd_icon_white), + tint = Color.White, + contentDescription = "white dog" ) - DescriptionText("Network Image") } + DescriptionText("Icon Button") + } +} + +@Composable +private fun CoilImage() { + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + modifier = Modifier.imageModifier(), + model = SMALL_IMAGE_URL, + contentScale = ContentScale.Fit, + contentDescription = "Network Image" + ) + DescriptionText("Network Image") } } @@ -95,7 +132,7 @@ private fun DescriptionText(description: String) { private fun Modifier.imageModifier(): Modifier { return this .padding(32.dp) - .width(64.dp).height(64.dp) + .size(160.dp) } @Composable