diff --git a/features/dd-sdk-android-session-replay-compose/api/dd-sdk-android-session-replay-compose.api b/features/dd-sdk-android-session-replay-compose/api/dd-sdk-android-session-replay-compose.api index c787e2f232..5ea08c9731 100644 --- a/features/dd-sdk-android-session-replay-compose/api/dd-sdk-android-session-replay-compose.api +++ b/features/dd-sdk-android-session-replay-compose/api/dd-sdk-android-session-replay-compose.api @@ -10,7 +10,7 @@ public final class com/datadog/android/sessionreplay/compose/ComposeExtensionSup public abstract interface annotation class com/datadog/android/sessionreplay/compose/ExperimentalSessionReplayApi : java/lang/annotation/Annotation { } -public final class com/datadog/android/sessionreplay/compose/internal/ModifierExtKt { +public final class com/datadog/android/sessionreplay/compose/ModifierExtKt { public static final fun sessionReplayHide (Landroidx/compose/ui/Modifier;Z)Landroidx/compose/ui/Modifier; public static final fun sessionReplayImagePrivacy (Landroidx/compose/ui/Modifier;Lcom/datadog/android/sessionreplay/ImagePrivacy;)Landroidx/compose/ui/Modifier; public static final fun sessionReplayTextAndInputPrivacy (Landroidx/compose/ui/Modifier;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;)Landroidx/compose/ui/Modifier; 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 7178866211..0c39b8e48d 100644 --- a/features/dd-sdk-android-session-replay-compose/consumer-rules.pro +++ b/features/dd-sdk-android-session-replay-compose/consumer-rules.pro @@ -41,7 +41,7 @@ -keep class androidx.compose.ui.graphics.AndroidImageBitmap { ; } --keep class coil.compose.ContentPainterModifier { +-keep class coil.compose.ContentPainterElement { ; } -keep class coil.compose.AsyncImagePainter { diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/data/BitmapInfo.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/data/BitmapInfo.kt new file mode 100644 index 0000000000..61f35eccf7 --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/data/BitmapInfo.kt @@ -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.android.sessionreplay.compose.internal.data + +import android.graphics.Bitmap + +internal data class BitmapInfo( + val bitmap: Bitmap, + val isContextualImage: Boolean +) 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 52774f491e..fd01c6ef4f 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 @@ -6,22 +6,9 @@ package com.datadog.android.sessionreplay.compose.internal.mappers.semantics -import android.graphics.Bitmap -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.semantics.SemanticsNode 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.reflection.ComposeReflection -import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.BitmapField -import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.ContentPainterModifierClass -import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.ImageField -import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.PainterElementClass -import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.PainterField -import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.PainterFieldOfAsyncImagePainter -import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.PainterFieldOfContentPainter -import com.datadog.android.sessionreplay.compose.internal.reflection.getSafe import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import com.datadog.android.sessionreplay.utils.ColorStringFormatter @@ -37,7 +24,7 @@ internal class ImageSemanticsNodeMapper( asyncJobStatusCallback: AsyncJobStatusCallback ): SemanticsWireframe { val bounds = resolveBounds(semanticsNode) - val bitmapInfo = resolveSemanticsPainter(semanticsNode) + val bitmapInfo = semanticsUtils.resolveSemanticsPainter(semanticsNode) val containerFrames = resolveModifierWireframes(semanticsNode).toMutableList() val imagePrivacy = semanticsUtils.getImagePrivacyOverride(semanticsNode) ?: parentContext.imagePrivacy @@ -65,73 +52,4 @@ internal class ImageSemanticsNodeMapper( uiContext = null ) } - - private fun resolveSemanticsPainter( - semanticsNode: SemanticsNode - ): BitmapInfo? { - var isContextualImage = false - var painter = tryParseLocalImagePainter(semanticsNode) - if (painter == null) { - painter = tryParseAsyncImagePainter(semanticsNode) - if (painter != null) { - isContextualImage = true - } - } - // 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) - else -> { - null - } - } - - val newBitmap = bitmap?.let { - @Suppress("UnsafeThirdPartyFunctionCall") // isMutable is always false - it.copy(Bitmap.Config.ARGB_8888, false) - } - return newBitmap?.let { - BitmapInfo(it, isContextualImage) - } - } - - private fun tryParseVectorPainterToBitmap(vectorPainter: VectorPainter): Bitmap? { - val vector = ComposeReflection.VectorField?.getSafe(vectorPainter) - val cacheDrawScope = ComposeReflection.CacheDrawScopeField?.getSafe(vector) - val mCachedImage = ComposeReflection.CachedImageField?.getSafe(cacheDrawScope) - return mCachedImage?.let { - BitmapField?.getSafe(it) as? Bitmap - } - } - - private fun tryParseBitmapPainterToBitmap(bitmapPainter: BitmapPainter): Bitmap? { - val image = ImageField?.getSafe(bitmapPainter) - 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 modifier?.let { PainterField?.getSafe(it) as? Painter } - } - - private fun tryParseAsyncImagePainter(semanticsNode: SemanticsNode): Painter? { - val modifier = semanticsNode.layoutInfo.getModifierInfo().firstOrNull { - ContentPainterModifierClass?.isInstance(it.modifier) == true - }?.modifier - val asyncPainter = PainterFieldOfContentPainter?.getSafe(modifier) - return PainterFieldOfAsyncImagePainter?.getSafe(asyncPainter) as? Painter - } - - private data class BitmapInfo( - val bitmap: Bitmap, - val isContextualImage: Boolean - ) } 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 5c404736cb..9ff626f578 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 @@ -64,8 +64,8 @@ internal object ComposeReflection { val AndroidImageBitmapClass = getClassSafe("androidx.compose.ui.graphics.AndroidImageBitmap") val BitmapField = AndroidImageBitmapClass?.getDeclaredFieldSafe("bitmap") - val ContentPainterModifierClass = getClassSafe("coil.compose.ContentPainterModifier") - val PainterFieldOfContentPainter = ContentPainterModifierClass?.getDeclaredFieldSafe("painter") + val ContentPainterElementClass = getClassSafe("coil.compose.ContentPainterElement") + val PainterFieldOfContentPainter = ContentPainterElementClass?.getDeclaredFieldSafe("painter") val AsyncImagePainterClass = getClassSafe("coil.compose.AsyncImagePainter") val PainterFieldOfAsyncImagePainter = AsyncImagePainterClass?.getDeclaredFieldSafe("_painter") diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt index a308acf436..de590f36f7 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt @@ -6,19 +6,30 @@ package com.datadog.android.sessionreplay.compose.internal.utils +import android.graphics.Bitmap import android.view.View import androidx.compose.runtime.Composition import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer 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.Placeable import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsOwner import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection +import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.BitmapField 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.GetInnerLayerCoordinatorMethod +import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.ImageField import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.LayoutNodeField +import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.PainterElementClass +import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.PainterField +import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.PainterFieldOfAsyncImagePainter +import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.PainterFieldOfContentPainter import com.datadog.android.sessionreplay.compose.internal.reflection.getSafe @Suppress("TooManyFunctions") @@ -48,6 +59,10 @@ internal class ReflectionUtils { return ComposeReflection.AndroidComposeViewClass?.isInstance(any) == true } + fun isAsyncImagePainter(painter: Painter): Boolean { + return ComposeReflection.AsyncImagePainterClass?.isInstance(painter) == true + } + fun getOwner(composition: Composition): Any? { return ComposeReflection.OwnerField?.getSafe(composition) } @@ -97,4 +112,39 @@ internal class ReflectionUtils { fun getClipShape(modifier: Modifier): Shape? { return ComposeReflection.ClipShapeField?.getSafe(modifier) as? Shape } + + fun getBitmapInVectorPainter(vectorPainter: VectorPainter): Bitmap? { + val vector = ComposeReflection.VectorField?.getSafe(vectorPainter) + val cacheDrawScope = ComposeReflection.CacheDrawScopeField?.getSafe(vector) + val mCachedImage = ComposeReflection.CachedImageField?.getSafe(cacheDrawScope) + return mCachedImage?.let { + BitmapField?.getSafe(it) as? Bitmap + } + } + + fun getBitmapInBitmapPainter(bitmapPainter: BitmapPainter): Bitmap? { + return ImageField?.getSafe(bitmapPainter)?.let { image -> + BitmapField?.getSafe(image) as? Bitmap + } + } + + fun getLocalImagePainter(semanticsNode: SemanticsNode): Painter? { + val modifier = semanticsNode.layoutInfo.getModifierInfo().firstOrNull { + PainterElementClass?.isInstance(it.modifier) == true + }?.modifier + return modifier?.let { PainterField?.getSafe(it) as? Painter } + } + + fun getAsyncImagePainter(semanticsNode: SemanticsNode): Painter? { + val modifier = semanticsNode.layoutInfo.getModifierInfo().firstOrNull { + ContentPainterElementClass?.isInstance(it.modifier) == true + }?.modifier + val asyncPainter = PainterFieldOfContentPainter?.getSafe(modifier) + val painter = PainterFieldOfAsyncImagePainter?.getSafe(asyncPainter) as? Painter + return painter + } + + fun getNestedPainter(painter: Painter): Painter? { + return PainterFieldOfAsyncImagePainter?.getSafe(painter) as? Painter + } } 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 dcda0cc283..2d90a5b3b8 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,12 +6,15 @@ package com.datadog.android.sessionreplay.compose.internal.utils +import android.graphics.Bitmap import android.view.View import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.vector.VectorPainter import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.getOrNull @@ -24,6 +27,7 @@ import com.datadog.android.sessionreplay.TouchPrivacy import com.datadog.android.sessionreplay.compose.ImagePrivacySemanticsPropertyKey import com.datadog.android.sessionreplay.compose.TextInputSemanticsPropertyKey import com.datadog.android.sessionreplay.compose.TouchSemanticsPropertyKey +import com.datadog.android.sessionreplay.compose.internal.data.BitmapInfo import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.TextLayoutInfo import com.datadog.android.sessionreplay.utils.GlobalBounds @@ -194,6 +198,37 @@ internal class SemanticsUtils(private val reflectionUtils: ReflectionUtils = Ref } } + internal fun resolveSemanticsPainter( + semanticsNode: SemanticsNode + ): BitmapInfo? { + var isContextualImage = false + var painter = reflectionUtils.getLocalImagePainter(semanticsNode) + if (painter == null) { + isContextualImage = true + painter = reflectionUtils.getAsyncImagePainter(semanticsNode) + } + // TODO RUM-6535: support more painters. + if (painter != null && reflectionUtils.isAsyncImagePainter(painter)) { + isContextualImage = true + painter = reflectionUtils.getNestedPainter(painter) + } + val bitmap = when (painter) { + is BitmapPainter -> reflectionUtils.getBitmapInBitmapPainter(painter) + is VectorPainter -> reflectionUtils.getBitmapInVectorPainter(painter) + else -> { + null + } + } + + val newBitmap = bitmap?.let { + @Suppress("UnsafeThirdPartyFunctionCall") // isMutable is always false + it.copy(Bitmap.Config.ARGB_8888, false) + } + return newBitmap?.let { + BitmapInfo(it, isContextualImage) + } + } + private fun resolveModifierColor(semanticsNode: SemanticsNode): Color? { val modifier = semanticsNode.layoutInfo.getModifierInfo().firstOrNull { reflectionUtils.isTextStringSimpleElement(it.modifier) diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt index 2211a52074..0e79c01ca0 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.compose.internal.utils +import android.graphics.Bitmap import android.view.View import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape @@ -16,6 +17,8 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.vector.VectorPainter import androidx.compose.ui.layout.LayoutInfo import androidx.compose.ui.layout.ModifierInfo import androidx.compose.ui.layout.Placeable @@ -35,6 +38,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp +import com.datadog.android.sessionreplay.compose.internal.data.BitmapInfo import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.TextLayoutInfo import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator import com.datadog.android.sessionreplay.utils.GlobalBounds @@ -53,6 +57,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.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock @@ -379,6 +384,39 @@ class SemanticsUtilsTest { assertThat(result).containsExactly(expected) } + @Test + fun `M return local bitmap W resolveSemanticsPainter { local image }`() { + // Given + val mockVectorPainter = mock() + val mockBitmap = mock() + whenever(mockReflectionUtils.getLocalImagePainter(mockSemanticsNode)) doReturn mockVectorPainter + whenever(mockReflectionUtils.getBitmapInVectorPainter(mockVectorPainter)) doReturn mockBitmap + whenever(mockBitmap.copy(any(), any())) doReturn mockBitmap + + // When + val result = testedSemanticsUtils.resolveSemanticsPainter(mockSemanticsNode) + + // Then + assertThat(result).isEqualTo(BitmapInfo(mockBitmap, false)) + } + + @Test + fun `M return async bitmap W resolveSemanticsPainter { async image }`() { + // Given + val mockBitmapPainter = mock() + val mockBitmap = mock() + whenever(mockReflectionUtils.getAsyncImagePainter(mockSemanticsNode)) doReturn mockBitmapPainter + whenever(mockReflectionUtils.getBitmapInBitmapPainter(mockBitmapPainter)) doReturn mockBitmap + whenever(mockReflectionUtils.isAsyncImagePainter(mockBitmapPainter)) doReturn false + whenever(mockBitmap.copy(any(), any())) doReturn mockBitmap + + // When + val result = testedSemanticsUtils.resolveSemanticsPainter(mockSemanticsNode) + + // Then + assertThat(result).isEqualTo(BitmapInfo(mockBitmap, true)) + } + private fun rectToBounds(rect: Rect, density: Float): GlobalBounds { val width = ((rect.right - rect.left) / density).toLong() val height = ((rect.bottom - rect.top) / density).toLong()