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

Move ImageSemanticsMapper reflection functions into ReflectionUtils #2419

Merged
merged 1 commit into from
Nov 28, 2024
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 @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
-keep class androidx.compose.ui.graphics.AndroidImageBitmap {
<fields>;
}
-keep class coil.compose.ContentPainterModifier {
-keep class coil.compose.ContentPainterElement {
<fields>;
}
-keep class coil.compose.AsyncImagePainter {
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.android.sessionreplay.compose.internal.data

import android.graphics.Bitmap

internal data class BitmapInfo(
val bitmap: Bitmap,
val isContextualImage: Boolean
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -97,4 +112,39 @@ internal class ReflectionUtils {
fun getClipShape(modifier: Modifier): Shape? {
return ComposeReflection.ClipShapeField?.getSafe(modifier) as? Shape
}

fun getBitmapInVectorPainter(vectorPainter: VectorPainter): Bitmap? {
Copy link
Member

Choose a reason for hiding this comment

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

❔ question: ‏ Do you have tests for these functions that you moved here ?

Copy link
Member Author

Choose a reason for hiding this comment

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

No, reflection functions are tricky to test, they heavily depend on runtime environment. That's why I move all the reflection functions into this single class, and we are able to mock it and ensure the other parts by unit test.

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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -379,6 +384,39 @@ class SemanticsUtilsTest {
assertThat(result).containsExactly(expected)
}

@Test
fun `M return local bitmap W resolveSemanticsPainter { local image }`() {
// Given
val mockVectorPainter = mock<VectorPainter>()
val mockBitmap = mock<Bitmap>()
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<BitmapPainter>()
val mockBitmap = mock<Bitmap>()
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()
Expand Down