diff --git a/detekt_custom.yml b/detekt_custom.yml index d604fedc74..22afb0382a 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -128,6 +128,7 @@ datadog: - "android.database.sqlite.SQLiteDatabase.endTransaction():java.lang.IllegalStateException" - "android.database.sqlite.SQLiteDatabase.setTransactionSuccessful():java.lang.IllegalStateException" - "android.graphics.Bitmap.compress(android.graphics.Bitmap.CompressFormat, kotlin.Int, java.io.OutputStream):java.lang.NullPointerException,java.lang.IllegalArgumentException" + - "android.graphics.Bitmap.copy(android.graphics.Bitmap.Config, kotlin.Boolean):java.lang.IllegalArgumentException" - "android.graphics.Bitmap.createBitmap(android.util.DisplayMetrics?, kotlin.Int, kotlin.Int, android.graphics.Bitmap.Config):java.lang.IllegalArgumentException" - "android.graphics.Bitmap.createScaledBitmap(android.graphics.Bitmap, kotlin.Int, kotlin.Int, kotlin.Boolean):java.lang.IllegalArgumentException" - "android.graphics.Canvas.constructor(android.graphics.Bitmap):java.lang.IllegalStateException" 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 835b5d9db1..0c5f49e839 100644 --- a/features/dd-sdk-android-session-replay-compose/consumer-rules.pro +++ b/features/dd-sdk-android-session-replay-compose/consumer-rules.pro @@ -16,10 +16,37 @@ -keepclassmembers class androidx.compose.ui.platform.AndroidComposeView { ; } - -keepclassmembers class androidx.compose.foundation.text.modifiers.TextStringSimpleElement { ; } -keepclassmembers class androidx.compose.foundation.BackgroundElement { ; } +-keepclassmembers class androidx.compose.ui.node.LayoutNode { + ; +} +-keepclassmembers class androidx.compose.ui.draw.PainterElement { + ; +} +-keepclassmembers class androidx.compose.ui.graphics.vector.VectorPainter { + ; +} +-keepclassmembers class androidx.compose.ui.graphics.painter.BitmapPainter { + ; +} +-keepclassmembers class androidx.compose.ui.graphics.vector.VectorComponent { + ; +} +-keepclassmembers class androidx.compose.ui.graphics.vector.DrawCache { + ; +} +-keepclassmembers class androidx.compose.ui.graphics.AndroidImageBitmap { + ; +} +-keepclassmembers class coil.compose.ContentPainterModifier { + ; +} +-keepclassmembers class coil.compose.AsyncImagePainter { + ; +} + diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/data/UiContext.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/data/UiContext.kt index 6d1ac1a59e..e340ffb42d 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/data/UiContext.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/data/UiContext.kt @@ -8,12 +8,14 @@ package com.datadog.android.sessionreplay.compose.internal.data import androidx.compose.ui.unit.Density import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.utils.ImageWireframeHelper internal data class UiContext( val parentContentColor: String?, val density: Float, val privacy: SessionReplayPrivacy, - val isInUserInputLayout: Boolean = false + val isInUserInputLayout: Boolean = false, + val imageWireframeHelper: ImageWireframeHelper ) { val composeDensity: Density get() = Density(density) diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/ComposeWireframeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/ComposeWireframeMapper.kt index 9d9fd03fbd..3411744492 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/ComposeWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/ComposeWireframeMapper.kt @@ -25,6 +25,7 @@ import com.datadog.android.sessionreplay.recorder.mapper.BaseWireframeMapper import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import com.datadog.android.sessionreplay.utils.ColorStringFormatter import com.datadog.android.sessionreplay.utils.DrawableToColorMapper +import com.datadog.android.sessionreplay.utils.ImageWireframeHelper import com.datadog.android.sessionreplay.utils.ViewBoundsResolver import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver @@ -71,6 +72,7 @@ internal class ComposeWireframeMapper( composer = composer, density = density, privacy = privacy, + imageWireframeHelper = mappingContext.imageWireframeHelper, internalLogger = internalLogger ) wireframes @@ -102,6 +104,7 @@ internal class ComposeWireframeMapper( composer: Composer, density: Float, privacy: SessionReplayPrivacy, + imageWireframeHelper: ImageWireframeHelper, internalLogger: InternalLogger ): List { val wireframes = mutableListOf() @@ -113,7 +116,8 @@ internal class ComposeWireframeMapper( parentUiContext = UiContext( parentContentColor = null, density = density, - privacy = privacy + privacy = privacy, + imageWireframeHelper = imageWireframeHelper ), internalLogger = internalLogger ) 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 a8c98c4795..41f03c99f4 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 @@ -17,7 +17,7 @@ internal abstract class AbstractSemanticsNodeMapper( private val colorStringFormatter: ColorStringFormatter ) : SemanticsNodeMapper { - protected fun resolveBound(semanticsNode: SemanticsNode): GlobalBounds { + protected fun resolveBounds(semanticsNode: SemanticsNode): GlobalBounds { val rect = semanticsNode.boundsInRoot val density = semanticsNode.layoutInfo.density.density val width = ((rect.right - rect.left) / density).toLong() 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 7858374f4c..dc991f4e6f 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 @@ -12,6 +12,7 @@ import com.datadog.android.sessionreplay.compose.internal.data.ComposeWireframe 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 @@ -20,9 +21,13 @@ internal class ButtonSemanticsNodeMapper( private val semanticsUtils: SemanticsUtils = SemanticsUtils() ) : AbstractSemanticsNodeMapper(colorStringFormatter) { - override fun map(semanticsNode: SemanticsNode, parentContext: UiContext): ComposeWireframe { + override fun map( + semanticsNode: SemanticsNode, + parentContext: UiContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): ComposeWireframe { val density = semanticsNode.layoutInfo.density - val bounds = resolveBound(semanticsNode) + val bounds = resolveBounds(semanticsNode) val buttonStyle = resolveSemanticsButtonStyle(semanticsNode, bounds, density) return ComposeWireframe( MobileSegment.Wireframe.ShapeWireframe( 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 new file mode 100644 index 0000000000..35d1859350 --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ImageSemanticsNodeMapper.kt @@ -0,0 +1,125 @@ +/* + * 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.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.ImagePrivacy +import com.datadog.android.sessionreplay.compose.internal.data.ComposeWireframe +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.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ColorStringFormatter + +internal class ImageSemanticsNodeMapper( + colorStringFormatter: ColorStringFormatter +) : AbstractSemanticsNodeMapper(colorStringFormatter) { + + override fun map( + semanticsNode: SemanticsNode, + parentContext: UiContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): ComposeWireframe? { + val bounds = resolveBounds(semanticsNode) + val bitmapInfo = resolveSemanticsPainter(semanticsNode) + val imageWireframe = if (bitmapInfo != null) { + parentContext.imageWireframeHelper.createImageWireframeByBitmap( + id = semanticsNode.id.toLong(), + globalBounds = bounds, + bitmap = bitmapInfo.bitmap, + density = parentContext.density, + isContextualImage = bitmapInfo.isContextualImage, + // TODO RUM-6192: Apply FGM here + imagePrivacy = ImagePrivacy.MASK_NONE, + asyncJobStatusCallback = asyncJobStatusCallback, + clipping = null, + shapeStyle = null, + border = null + ) + } else { + null + } + return imageWireframe?.let { + ComposeWireframe( + imageWireframe, + 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. + 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(it.config, 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 BitmapField?.getSafe(mCachedImage) as? Bitmap + } + + private fun tryParseBitmapPainterToBitmap(bitmapPainter: BitmapPainter): Bitmap? { + val image = ImageField?.getSafe(bitmapPainter) + return 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 + } + + 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/mappers/semantics/SemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsNodeMapper.kt index b4a947af05..52562c8df6 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsNodeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsNodeMapper.kt @@ -9,11 +9,13 @@ package com.datadog.android.sessionreplay.compose.internal.mappers.semantics import androidx.compose.ui.semantics.SemanticsNode import com.datadog.android.sessionreplay.compose.internal.data.ComposeWireframe import com.datadog.android.sessionreplay.compose.internal.data.UiContext +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback internal interface SemanticsNodeMapper { fun map( semanticsNode: SemanticsNode, - parentContext: UiContext + parentContext: UiContext, + asyncJobStatusCallback: AsyncJobStatusCallback ): ComposeWireframe? } diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt index 934491303d..f6d20f2c77 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt @@ -32,7 +32,8 @@ internal class SemanticsWireframeMapper( private val semanticsUtils: SemanticsUtils = SemanticsUtils(), private val semanticsNodeMapper: Map = mapOf( // TODO RUM-6189 Add Mappers for each Semantics Role - Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter) + Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter), + Role.Image to ImageSemanticsNodeMapper(colorStringFormatter) ), // Text doesn't have a role in semantics, so it should be a fallback mapper. private val textSemanticsNodeMapper: TextSemanticsNodeMapper = TextSemanticsNodeMapper(colorStringFormatter) @@ -51,7 +52,7 @@ internal class SemanticsWireframeMapper( val density = mappingContext.systemInformation.screenDensity.let { if (it == 0.0f) 1.0f else it } val privacy = mappingContext.privacy return semanticsUtils.findRootSemanticsNode(view)?.let { node -> - createComposeWireframes(node, density, privacy) + createComposeWireframes(node, density, mappingContext, privacy, asyncJobStatusCallback) } ?: emptyList() } @@ -65,7 +66,9 @@ internal class SemanticsWireframeMapper( private fun createComposeWireframes( semanticsNode: SemanticsNode, density: Float, - privacy: SessionReplayPrivacy + mappingContext: MappingContext, + privacy: SessionReplayPrivacy, + asyncJobStatusCallback: AsyncJobStatusCallback ): List { val wireframes = mutableListOf() createComposerWireframes( @@ -74,8 +77,10 @@ internal class SemanticsWireframeMapper( parentUiContext = UiContext( parentContentColor = null, density = density, - privacy = privacy - ) + privacy = privacy, + imageWireframeHelper = mappingContext.imageWireframeHelper + ), + asyncJobStatusCallback = asyncJobStatusCallback ) return wireframes } @@ -83,15 +88,19 @@ internal class SemanticsWireframeMapper( private fun createComposerWireframes( semanticsNode: SemanticsNode, wireframes: MutableList, - parentUiContext: UiContext + parentUiContext: UiContext, + asyncJobStatusCallback: AsyncJobStatusCallback ) { - getSemanticsNodeMapper(semanticsNode) - .map(semanticsNode, parentUiContext)?.wireframe?.let { - wireframes.add(it) - } + getSemanticsNodeMapper(semanticsNode).map( + semanticsNode = semanticsNode, + parentContext = parentUiContext, + asyncJobStatusCallback = asyncJobStatusCallback + )?.wireframe?.let { + wireframes.add(it) + } val children = semanticsNode.children children.forEach { - createComposerWireframes(it, wireframes, parentUiContext) + createComposerWireframes(it, wireframes, parentUiContext, asyncJobStatusCallback) } } } diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TextSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TextSemanticsNodeMapper.kt index 72457c619b..32b333aad4 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TextSemanticsNodeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TextSemanticsNodeMapper.kt @@ -22,14 +22,19 @@ 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.getSafe import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import com.datadog.android.sessionreplay.utils.ColorStringFormatter internal class TextSemanticsNodeMapper(colorStringFormatter: ColorStringFormatter) : AbstractSemanticsNodeMapper(colorStringFormatter) { - override fun map(semanticsNode: SemanticsNode, parentContext: UiContext): ComposeWireframe { + override fun map( + semanticsNode: SemanticsNode, + parentContext: UiContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): ComposeWireframe { val text = resolveText(semanticsNode.config) val textStyle = resolveTextStyle(semanticsNode, parentContext) ?: defaultTextStyle - val bounds = resolveBound(semanticsNode) + val bounds = resolveBounds(semanticsNode) return ComposeWireframe( MobileSegment.Wireframe.TextWireframe( id = semanticsNode.id.toLong(), 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 1a9536cda8..77efe4a434 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 @@ -35,6 +35,8 @@ internal object ComposeReflection { val OwnerField = WrappedCompositionClass?.getDeclaredFieldSafe("owner") + val LayoutNodeClass = getClassSafe("androidx.compose.ui.node.LayoutNode") + val LayoutNodeOwnerField = LayoutNodeClass?.getDeclaredFieldSafe("owner") val AndroidComposeViewClass = getClassSafe("androidx.compose.ui.platform.AndroidComposeView") val SemanticsOwner = AndroidComposeViewClass?.getDeclaredFieldSafe("semanticsOwner") @@ -44,6 +46,30 @@ internal object ComposeReflection { val BackgroundElementClass = getClassSafe("androidx.compose.foundation.BackgroundElement") val ColorField = BackgroundElementClass?.getDeclaredFieldSafe("color") val ShapeField = BackgroundElementClass?.getDeclaredFieldSafe("shape") + + val PainterElementClass = getClassSafe("androidx.compose.ui.draw.PainterElement") + val PainterField = PainterElementClass?.getDeclaredFieldSafe("painter") + + val VectorPainterClass = getClassSafe("androidx.compose.ui.graphics.vector.VectorPainter") + val VectorField = VectorPainterClass?.getDeclaredFieldSafe("vector") + + val BitmapPainterClass = getClassSafe("androidx.compose.ui.graphics.painter.BitmapPainter") + val ImageField = BitmapPainterClass?.getDeclaredFieldSafe("image") + + val VectorComponent = getClassSafe("androidx.compose.ui.graphics.vector.VectorComponent") + val CacheDrawScopeField = VectorComponent?.getDeclaredFieldSafe("cacheDrawScope") + + val DrawCacheClass = getClassSafe("androidx.compose.ui.graphics.vector.DrawCache") + val CachedImageField = DrawCacheClass?.getDeclaredFieldSafe("mCachedImage") + + 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 AsyncImagePainterClass = getClassSafe("coil.compose.AsyncImagePainter") + val PainterFieldOfAsyncImagePainter = AsyncImagePainterClass?.getDeclaredFieldSafe("_painter") } internal fun Field.accessible(): Field { 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 e54b02b459..393e5632b9 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.UiContext import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import com.datadog.android.sessionreplay.utils.ColorStringFormatter import com.datadog.android.sessionreplay.utils.GlobalBounds import fr.xgouchet.elmyr.Forge @@ -85,7 +86,7 @@ internal open class AbstractCompositionGroupMapperTest { val mockNode = mockSemanticsNodeWithBound() // When - val result = testedMapper.resolveBounds(mockNode) + val result = testedMapper.stubResolveBounds(mockNode) // Then assertThat(result.x).isEqualTo((fakeBounds.left / fakeDensity).toLong()) @@ -131,12 +132,16 @@ internal class StubAbstractSemanticsNodeMapper( var mappedWireframe: ComposeWireframe? = null - override fun map(semanticsNode: SemanticsNode, parentContext: UiContext): ComposeWireframe? { + override fun map( + semanticsNode: SemanticsNode, + parentContext: UiContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): ComposeWireframe? { return null } - fun resolveBounds(semanticsNode: SemanticsNode): GlobalBounds { - return super.resolveBound(semanticsNode) + fun stubResolveBounds(semanticsNode: SemanticsNode): GlobalBounds { + return super.resolveBounds(semanticsNode) } fun covertColor(color: Long): String? { 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 dc4acbf5da..f7abe692c6 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 @@ -12,6 +12,7 @@ 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.model.MobileSegment +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.FloatForgery import fr.xgouchet.elmyr.annotation.Forgery @@ -49,6 +50,9 @@ internal class ButtonSemanticsNodeMapperTest : AbstractCompositionGroupMapperTes @Mock private lateinit var mockSemanticsUtils: SemanticsUtils + @Mock + private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback + @LongForgery(min = 0L, max = 0xffffff) var fakeBackgroundColor: Long = 0L @@ -96,7 +100,8 @@ internal class ButtonSemanticsNodeMapperTest : AbstractCompositionGroupMapperTes // When val actual = testedButtonSemanticsNodeMapper.map( mockSemanticsNode, - fakeUiContext + fakeUiContext, + mockAsyncJobStatusCallback ) // Then diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapperTest.kt index e1a9e1e2af..9c864253ad 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapperTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapperTest.kt @@ -111,7 +111,11 @@ class SemanticsWireframeMapperTest { ) // Then - verify(mockTextSemanticsNodeMapper, times(1)).map(eq(mockSemanticsNode), any()) + verify(mockTextSemanticsNodeMapper, times(1)).map( + eq(mockSemanticsNode), + any(), + eq(mockAsyncJobStatusCallback) + ) } private fun mockSemanticsNode(role: Role?): SemanticsNode { diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TextSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TextSemanticsNodeMapperTest.kt index 0f0b22c6c1..a62359ca37 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TextSemanticsNodeMapperTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TextSemanticsNodeMapperTest.kt @@ -22,6 +22,7 @@ import com.datadog.android.sessionreplay.compose.internal.data.UiContext import com.datadog.android.sessionreplay.compose.internal.mappers.TextCompositionGroupMapper.Companion.DEFAULT_FONT_FAMILY import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.IntForgery @@ -60,6 +61,9 @@ internal class TextSemanticsNodeMapperTest : AbstractCompositionGroupMapperTest( @Mock private lateinit var mockTextLayoutResult: TextLayoutResult + @Mock + private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback + @StringForgery(regex = "#[0-9A-F]{8}") lateinit var fakeTextColorHexString: String @@ -110,7 +114,8 @@ internal class TextSemanticsNodeMapperTest : AbstractCompositionGroupMapperTest( } val actual = testedTextSemanticsNodeMapper.map( mockNode, - fakeUiContext + fakeUiContext, + mockAsyncJobStatusCallback ) val expected = MobileSegment.Wireframe.TextWireframe( diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/UIContextForgeryFactory.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/UIContextForgeryFactory.kt index 06e84416cd..6f7048738d 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/UIContextForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/UIContextForgeryFactory.kt @@ -10,6 +10,7 @@ import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.datadog.android.sessionreplay.compose.internal.data.UiContext import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.ForgeryFactory +import org.mockito.kotlin.mock internal class UIContextForgeryFactory : ForgeryFactory { override fun getForgery(forge: Forge): UiContext { @@ -17,7 +18,8 @@ internal class UIContextForgeryFactory : ForgeryFactory { parentContentColor = forge.anAlphabeticalString(), density = forge.aFloat(0.01f, 100f), privacy = forge.aValueFrom(SessionReplayPrivacy::class.java), - isInUserInputLayout = forge.aBool() + isInUserInputLayout = forge.aBool(), + imageWireframeHelper = mock() ) } } diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index 8c711633d1..cd7869fc61 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -82,7 +82,8 @@ interface com.datadog.android.sessionreplay.utils.DrawableToColorMapper data class com.datadog.android.sessionreplay.utils.GlobalBounds constructor(Long, Long, Long, Long) interface com.datadog.android.sessionreplay.utils.ImageWireframeHelper - fun createImageWireframe(android.view.View, com.datadog.android.sessionreplay.ImagePrivacy, Int, Long, Long, Int, Int, Boolean, android.graphics.drawable.Drawable, AsyncJobStatusCallback, com.datadog.android.sessionreplay.model.MobileSegment.WireframeClip? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeBorder? = null, String? = DRAWABLE_CHILD_NAME): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe? + fun createImageWireframeByBitmap(Long, GlobalBounds, android.graphics.Bitmap, Float, Boolean, com.datadog.android.sessionreplay.ImagePrivacy, AsyncJobStatusCallback, com.datadog.android.sessionreplay.model.MobileSegment.WireframeClip? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeBorder? = null): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe? + fun createImageWireframeByDrawable(android.view.View, com.datadog.android.sessionreplay.ImagePrivacy, Int, Long, Long, Int, Int, Boolean, android.graphics.drawable.Drawable, AsyncJobStatusCallback, com.datadog.android.sessionreplay.model.MobileSegment.WireframeClip? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeBorder? = null, String? = DRAWABLE_CHILD_NAME): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe? fun createCompoundDrawableWireframes(android.widget.TextView, com.datadog.android.sessionreplay.recorder.MappingContext, Int, AsyncJobStatusCallback): MutableList companion object open class com.datadog.android.sessionreplay.utils.LegacyDrawableToColorMapper : DrawableToColorMapper diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index ccd173c6cd..d234155dc1 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -1508,14 +1508,16 @@ public final class com/datadog/android/sessionreplay/utils/GlobalBounds { public abstract interface class com/datadog/android/sessionreplay/utils/ImageWireframeHelper { public static final field Companion Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper$Companion; public abstract fun createCompoundDrawableWireframes (Landroid/widget/TextView;Lcom/datadog/android/sessionreplay/recorder/MappingContext;ILcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;)Ljava/util/List; - public abstract fun createImageWireframe (Landroid/view/View;Lcom/datadog/android/sessionreplay/ImagePrivacy;IJJIIZLandroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; + public abstract fun createImageWireframeByBitmap (JLcom/datadog/android/sessionreplay/utils/GlobalBounds;Landroid/graphics/Bitmap;FZLcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; + public abstract fun createImageWireframeByDrawable (Landroid/view/View;Lcom/datadog/android/sessionreplay/ImagePrivacy;IJJIIZLandroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; } public final class com/datadog/android/sessionreplay/utils/ImageWireframeHelper$Companion { } public final class com/datadog/android/sessionreplay/utils/ImageWireframeHelper$DefaultImpls { - public static synthetic fun createImageWireframe$default (Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Landroid/view/View;Lcom/datadog/android/sessionreplay/ImagePrivacy;IJJIIZLandroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; + public static synthetic fun createImageWireframeByBitmap$default (Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;JLcom/datadog/android/sessionreplay/utils/GlobalBounds;Landroid/graphics/Bitmap;FZLcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; + public static synthetic fun createImageWireframeByDrawable$default (Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Landroid/view/View;Lcom/datadog/android/sessionreplay/ImagePrivacy;IJJIIZLandroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; } public class com/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper : com/datadog/android/sessionreplay/utils/DrawableToColorMapper { diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt index b9f8c54468..5cb92eff16 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt @@ -109,7 +109,7 @@ internal abstract class CheckableTextViewMapper( view, mappingContext.systemInformation.screenDensity ) - mappingContext.imageWireframeHelper.createImageWireframe( + mappingContext.imageWireframeHelper.createImageWireframeByDrawable( view = view, imagePrivacy = mappingContext.imagePrivacy, currentWireframeIndex = 0, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapper.kt index dc69b1da01..c5b7f1d8be 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapper.kt @@ -61,7 +61,7 @@ internal class ImageViewMapper( if (contentDrawable != null) { // resolve foreground - mappingContext.imageWireframeHelper.createImageWireframe( + mappingContext.imageWireframeHelper.createImageWireframeByDrawable( view = view, imagePrivacy = mappingContext.imagePrivacy, currentWireframeIndex = wireframes.size, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt index 9ff0e2f564..ca9e2b3151 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt @@ -78,7 +78,7 @@ internal open class SwitchCompatMapper( setTintList(it) } }?.let { drawable -> - mappingContext.imageWireframeHelper.createImageWireframe( + mappingContext.imageWireframeHelper.createImageWireframeByDrawable( view = view, imagePrivacy = mappingContext.imagePrivacy, currentWireframeIndex = prevIndex + 1, @@ -109,7 +109,7 @@ internal open class SwitchCompatMapper( return view.thumbDrawable?.let { drawable -> thumbBounds?.let { thumbBounds -> - mappingContext.imageWireframeHelper.createImageWireframe( + mappingContext.imageWireframeHelper.createImageWireframeByDrawable( view = view, imagePrivacy = mappingContext.imagePrivacy, currentWireframeIndex = prevIndex + 1, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt index 0e4caec6e5..fb7834d2c1 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.internal.recorder.resources +import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.InsetDrawable @@ -21,6 +22,7 @@ import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.GlobalBounds import com.datadog.android.sessionreplay.utils.ImageWireframeHelper import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver import java.util.Locale @@ -38,7 +40,70 @@ internal class DefaultImageWireframeHelper( @Suppress("ReturnCount", "LongMethod") @UiThread - override fun createImageWireframe( + override fun createImageWireframeByBitmap( + id: Long, + globalBounds: GlobalBounds, + bitmap: Bitmap, + density: Float, + isContextualImage: Boolean, + imagePrivacy: ImagePrivacy, + asyncJobStatusCallback: AsyncJobStatusCallback, + clipping: MobileSegment.WireframeClip?, + shapeStyle: MobileSegment.ShapeStyle?, + border: MobileSegment.ShapeBorder? + ): MobileSegment.Wireframe { + if (imagePrivacy == ImagePrivacy.MASK_ALL) { + return createContentPlaceholderWireframe( + id = id, + globalBounds = globalBounds, + density = density, + label = MASK_ALL_CONTENT_LABEL + ) + } + + // in case we suspect the image is PII, return a placeholder + if (isContextualImage && ImagePrivacy.MASK_LARGE_ONLY == imagePrivacy) { + return createContentPlaceholderWireframe( + id = id, + globalBounds = globalBounds, + density = density, + label = MASK_CONTEXTUAL_CONTENT_LABEL + ) + } + + val imageWireframe = MobileSegment.Wireframe.ImageWireframe( + id = id, + globalBounds.x, + globalBounds.y, + width = globalBounds.width, + height = globalBounds.height, + shapeStyle = shapeStyle, + border = border, + clip = clipping, + isEmpty = true + ) + + asyncJobStatusCallback.jobStarted() + + resourceResolver.resolveResourceId( + bitmap = bitmap, + resourceResolverCallback = object : ResourceResolverCallback { + override fun onSuccess(resourceId: String) { + populateResourceIdInWireframe(resourceId, imageWireframe) + asyncJobStatusCallback.jobFinished() + } + + override fun onFailure() { + asyncJobStatusCallback.jobFinished() + } + } + ) + return imageWireframe + } + + @Suppress("ReturnCount", "LongMethod") + @UiThread + override fun createImageWireframeByDrawable( view: View, imagePrivacy: ImagePrivacy, currentWireframeIndex: Int, @@ -183,7 +248,7 @@ internal class DefaultImageWireframeHelper( position = compoundDrawablePosition ) - createImageWireframe( + createImageWireframeByDrawable( view = textView, imagePrivacy = mappingContext.imagePrivacy, currentWireframeIndex = ++wireframeIndex, @@ -236,6 +301,22 @@ internal class DefaultImageWireframeHelper( } } + private fun createContentPlaceholderWireframe( + id: Long, + globalBounds: GlobalBounds, + density: Float, + label: String + ): MobileSegment.Wireframe.PlaceholderWireframe { + return MobileSegment.Wireframe.PlaceholderWireframe( + id, + globalBounds.x.densityNormalized(density), + globalBounds.y.densityNormalized(density), + globalBounds.width.densityNormalized(density), + globalBounds.height.densityNormalized(density), + label = label + ) + } + private fun createContentPlaceholderWireframe( view: View, id: Long, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt index 90d3e6d030..3ab6b51e5a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt @@ -40,6 +40,37 @@ internal class ResourceResolver( ) { // region internal + + @MainThread + internal fun resolveResourceId( + bitmap: Bitmap, + resourceResolverCallback: ResourceResolverCallback + ) { + threadPoolExecutor.executeSafe("resolveResourceId", logger) { + val compressedBitmapBytes = webPImageCompression.compressBitmap(bitmap) + + // failed to compress bitmap + if (compressedBitmapBytes.isEmpty()) { + resourceResolverCallback.onFailure() + } else { + resolveBitmapHash( + compressedBitmapBytes = compressedBitmapBytes, + resolveResourceCallback = object : ResolveResourceCallback { + override fun onResolved(resourceId: String, resourceData: ByteArray) { + resourceItemCreationHandler.queueItem(resourceId, resourceData) + resourceResolverCallback.onSuccess(resourceId) + } + + override fun onFailed() { + resourceResolverCallback.onFailure() + } + } + ) + } + } + } + + // endregion @MainThread internal fun resolveResourceId( resources: Resources, @@ -127,6 +158,29 @@ internal class ResourceResolver( } } + @WorkerThread + private fun resolveBitmapHash( + compressedBitmapBytes: ByteArray, + resolveResourceCallback: ResolveResourceCallback + ) { + // failed to get image data + if (compressedBitmapBytes.isEmpty()) { + // we are already logging the failure in webpImageCompression + resolveResourceCallback.onFailed() + return + } + + val resourceId = md5HashGenerator.generate(compressedBitmapBytes) + + // failed to resolve bitmap identifier + if (resourceId == null) { + // logging md5 generation failures inside md5HashGenerator + resolveResourceCallback.onFailed() + return + } + resolveResourceCallback.onResolved(resourceId, compressedBitmapBytes) + } + @Suppress("ReturnCount") @WorkerThread private fun resolveResourceHash( diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt index c71dacb27e..2b28b1134c 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt @@ -129,7 +129,7 @@ abstract class BaseAsyncBackgroundWireframeMapper internal construc val drawableCopy = view.background?.constantState?.newDrawable(resources) return if (drawableCopy != null) { - mappingContext.imageWireframeHelper.createImageWireframe( + mappingContext.imageWireframeHelper.createImageWireframeByDrawable( view = view, imagePrivacy = mappingContext.imagePrivacy, currentWireframeIndex = 0, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/ImageWireframeHelper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/ImageWireframeHelper.kt index edeb7e683f..2c777dcea9 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/ImageWireframeHelper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/ImageWireframeHelper.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.utils +import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.view.View import android.widget.TextView @@ -19,6 +20,32 @@ import com.datadog.android.sessionreplay.recorder.MappingContext */ interface ImageWireframeHelper { + /** + * Asks the helper to create an image wireframe based on a given bitmap. + * @param id the unique id for the wireframe. + * @param globalBounds the global bounds of the bitmap. + * @param bitmap the bitmap to capture. + * @param density the density of the screen. + * @param isContextualImage if the image is contextual. + * @param imagePrivacy defines which images should be hidden. + * @param asyncJobStatusCallback the callback for the async capture process. + * @param clipping the bounds of the image that are actually visible. + * @param shapeStyle provides a custom shape (e.g. rounded corners) to the image wireframe. + * @param border provides a custom border to the image wireframe. + */ + fun createImageWireframeByBitmap( + id: Long, + globalBounds: GlobalBounds, + bitmap: Bitmap, + density: Float, + isContextualImage: Boolean, + imagePrivacy: ImagePrivacy, + asyncJobStatusCallback: AsyncJobStatusCallback, + clipping: MobileSegment.WireframeClip? = null, + shapeStyle: MobileSegment.ShapeStyle? = null, + border: MobileSegment.ShapeBorder? = null + ): MobileSegment.Wireframe? + /** * Asks the helper to create an image wireframe, and process the provided drawable in the background. * @param view the view owning the drawable @@ -37,7 +64,7 @@ interface ImageWireframeHelper { * @param prefix a prefix identifying the drawable in the parent view's context */ // TODO RUM-3666 limit the number of params to this function - fun createImageWireframe( + fun createImageWireframeByDrawable( view: View, imagePrivacy: ImagePrivacy, currentWireframeIndex: Int, diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt index 345c5f0501..b9967fe236 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt @@ -213,7 +213,7 @@ internal abstract class BaseCheckableTextViewMapperTest : val expectedY = testedCheckableTextViewMapper .resolveCheckableBounds(mockCheckableTextView, fakeMappingContext.systemInformation.screenDensity).y // Then - verify(fakeMappingContext.imageWireframeHelper).createImageWireframe( + verify(fakeMappingContext.imageWireframeHelper).createImageWireframeByDrawable( view = eq(mockCheckableTextView), imagePrivacy = eq(ImagePrivacy.MASK_LARGE_ONLY), currentWireframeIndex = anyInt(), @@ -256,7 +256,7 @@ internal abstract class BaseCheckableTextViewMapperTest : val expectedY = testedCheckableTextViewMapper .resolveCheckableBounds(mockCheckableTextView, fakeMappingContext.systemInformation.screenDensity).y // Then - verify(fakeMappingContext.imageWireframeHelper).createImageWireframe( + verify(fakeMappingContext.imageWireframeHelper).createImageWireframeByDrawable( view = eq(mockCheckableTextView), imagePrivacy = eq(ImagePrivacy.MASK_LARGE_ONLY), currentWireframeIndex = anyInt(), diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapperTest.kt index f47c2d5383..ddd0b5a6c8 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapperTest.kt @@ -284,7 +284,7 @@ internal class ImageViewMapperTest { fun `M call async callback W map() { }`() { // Given whenever( - mockImageWireframeHelper.createImageWireframe( + mockImageWireframeHelper.createImageWireframeByDrawable( view = any(), imagePrivacy = any(), currentWireframeIndex = any(), @@ -315,7 +315,7 @@ internal class ImageViewMapperTest { assertThat(wireframes[0]).isEqualTo(expectedImageWireframe) verify(mockImageWireframeHelper) - .createImageWireframe( + .createImageWireframeByDrawable( view = any(), imagePrivacy = any(), currentWireframeIndex = any(), @@ -444,7 +444,7 @@ internal class ImageViewMapperTest { returnedWireframe: MobileSegment.Wireframe ) { whenever( - mockImageWireframeHelper.createImageWireframe( + mockImageWireframeHelper.createImageWireframeByDrawable( view = eq(expectedView), imagePrivacy = eq(expectedImagePrivacy), currentWireframeIndex = eq(expectedIndex), diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt index e433886688..9bbdb154bb 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt @@ -87,7 +87,7 @@ internal class SwitchCompatMapperTest : BaseSwitchCompatMapperTest() { } else { assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes) - verify(fakeMappingContext.imageWireframeHelper, times(2)).createImageWireframe( + verify(fakeMappingContext.imageWireframeHelper, times(2)).createImageWireframeByDrawable( view = eq(mockSwitch), imagePrivacy = eq(ImagePrivacy.MASK_LARGE_ONLY), currentWireframeIndex = ArgumentMatchers.anyInt(), @@ -179,7 +179,7 @@ internal class SwitchCompatMapperTest : BaseSwitchCompatMapperTest() { // Then assertThat(resolvedWireframes).isEqualTo(fakeTextWireframes) - verify(fakeMappingContext.imageWireframeHelper, never()).createImageWireframe( + verify(fakeMappingContext.imageWireframeHelper, never()).createImageWireframeByDrawable( view = any(), imagePrivacy = any(), currentWireframeIndex = any(), diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelperTest.kt index d81be43baa..5682c0d73c 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelperTest.kt @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay.internal.recorder.resources import android.content.Context import android.content.res.Resources +import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.InsetDrawable @@ -32,6 +33,8 @@ import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver import com.datadog.android.utils.isCloseTo import com.datadog.android.utils.verifyLog import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.annotation.LongForgery import fr.xgouchet.elmyr.annotation.StringForgery @@ -62,6 +65,7 @@ import java.util.Locale ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) +@Suppress("MaxLineLength") internal class DefaultImageWireframeHelperTest { private lateinit var testedHelper: ImageWireframeHelper @@ -98,26 +102,26 @@ internal class DefaultImageWireframeHelperTest { @Mock lateinit var mockDrawable: Drawable - @Mock // TODO RUM-000 use forgery instead of mock ! - lateinit var mockBounds: GlobalBounds - @Mock lateinit var mockResources: Resources @Mock lateinit var mockDisplayMetrics: DisplayMetrics + @Mock + lateinit var mockBitmap: Bitmap + @Mock lateinit var mockContext: Context - @LongForgery(min = 1) - var fakeGeneratedIdentifier: Long = 0L + @LongForgery + var fakeViewId: Long = 0L - @IntForgery(min = 1, max = 300) - var fakeDrawableWidth: Int = 0 + @FloatForgery(min = 1f, max = 10f) + var fakeDensity: Float = 0.0f - @IntForgery(min = 1, max = 300) - var fakeDrawableHeight: Int = 0 + @LongForgery(min = 1) + var fakeGeneratedIdentifier: Long = 0L private lateinit var fakeDrawableXY: Pair @@ -127,21 +131,24 @@ internal class DefaultImageWireframeHelperTest { @StringForgery lateinit var fakeResourceId: String + @Forgery + lateinit var fakeBounds: GlobalBounds + @BeforeEach fun `set up`(forge: Forge) { - val fakeScreenWidth = 1000 - val fakeScreenHeight = 1000 + val fakeScreenWidth = 655367L + val fakeScreenHeight = 655367L - val randomXLocation = forge.aLong(min = 1, max = (fakeScreenWidth - fakeDrawableWidth).toLong()) - val randomYLocation = forge.aLong(min = 1, max = (fakeScreenHeight - fakeDrawableHeight).toLong()) + val randomXLocation = forge.aLong(min = 1, max = (fakeScreenWidth - fakeBounds.width)) + val randomYLocation = forge.aLong(min = 1, max = (fakeScreenHeight - fakeBounds.height)) fakeDrawableXY = Pair(randomXLocation, randomYLocation) whenever(mockMappingContext.imagePrivacy).thenReturn(ImagePrivacy.MASK_LARGE_ONLY) whenever(mockMappingContext.systemInformation).thenReturn(mockSystemInformation) whenever(mockSystemInformation.screenDensity).thenReturn(0f) whenever(mockViewIdentifierResolver.resolveChildUniqueIdentifier(mockView, "drawable")) .thenReturn(fakeGeneratedIdentifier) - whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) - whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeBounds.width.toInt()) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeBounds.height.toInt()) whenever(mockResources.displayMetrics).thenReturn(mockDisplayMetrics) whenever(mockView.resources).thenReturn(mockResources) whenever(mockView.context).thenReturn(mockContext) @@ -149,9 +156,9 @@ internal class DefaultImageWireframeHelperTest { whenever(mockTextView.resources).thenReturn(mockResources) whenever(mockTextView.context).thenReturn(mockContext) whenever(mockViewUtilsInternal.resolveDrawableBounds(any(), any(), any())) - .thenReturn(mockBounds) - whenever(mockTextView.width).thenReturn(fakeDrawableWidth) - whenever(mockTextView.height).thenReturn(fakeDrawableHeight) + .thenReturn(fakeBounds) + whenever(mockTextView.width).thenReturn(fakeBounds.width.toInt()) + whenever(mockTextView.height).thenReturn(fakeBounds.height.toInt()) whenever(mockTextView.paddingStart).thenReturn(fakePadding) whenever(mockTextView.paddingEnd).thenReturn(fakePadding) whenever(mockTextView.paddingTop).thenReturn(fakePadding) @@ -162,10 +169,6 @@ internal class DefaultImageWireframeHelperTest { DRAWABLE_CHILD_NAME + 1 ) ).thenReturn(fakeGeneratedIdentifier) - whenever(mockBounds.width).thenReturn(fakeDrawableWidth.toLong()) - whenever(mockBounds.height).thenReturn(fakeDrawableHeight.toLong()) - whenever(mockBounds.x).thenReturn(0L) - whenever(mockBounds.y).thenReturn(0L) testedHelper = DefaultImageWireframeHelper( logger = mockLogger, @@ -179,9 +182,9 @@ internal class DefaultImageWireframeHelperTest { // region createImageWireframe @Test - fun `M return content placeholder W createImageWireframe() { ImagePrivacy NONE }`() { + fun `M return content placeholder W createImageWireframeByDrawable() { ImagePrivacy MASK_ALL }`() { // When - val wireframe = testedHelper.createImageWireframe( + val wireframe = testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_ALL, currentWireframeIndex = 0, @@ -201,9 +204,28 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M not return image wireframe W createImageWireframe() { ImagePrivacy ALL }`() { + fun `M return content placeholder W createImageWireframeByBitmap() { ImagePrivacy MASK_ALL }`() { // When - val wireframe = testedHelper.createImageWireframe( + val wireframe = testedHelper.createImageWireframeByBitmap( + id = fakeViewId, + bitmap = mockBitmap, + density = fakeDensity, + imagePrivacy = ImagePrivacy.MASK_ALL, + isContextualImage = false, + globalBounds = fakeBounds, + shapeStyle = null, + border = null, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(wireframe).isInstanceOf(MobileSegment.Wireframe.PlaceholderWireframe::class.java) + } + + @Test + fun `M not return image wireframe W createImageWireframe(usePIIPlaceholder = true) { ImagePrivacy MASK_NONE }`() { + // When + val wireframe = testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_NONE, currentWireframeIndex = 0, @@ -222,13 +244,32 @@ internal class DefaultImageWireframeHelperTest { assertThat(wireframe).isInstanceOf(MobileSegment.Wireframe.ImageWireframe::class.java) } + @Test + fun `M not return image wireframe W createImageWireframe { ImagePrivacy MASK_LARGE_ONLY & isContextual image}`() { + // When + val wireframe = testedHelper.createImageWireframeByBitmap( + id = fakeViewId, + bitmap = mockBitmap, + density = fakeDensity, + imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, + isContextualImage = true, + globalBounds = fakeBounds, + shapeStyle = null, + border = null, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(wireframe).isInstanceOf(MobileSegment.Wireframe.PlaceholderWireframe::class.java) + } + @Test fun `M return null W createImageWireframe() { application context is null }`() { // Given whenever(mockView.context.applicationContext).thenReturn(null) // When - val wireframe = testedHelper.createImageWireframe( + val wireframe = testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = 0, @@ -248,12 +289,12 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M send telemetry W createImageWireframe() { application context is null }`() { + fun `M send telemetry W createImageWireframeByDrawable() { application context is null }`() { // Given whenever(mockView.context.applicationContext).thenReturn(null) // When - testedHelper.createImageWireframe( + testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = 0, @@ -277,12 +318,12 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M log error W createImageWireframe() { resources is null }`() { + fun `M log error W createImageWireframeByDrawable() { resources is null }`() { // Given whenever(mockView.resources).thenReturn(null) // When - testedHelper.createImageWireframe( + testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = 0, @@ -306,13 +347,13 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M return null W createImageWireframe() { id is null }`() { + fun `M return null W createImageWireframeByDrawable() { id is null }`() { // Given whenever(mockViewIdentifierResolver.resolveChildUniqueIdentifier(any(), any())) .thenReturn(null) // When - val wireframe = testedHelper.createImageWireframe( + val wireframe = testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = 0, @@ -333,9 +374,9 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M return null W createImageWireframe() { drawable has no width }`() { + fun `M return null W createImageWireframeByDrawable() { drawable has no width }`() { // When - val wireframe = testedHelper.createImageWireframe( + val wireframe = testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = 0, @@ -355,9 +396,9 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M return null W createImageWireframe() { drawable has no height }`() { + fun `M return null W createImageWireframeByDrawable() { drawable has no height }`() { // When - val wireframe = testedHelper.createImageWireframe( + val wireframe = testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = 0, @@ -377,7 +418,7 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M return wireframe W createImageWireframe()`( + fun `M return wireframe W createImageWireframeByDrawable()`( @Mock mockShapeStyle: MobileSegment.ShapeStyle, @Mock mockBorder: MobileSegment.ShapeBorder, @Mock stubWireframeClip: MobileSegment.WireframeClip @@ -404,8 +445,8 @@ internal class DefaultImageWireframeHelperTest { id = fakeGeneratedIdentifier, x = fakeDrawableXY.first, y = fakeDrawableXY.second, - width = fakeDrawableWidth.toLong(), - height = fakeDrawableHeight.toLong(), + width = fakeBounds.width, + height = fakeBounds.height, shapeStyle = mockShapeStyle, border = mockBorder, resourceId = fakeResourceId, @@ -414,14 +455,14 @@ internal class DefaultImageWireframeHelperTest { ) // When - val wireframe = testedHelper.createImageWireframe( + val wireframe = testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = 0, x = fakeDrawableXY.first, y = fakeDrawableXY.second, - width = fakeDrawableWidth, - height = fakeDrawableHeight, + width = fakeBounds.width.toInt(), + height = fakeBounds.height.toInt(), drawable = mockDrawable, shapeStyle = mockShapeStyle, border = mockBorder, @@ -446,6 +487,61 @@ internal class DefaultImageWireframeHelperTest { assertThat(wireframe).isEqualTo(expectedWireframe) } + @Test + fun `M return wireframe W createImageWireframeByBitmap()`( + @Mock mockShapeStyle: MobileSegment.ShapeStyle, + @Mock mockBorder: MobileSegment.ShapeBorder, + @Mock stubWireframeClip: MobileSegment.WireframeClip + ) { + // Given + whenever( + mockResourceResolver.resolveResourceId( + bitmap = any(), + resourceResolverCallback = any() + ) + ).thenAnswer { + val callback = it.arguments[1] as ResourceResolverCallback + callback.onSuccess(fakeResourceId) + } + + val expectedWireframe = MobileSegment.Wireframe.ImageWireframe( + id = fakeGeneratedIdentifier, + x = fakeBounds.x, + y = fakeBounds.y, + width = fakeBounds.width, + height = fakeBounds.height, + shapeStyle = mockShapeStyle, + border = mockBorder, + resourceId = fakeResourceId, + clip = stubWireframeClip, + isEmpty = false + ) + + // When + val wireframe = testedHelper.createImageWireframeByBitmap( + id = fakeGeneratedIdentifier, + imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, + density = fakeDensity, + globalBounds = fakeBounds, + bitmap = mockBitmap, + shapeStyle = mockShapeStyle, + border = mockBorder, + asyncJobStatusCallback = mockAsyncJobStatusCallback, + isContextualImage = false, + clipping = stubWireframeClip + ) + + // Then + verify(mockResourceResolver).resolveResourceId( + bitmap = any(), + resourceResolverCallback = any() + ) + verify(mockAsyncJobStatusCallback).jobStarted() + verify(mockAsyncJobStatusCallback).jobFinished() + verifyNoMoreInteractions(mockAsyncJobStatusCallback) + assertThat(wireframe).isEqualTo(expectedWireframe) + } + // endregion // region createCompoundDrawableWireframes @@ -479,7 +575,7 @@ internal class DefaultImageWireframeHelperTest { any(), any() ) - ).thenReturn(mockBounds) + ).thenReturn(fakeBounds) val fakeDrawables = arrayOf(null, mockDrawable, null, null) whenever(mockTextView.compoundDrawables) .thenReturn(fakeDrawables) @@ -524,7 +620,7 @@ internal class DefaultImageWireframeHelperTest { any() ) ) - .thenReturn(mockBounds) + .thenReturn(fakeBounds) val fakeDrawables = arrayOf(null, mockDrawable, null, mockDrawable) whenever(mockTextView.compoundDrawables) .thenReturn(fakeDrawables) @@ -592,7 +688,7 @@ internal class DefaultImageWireframeHelperTest { whenever(mockInsetDrawable.drawable).thenReturn(mockGradientDrawable) // When - testedHelper.createImageWireframe( + testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = 0, @@ -624,14 +720,14 @@ internal class DefaultImageWireframeHelperTest { @Test fun `M resolve drawable width and height W createImageWireframe() { TextView }`() { // When - testedHelper.createImageWireframe( + testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = 0, x = 0, y = 0, - width = fakeDrawableWidth, - height = fakeDrawableHeight, + width = fakeBounds.width.toInt(), + height = fakeBounds.height.toInt(), drawable = mockDrawable, shapeStyle = null, border = null, @@ -651,7 +747,7 @@ internal class DefaultImageWireframeHelperTest { resourceResolverCallback = any() ) - assertThat(captor.allValues).containsExactly(fakeDrawableWidth, fakeDrawableHeight) + assertThat(captor.allValues).containsExactly(fakeBounds.width.toInt(), fakeBounds.height.toInt()) } @Test @@ -682,7 +778,7 @@ internal class DefaultImageWireframeHelperTest { } // When - val result = testedHelper.createImageWireframe( + val result = testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = forge.aPositiveInt(), @@ -709,7 +805,7 @@ internal class DefaultImageWireframeHelperTest { whenever(mockImageTypeResolver.isDrawablePII(any(), any())).thenReturn(false) // When - testedHelper.createImageWireframe( + testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = 0, @@ -742,7 +838,7 @@ internal class DefaultImageWireframeHelperTest { whenever(mockImageTypeResolver.isDrawablePII(any(), any())).thenReturn(true) // When - val actualWireframe = testedHelper.createImageWireframe( + val actualWireframe = testedHelper.createImageWireframeByDrawable( view = mockView, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, currentWireframeIndex = 0, diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolverTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolverTest.kt index 0480f64d8a..551c916cad 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolverTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolverTest.kt @@ -229,7 +229,7 @@ internal class ResourceResolverTest { } @Test - fun `M send onReady W resolveResourceId() { failed to get image data }`() { + fun `M send onReady W resolveResourceId(Drawable) { failed to get image data }`() { // Given whenever(mockBitmap.isRecycled) .thenReturn(true) @@ -255,6 +255,28 @@ internal class ResourceResolverTest { verify(mockSerializerCallback).onFailure() } + @Test + fun `M send onReady W resolveResourceId(Bitmap) { failed to get image data }`() { + // Given + whenever(mockBitmap.isRecycled) + .thenReturn(true) + .thenReturn(false) + + val emptyByteArray = ByteArray(0) + + whenever(mockWebPImageCompression.compressBitmap(any())) + .thenReturn(emptyByteArray) + + // When + testedResourceResolver.resolveResourceId( + bitmap = mockBitmap, + resourceResolverCallback = mockSerializerCallback + ) + + // Then + verify(mockSerializerCallback).onFailure() + } + @Test fun `M calculate resourceId W resolveResourceId() { cache miss }`() { // Given @@ -636,7 +658,7 @@ internal class ResourceResolverTest { } @Test - fun `M return all callbacks W resolveResourceId() { multiple threads, first takes longer }`( + fun `M return all callbacks W resolveResourceId(Drawable) { multiple threads, first takes longer }`( @Mock mockFirstCallback: ResourceResolverCallback, @Mock mockSecondCallback: ResourceResolverCallback, @Mock mockFirstDrawable: Drawable, @@ -688,6 +710,51 @@ internal class ResourceResolverTest { verify(mockSecondCallback).onSuccess(fakeSecondResourceId) } + @Test + fun `M return all callbacks W resolveResourceId(Bitmap) { multiple threads, first takes longer }`( + @Mock mockFirstCallback: ResourceResolverCallback, + @Mock mockSecondCallback: ResourceResolverCallback, + @Mock mockFirstBitmap: Bitmap, + @Mock mockSecondBitmap: Bitmap, + @StringForgery fakeFirstResourceId: String, + @StringForgery fakeSecondResourceId: String, + forge: Forge + ) { + // Given + val firstBitmapCompression = forge.aString().toByteArray() + val secondBitmapCompression = forge.aString().toByteArray() + whenever(mockWebPImageCompression.compressBitmap(mockFirstBitmap)).thenReturn(firstBitmapCompression) + whenever(mockWebPImageCompression.compressBitmap(mockSecondBitmap)).thenReturn(secondBitmapCompression) + whenever(mockMD5HashGenerator.generate(firstBitmapCompression)).thenReturn(fakeFirstResourceId) + whenever(mockMD5HashGenerator.generate(secondBitmapCompression)).thenReturn(fakeSecondResourceId) + val countDownLatch = CountDownLatch(2) + val thread1 = Thread { + testedResourceResolver.resolveResourceId( + bitmap = mockFirstBitmap, + resourceResolverCallback = mockFirstCallback + ) + Thread.sleep(1500) + countDownLatch.countDown() + } + val thread2 = Thread { + testedResourceResolver.resolveResourceId( + bitmap = mockSecondBitmap, + resourceResolverCallback = mockSecondCallback + ) + Thread.sleep(500) + countDownLatch.countDown() + } + + // When + thread1.start() + thread2.start() + + // Then + countDownLatch.await() + verify(mockFirstCallback).onSuccess(fakeFirstResourceId) + verify(mockSecondCallback).onSuccess(fakeSecondResourceId) + } + @Test fun `M failover to bitmap creation W resolveResourceId() { bitmapDrawable returned empty bytearray }`( @Mock mockCreatedBitmap: Bitmap