diff --git a/detekt_custom.yml b/detekt_custom.yml index f9c57759d3..5ae85b0ace 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -130,8 +130,10 @@ datadog: - "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.createBitmap(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" + - "android.graphics.Color.parseColor(kotlin.String?):java.lang.IllegalArgumentException" - "android.graphics.drawable.LayerDrawable.getDrawable(kotlin.Int):java.lang.IndexOutOfBoundsException" - "android.net.ConnectivityManager.registerDefaultNetworkCallback(android.net.ConnectivityManager.NetworkCallback):java.lang.IllegalArgumentException,java.lang.SecurityException" - "android.net.ConnectivityManager.unregisterNetworkCallback(android.net.ConnectivityManager.NetworkCallback):java.lang.SecurityException" @@ -468,6 +470,7 @@ datadog: # endregion # region Android Graphics - "android.graphics.Bitmap.recycle()" + - "android.graphics.Canvas.drawColor(kotlin.Int)" - "android.graphics.Canvas.drawColor(kotlin.Int, android.graphics.PorterDuff.Mode)" - "android.graphics.Color.argb(kotlin.Int, kotlin.Int, kotlin.Int, kotlin.Int)" - "android.graphics.Color.blue(kotlin.Int)" @@ -484,6 +487,14 @@ datadog: - "android.graphics.drawable.Drawable.setTintList(android.content.res.ColorStateList?)" - "android.graphics.drawable.RippleDrawable.findIndexByLayerId(kotlin.Int)" - "android.graphics.drawable.DrawableContainer.DrawableContainerState.getChild(kotlin.Int)" + - "android.graphics.Matrix.constructor()" + - "android.graphics.Matrix.preScale(kotlin.Float, kotlin.Float)" + - "android.graphics.Matrix.preTranslate(kotlin.Float, kotlin.Float)" + - "android.graphics.Paint.constructor()" + - "android.graphics.Path.computeBounds(android.graphics.RectF, kotlin.Boolean)" + - "android.graphics.Path.transform(android.graphics.Matrix)" + - "android.graphics.PathMeasure.constructor(android.graphics.Path?, kotlin.Boolean)" + - "android.graphics.PathMeasure.nextContour()" - "android.graphics.Point.constructor()" - "android.graphics.Point.constructor(kotlin.Int, kotlin.Int)" - "android.graphics.Rect.centerX()" @@ -492,6 +503,9 @@ datadog: - "android.graphics.Rect.constructor(kotlin.Int, kotlin.Int, kotlin.Int, kotlin.Int)" - "android.graphics.Rect.height()" - "android.graphics.Rect.width()" + - "android.graphics.RectF.constructor()" + - "android.graphics.RectF.width()" + - "android.graphics.RectF.height()" # endregion # region Androidx APIs - "androidx.appcompat.widget.DatadogActionBarContainerAccessor.constructor(androidx.appcompat.widget.ActionBarContainer)" @@ -510,6 +524,11 @@ datadog: - "androidx.compose.runtime.tooling.CompositionGroup.stableId()" - "androidx.compose.ui.graphics.Color(kotlin.Long)" - "androidx.compose.ui.graphics.Color.toArgb()" + - "androidx.compose.ui.graphics.Matrix.constructor(kotlin.FloatArray)" + - "androidx.compose.ui.graphics.Matrix.scale(kotlin.Float, kotlin.Float, kotlin.Float)" + - "androidx.compose.ui.graphics.Matrix.translate(kotlin.Float, kotlin.Float, kotlin.Float)" + - "androidx.compose.ui.graphics.Path.getBounds()" + - "androidx.compose.ui.graphics.Path.transform(androidx.compose.ui.graphics.Matrix)" - "androidx.compose.ui.layout.LayoutCoordinates.positionInWindow()" - "androidx.compose.ui.layout.LayoutInfo.getModifierInfo()" - "androidx.compose.ui.unit.Density(kotlin.Float, kotlin.Float)" @@ -1141,6 +1160,7 @@ datadog: - "kotlin.Float.toFloat()" - "kotlin.Float.toInt()" - "kotlin.Float.toLong()" + - "kotlin.FloatArray.constructor(kotlin.Int)" - "kotlin.Int.and(kotlin.Int)" - "kotlin.Int.coerceAtMost(kotlin.Int)" - "kotlin.Int.inv()" 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 40c7331c5e..dd623a9762 100644 --- a/features/dd-sdk-android-session-replay-compose/consumer-rules.pro +++ b/features/dd-sdk-android-session-replay-compose/consumer-rules.pro @@ -14,6 +14,15 @@ -keepclassmembers class androidx.compose.foundation.text.modifiers.TextStringSimpleElement { ; } +-keepclassmembers class androidx.compose.material.CheckDrawingCache { + ; +} +-keepclassmembers class androidx.compose.material.CheckboxKt { + ; +} +-keepclassmembers class androidx.compose.ui.draw.DrawBehindElement { + ; +} -keepclassmembers class androidx.compose.foundation.BackgroundElement { ; } diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/CheckboxSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/CheckboxSemanticsNodeMapper.kt new file mode 100644 index 0000000000..ed717b9e4b --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/CheckboxSemanticsNodeMapper.kt @@ -0,0 +1,256 @@ +/* + * 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 CHECKBOX_SIZE16-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.compose.internal.mappers.semantics + +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.state.ToggleableState +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.compose.internal.data.SemanticsWireframe +import com.datadog.android.sessionreplay.compose.internal.data.UiContext +import com.datadog.android.sessionreplay.compose.internal.utils.ColorUtils +import com.datadog.android.sessionreplay.compose.internal.utils.PathUtils +import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.GlobalBounds + +internal class CheckboxSemanticsNodeMapper( + colorStringFormatter: ColorStringFormatter, + private val semanticsUtils: SemanticsUtils = SemanticsUtils(), + private val colorUtils: ColorUtils = ColorUtils(), + private val logger: InternalLogger = InternalLogger.UNBOUND, + private val pathUtils: PathUtils = PathUtils(logger) +) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) { + + override fun map( + semanticsNode: SemanticsNode, + parentContext: UiContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): SemanticsWireframe { + val globalBounds = resolveBounds(semanticsNode) + + val checkableWireframes = if (isCheckboxMasked(parentContext)) { + listOf( + resolveMaskedCheckable( + semanticsNode = semanticsNode, + globalBounds = globalBounds + ) + ) + } else { + createCheckboxWireframes( + parentContext = parentContext, + asyncJobStatusCallback = asyncJobStatusCallback, + semanticsNode = semanticsNode, + globalBounds = globalBounds, + currentIndex = 0 + ) + } + + return SemanticsWireframe( + uiContext = null, + wireframes = checkableWireframes + ) + } + + private fun isCheckboxMasked(parentContext: UiContext): Boolean = + parentContext.textAndInputPrivacy != TextAndInputPrivacy.MASK_SENSITIVE_INPUTS + + private fun resolveMaskedCheckable( + semanticsNode: SemanticsNode, + globalBounds: GlobalBounds + ): MobileSegment.Wireframe { + // TODO RUM-5118: Decide how to display masked checkbox, Currently use old unchecked shape wireframe, + return createUncheckedState( + semanticsNode = semanticsNode, + globalBounds = globalBounds, + backgroundColor = DEFAULT_COLOR_WHITE, + borderColor = DEFAULT_COLOR_BLACK, + currentIndex = 0 + ) + } + + private fun createCheckboxWireframes( + parentContext: UiContext, + asyncJobStatusCallback: AsyncJobStatusCallback, + semanticsNode: SemanticsNode, + globalBounds: GlobalBounds, + currentIndex: Int + ): List { + val borderColor = resolveBorderColor(semanticsNode) + val rawFillColor = semanticsUtils.resolveCheckboxFillColor(semanticsNode) + val rawCheckmarkColor = semanticsUtils.resolveCheckmarkColor(semanticsNode) + val fillColorRgba = rawFillColor?.let { convertColor(it) } ?: DEFAULT_COLOR_WHITE + val checkmarkColorRgba = rawCheckmarkColor?.let { convertColor(it) } + ?: getFallbackCheckmarkColor(DEFAULT_COLOR_WHITE) + val parsedFillColor = colorUtils.parseColorSafe(fillColorRgba) + val isChecked = isCheckboxChecked(semanticsNode) + val checkmarkColor = resolveCheckmarkColor(isChecked, checkmarkColorRgba, parsedFillColor) + + val wireframes = mutableListOf() + + if (parsedFillColor != null && checkmarkColor != null) { + val composePath = semanticsUtils + .resolveCheckPath(semanticsNode) + + val androidPath = composePath?.let { checkPath -> + pathUtils.asAndroidPathSafe(checkPath) + } + + if (androidPath != null) { + parentContext.imageWireframeHelper.createImageWireframeByPath( + id = resolveId(semanticsNode, currentIndex), + globalBounds = globalBounds, + path = androidPath, + strokeColor = checkmarkColor, + strokeWidth = STROKE_WIDTH_DP.toInt(), + targetWidth = CHECKBOX_SIZE_DP, + targetHeight = CHECKBOX_SIZE_DP, + density = parentContext.density, + isContextualImage = false, + imagePrivacy = ImagePrivacy.MASK_NONE, + asyncJobStatusCallback = asyncJobStatusCallback, + clipping = null, + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = fillColorRgba, + opacity = 1f, + cornerRadius = CHECKBOX_CORNER_RADIUS + ), + border = MobileSegment.ShapeBorder( + color = borderColor, + width = BOX_BORDER_WIDTH_DP + ), + customResourceIdCacheKey = null + )?.let { imageWireframe -> + wireframes.add(imageWireframe) + } + } + } + + if (wireframes.isNotEmpty()) { + return wireframes + } + + // if we failed to create a wireframe from the path + return createManualCheckedWireframes( + semanticsNode = semanticsNode, + globalBounds = globalBounds, + backgroundColor = fillColorRgba, + borderColor = borderColor + ) + } + + private fun resolveCheckmarkColor(isChecked: Boolean, checkmarkColorRgba: String, fillColor: Int?): Int? = + if (isChecked) { + colorUtils.parseColorSafe(checkmarkColorRgba) + } else { + fillColor + } + + private fun resolveBorderColor(semanticsNode: SemanticsNode): String { + return semanticsUtils.resolveBorderColor(semanticsNode) + ?.let { rawColor -> + convertColor(rawColor) + } ?: DEFAULT_COLOR_BLACK + } + + private fun createManualCheckedWireframes( + semanticsNode: SemanticsNode, + globalBounds: GlobalBounds, + backgroundColor: String, + borderColor: String + ): List { + val strokeColor = getFallbackCheckmarkColor(backgroundColor) + + val wireframes = mutableListOf() + + val background = createUncheckedState( + semanticsNode = semanticsNode, + globalBounds = globalBounds, + backgroundColor = backgroundColor, + borderColor = borderColor, + currentIndex = 0 + ) + + wireframes.add(background) + + val checkmarkWidth = globalBounds.width * CHECKMARK_SIZE_FACTOR + val checkmarkHeight = globalBounds.height * CHECKMARK_SIZE_FACTOR + val xPos = globalBounds.x + ((globalBounds.width / 2) - (checkmarkWidth / 2)) + val yPos = globalBounds.y + ((globalBounds.height / 2) - (checkmarkHeight / 2)) + val foreground = MobileSegment.Wireframe.ShapeWireframe( + id = resolveId(semanticsNode, 1), + x = xPos.toLong(), + y = yPos.toLong(), + width = checkmarkWidth.toLong(), + height = checkmarkHeight.toLong(), + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = strokeColor, + opacity = 1f, + cornerRadius = CHECKBOX_CORNER_RADIUS + ), + border = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BOX_BORDER_WIDTH_DP + ) + ) + + wireframes.add(foreground) + return wireframes + } + + private fun createUncheckedState( + semanticsNode: SemanticsNode, + globalBounds: GlobalBounds, + backgroundColor: String, + borderColor: String, + currentIndex: Int + ) = MobileSegment.Wireframe.ShapeWireframe( + id = resolveId(semanticsNode, currentIndex), + x = globalBounds.x, + y = globalBounds.y, + width = CHECKBOX_SIZE_DP.toLong(), + height = CHECKBOX_SIZE_DP.toLong(), + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = backgroundColor, + opacity = 1f, + cornerRadius = CHECKBOX_CORNER_RADIUS + ), + border = MobileSegment.ShapeBorder( + color = borderColor, + width = BOX_BORDER_WIDTH_DP + ) + ) + + private fun isCheckboxChecked(semanticsNode: SemanticsNode): Boolean = + semanticsNode.config.getOrNull(SemanticsProperties.ToggleableState) == ToggleableState.On + + private fun getFallbackCheckmarkColor(backgroundColor: String?) = + if (backgroundColor == DEFAULT_COLOR_WHITE) { + DEFAULT_COLOR_BLACK + } else { + DEFAULT_COLOR_WHITE + } + + internal companion object { + internal const val DEFAULT_COLOR_BLACK = "#000000FF" + internal const val DEFAULT_COLOR_WHITE = "#FFFFFFFF" + + // when we create the checkmark manually, what % of the checkbox size should it be + internal const val CHECKMARK_SIZE_FACTOR = 0.5 + + // values from Compose Checkbox sourcecode + internal const val BOX_BORDER_WIDTH_DP = 2L + internal const val STROKE_WIDTH_DP = 2f + internal const val CHECKBOX_SIZE_DP = 20 + internal const val CHECKBOX_CORNER_RADIUS = 2f + } +} diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapper.kt index d1157f9dc7..8aa368013d 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapper.kt @@ -31,7 +31,8 @@ internal class RootSemanticsNodeMapper( Role.RadioButton to RadioButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils), Role.Tab to TabSemanticsNodeMapper(colorStringFormatter, semanticsUtils), Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils), - Role.Image to ImageSemanticsNodeMapper(colorStringFormatter, semanticsUtils) + Role.Image to ImageSemanticsNodeMapper(colorStringFormatter, semanticsUtils), + Role.Checkbox to CheckboxSemanticsNodeMapper(colorStringFormatter, semanticsUtils) ), // Text doesn't have a role in semantics, so it should be a fallback mapper. private val textSemanticsNodeMapper: TextSemanticsNodeMapper = TextSemanticsNodeMapper( 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 e0feb83111..652d1d36da 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 @@ -40,6 +40,16 @@ internal object ComposeReflection { val ColorField = BackgroundElementClass?.getDeclaredFieldSafe("color") val ShapeField = BackgroundElementClass?.getDeclaredFieldSafe("shape") + val CheckDrawingCacheClass = getClassSafe("androidx.compose.material.CheckDrawingCache") + val CheckboxKtClass = getClassSafe("androidx.compose.material.CheckboxKt\$CheckboxImpl\$1\$1") + val DrawBehindElementClass = getClassSafe("androidx.compose.ui.draw.DrawBehindElement") + val BorderColorField = CheckboxKtClass?.getDeclaredFieldSafe("\$borderColor\$delegate") + val BoxColorField = CheckboxKtClass?.getDeclaredFieldSafe("\$boxColor\$delegate") + val CheckCacheField = CheckboxKtClass?.getDeclaredFieldSafe("\$checkCache") + val CheckColorField = CheckboxKtClass?.getDeclaredFieldSafe("\$checkColor\$delegate") + val CheckPathField = CheckDrawingCacheClass?.getDeclaredFieldSafe("checkPath") + val OnDrawField = DrawBehindElementClass?.getDeclaredFieldSafe("onDraw") + val PaddingElementClass = getClassSafe("androidx.compose.foundation.layout.PaddingElement") val StartField = PaddingElementClass?.getDeclaredFieldSafe("start") val EndField = PaddingElementClass?.getDeclaredFieldSafe("end") diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ColorUtils.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ColorUtils.kt new file mode 100644 index 0000000000..84caf13c0b --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ColorUtils.kt @@ -0,0 +1,34 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.compose.internal.utils + +import android.graphics.Color +import com.datadog.android.api.InternalLogger +import java.util.Locale + +internal class ColorUtils( + private val logger: InternalLogger = InternalLogger.UNBOUND +) { + internal fun parseColorSafe(color: String): Int? { + return try { + @Suppress("UnsafeThirdPartyFunctionCall") // handling IllegalArgumentException + Color.parseColor(color) + } catch (e: IllegalArgumentException) { + logger.log( + target = InternalLogger.Target.MAINTAINER, + level = InternalLogger.Level.WARN, + messageBuilder = { COLOR_PARSE_ERROR.format(Locale.US, color) }, + throwable = e + ) + null + } + } + + internal companion object { + internal const val COLOR_PARSE_ERROR = "Failed to parse color: %s" + } +} diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/PathUtils.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/PathUtils.kt new file mode 100644 index 0000000000..d6dc9a4db7 --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/PathUtils.kt @@ -0,0 +1,34 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.compose.internal.utils + +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asAndroidPath +import com.datadog.android.api.InternalLogger + +internal class PathUtils( + private val logger: InternalLogger +) { + internal fun asAndroidPathSafe(path: Path): android.graphics.Path? { + return try { + @Suppress("UnsafeThirdPartyFunctionCall") // handling UnsupportedOperationException + path.asAndroidPath() + } catch (e: UnsupportedOperationException) { + logger.log( + target = InternalLogger.Target.MAINTAINER, + level = InternalLogger.Level.WARN, + messageBuilder = { PATH_CONVERSION_ERROR }, + throwable = e + ) + null + } + } + + internal companion object { + internal const val PATH_CONVERSION_ERROR = "Failed to convert Compose Path to Android Path" + } +} diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt index 396803c9cc..f61c70e615 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt @@ -9,10 +9,12 @@ package com.datadog.android.sessionreplay.compose.internal.utils import android.graphics.Bitmap import android.text.StaticLayout import android.view.View +import androidx.compose.animation.core.AnimationState 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.Path import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter @@ -70,6 +72,10 @@ internal class ReflectionUtils { return ComposeReflection.AsyncImagePainterClass?.isInstance(painter) == true } + fun isDrawBehindElementClass(modifier: Modifier): Boolean { + return ComposeReflection.DrawBehindElementClass?.isInstance(modifier) == true + } + fun getOwner(composition: Composition): Any? { return ComposeReflection.OwnerField?.getSafe(composition) } @@ -171,4 +177,28 @@ internal class ReflectionUtils { fun getInteropView(semanticsNode: SemanticsNode): View? { return GetInteropViewMethod?.invoke(semanticsNode.layoutInfo) as? View } + + fun getOnDraw(modifier: Modifier): Any? { + return ComposeReflection.OnDrawField?.getSafe(modifier) + } + + fun getBoxColor(onDrawInstance: Any): AnimationState<*, *>? { + return ComposeReflection.BoxColorField?.getSafe(onDrawInstance) as? AnimationState<*, *> + } + + fun getCheckColor(onDrawInstance: Any): AnimationState<*, *>? { + return ComposeReflection.CheckColorField?.getSafe(onDrawInstance) as? AnimationState<*, *> + } + + fun getBorderColor(onDrawInstance: Any): AnimationState<*, *>? { + return ComposeReflection.BorderColorField?.getSafe(onDrawInstance) as? AnimationState<*, *> + } + + fun getCheckCache(onDrawInstance: Any): Any? { + return ComposeReflection.CheckCacheField?.getSafe(onDrawInstance) + } + + fun getCheckPath(checkCache: Any): Path? { + return ComposeReflection.CheckPathField?.getSafe(checkCache) as? Path + } } diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt index 239cd57dc3..b140ac7ca4 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt @@ -12,6 +12,7 @@ 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.Path import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.vector.VectorPainter @@ -114,6 +115,31 @@ internal class SemanticsUtils(private val reflectionUtils: ReflectionUtils = Ref return backgroundModifier?.let { reflectionUtils.getShape(it) } } + internal fun resolveCheckPath(semanticsNode: SemanticsNode): Path? = + resolveOnDrawInstance(semanticsNode)?.let { onDraw -> + reflectionUtils.getCheckCache(onDraw)?.let { checkCache -> + reflectionUtils.getCheckPath(checkCache) + } + } + + internal fun resolveCheckboxFillColor(semanticsNode: SemanticsNode): Long? = + resolveReflectedProperty( + semanticsNode, + CheckmarkFieldType.FILL_COLOR + ) + + internal fun resolveCheckmarkColor(semanticsNode: SemanticsNode): Long? = + resolveReflectedProperty( + semanticsNode, + CheckmarkFieldType.CHECKMARK_COLOR + ) + + internal fun resolveBorderColor(semanticsNode: SemanticsNode): Long? = + resolveReflectedProperty( + semanticsNode, + CheckmarkFieldType.BORDER_COLOR + ) + private fun shrinkInnerBounds( modifier: Modifier, currentBounds: GlobalBounds @@ -277,4 +303,45 @@ internal class SemanticsUtils(private val reflectionUtils: ReflectionUtils = Ref internal fun getInteropView(semanticsNode: SemanticsNode): View? { return reflectionUtils.getInteropView(semanticsNode) } + + private fun resolveOnDrawInstance(semanticsNode: SemanticsNode): Any? { + val drawBehindElement = + semanticsNode.layoutInfo.getModifierInfo().firstOrNull { modifierInfo -> + reflectionUtils.isDrawBehindElementClass(modifierInfo.modifier) + }?.modifier + + return drawBehindElement?.let { + reflectionUtils.getOnDraw(it) + } + } + + private fun resolveReflectedProperty(semanticsNode: SemanticsNode, fieldType: CheckmarkFieldType): Long? { + val onDrawInstance = resolveOnDrawInstance(semanticsNode) + + val color = onDrawInstance?.let { + when (fieldType) { + CheckmarkFieldType.FILL_COLOR -> { + reflectionUtils.getBoxColor(onDrawInstance) + } + CheckmarkFieldType.CHECKMARK_COLOR -> { + reflectionUtils.getCheckColor(onDrawInstance) + } + CheckmarkFieldType.BORDER_COLOR -> { + reflectionUtils.getBorderColor(onDrawInstance) + } + } + } + + val result = (color?.value as? Color)?.value + + return result?.toLong() + } + + internal companion object { + internal enum class CheckmarkFieldType { + FILL_COLOR, + CHECKMARK_COLOR, + BORDER_COLOR + } + } } diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/CheckboxSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/CheckboxSemanticsNodeMapperTest.kt new file mode 100644 index 0000000000..1f0e983b14 --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/CheckboxSemanticsNodeMapperTest.kt @@ -0,0 +1,516 @@ +/* + * 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 androidx.compose.ui.graphics.Path +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.state.ToggleableState +import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.compose.internal.data.UiContext +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.CheckboxSemanticsNodeMapper.Companion.BOX_BORDER_WIDTH_DP +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.CheckboxSemanticsNodeMapper.Companion.CHECKBOX_CORNER_RADIUS +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.CheckboxSemanticsNodeMapper.Companion.CHECKBOX_SIZE_DP +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.CheckboxSemanticsNodeMapper.Companion.DEFAULT_COLOR_BLACK +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.CheckboxSemanticsNodeMapper.Companion.DEFAULT_COLOR_WHITE +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.CheckboxSemanticsNodeMapper.Companion.STROKE_WIDTH_DP +import com.datadog.android.sessionreplay.compose.internal.utils.ColorUtils +import com.datadog.android.sessionreplay.compose.internal.utils.PathUtils +import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ImageWireframeHelper +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(SessionReplayComposeForgeConfigurator::class) +internal class CheckboxSemanticsNodeMapperTest : AbstractSemanticsNodeMapperTest() { + private lateinit var testedMapper: CheckboxSemanticsNodeMapper + + @Mock + private lateinit var mockSemanticsNode: SemanticsNode + + @Mock + lateinit var mockConfig: SemanticsConfiguration + + @Mock + private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback + + @LongForgery(min = 0xffffffff) + var fakeBorderColor: Long = 0L + + @LongForgery(min = 0xffffffff) + var fakeFillColor: Long = 0L + + @LongForgery(min = 0xffffffff) + var fakeCheckmarkColor: Long = 0L + + @StringForgery(regex = "#[0-9A-F]{8}") + lateinit var fakeBorderColorHexString: String + + @StringForgery(regex = "#[0-9A-F]{8}") + lateinit var fakeFillColorHexString: String + + @StringForgery(regex = "#[0-9A-F]{8}") + lateinit var fakeCheckmarkColorHexString: String + + @Mock + private lateinit var mockUiContext: UiContext + + @Mock + private lateinit var mockPath: Path + + @Mock + private lateinit var mockPathUtils: PathUtils + + @Mock + private lateinit var mockColorUtils: ColorUtils + + @Mock + private lateinit var mockImageWireframeHelper: ImageWireframeHelper + + @BeforeEach + override fun `set up`(forge: Forge) { + super.`set up`(forge) + + whenever(mockUiContext.imageWireframeHelper) + .thenReturn(mockImageWireframeHelper) + + whenever(mockUiContext.density) + .thenReturn(fakeDensity) + + whenever(mockUiContext.textAndInputPrivacy) + .thenReturn(TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) + + whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn rectToBounds( + fakeBounds, + fakeDensity + ) + + mockSemanticsNodeWithBound { + whenever(mockSemanticsNode.layoutInfo).doReturn(mockLayoutInfo) + whenever(mockSemanticsNode.config).doReturn(mockConfig) + } + + whenever(mockPathUtils.asAndroidPathSafe(any())) + .thenReturn(mock()) + + whenever(mockSemanticsUtils.resolveCheckmarkColor(mockSemanticsNode)) + .thenReturn(fakeCheckmarkColor) + + whenever(mockSemanticsUtils.resolveBorderColor(mockSemanticsNode)) + .thenReturn(fakeBorderColor) + + whenever(mockSemanticsUtils.resolveCheckPath(mockSemanticsNode)) + .thenReturn(mockPath) + + mockColorStringFormatter(fakeBorderColor, fakeBorderColorHexString) + mockColorStringFormatter(fakeFillColor, fakeFillColorHexString) + mockColorStringFormatter(fakeCheckmarkColor, fakeCheckmarkColorHexString) + + testedMapper = CheckboxSemanticsNodeMapper( + colorStringFormatter = mockColorStringFormatter, + semanticsUtils = mockSemanticsUtils, + pathUtils = mockPathUtils, + colorUtils = mockColorUtils + ) + } + + @Test + fun `M return the unchecked wireframe W map { unchecked, reflection resolution successful }`() { + // Given + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.Off) + + // When + val semanticsWireframe = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockUiContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + val actualWireframe = semanticsWireframe.wireframes[0] as MobileSegment.Wireframe.ShapeWireframe + + val expectedShapeBorder = MobileSegment.ShapeBorder( + color = fakeBorderColorHexString, + width = BOX_BORDER_WIDTH_DP + ) + + val expectedShapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_WHITE, + opacity = 1f, + cornerRadius = CHECKBOX_CORNER_RADIUS + ) + + assertThat(actualWireframe.border).isEqualTo(expectedShapeBorder) + assertThat(actualWireframe.shapeStyle).isEqualTo(expectedShapeStyle) + } + + @Test + fun `M return the unchecked wireframe W map { unchecked, reflection resolution failure }`() { + // Given + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.Off) + + whenever(mockSemanticsUtils.resolveBorderColor(mockSemanticsNode)) + .thenReturn(null) + + // When + val semanticsWireframe = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockUiContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + val actualWireframe = semanticsWireframe.wireframes[0] as MobileSegment.Wireframe.ShapeWireframe + + val expectedShapeBorder = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BOX_BORDER_WIDTH_DP + ) + + val expectedShapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_WHITE, + opacity = 1f, + cornerRadius = CHECKBOX_CORNER_RADIUS + ) + + assertThat(actualWireframe.border).isEqualTo(expectedShapeBorder) + assertThat(actualWireframe.shapeStyle).isEqualTo(expectedShapeStyle) + } + + @Test + fun `M return fallback W map { checked, reflection resolution failure, got fill color }`() { + // Given + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.On) + + whenever(mockSemanticsUtils.resolveCheckPath(mockSemanticsNode)) + .thenReturn(null) + + // When + val semanticsWireframe = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockUiContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + val backgroundWireframe = semanticsWireframe.wireframes[0] as MobileSegment.Wireframe.ShapeWireframe + + val expectedBgShapeBorder = MobileSegment.ShapeBorder( + color = fakeBorderColorHexString, + width = BOX_BORDER_WIDTH_DP + ) + + val expectedBgShapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_WHITE, + opacity = 1f, + cornerRadius = CHECKBOX_CORNER_RADIUS + ) + + assertThat(backgroundWireframe.border).isEqualTo(expectedBgShapeBorder) + assertThat(backgroundWireframe.shapeStyle).isEqualTo(expectedBgShapeStyle) + } + + @Test + fun `M return the correct fallback fg wireframe W map { checked, reflection resolution failure }`() { + // Given + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.On) + + whenever(mockSemanticsUtils.resolveCheckPath(mockSemanticsNode)) + .thenReturn(null) + + whenever(mockSemanticsUtils.resolveCheckboxFillColor(mockSemanticsNode)) + .thenReturn(fakeFillColor) + + // When + val semanticsWireframe = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockUiContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + val foregroundWireframe = semanticsWireframe.wireframes[1] as MobileSegment.Wireframe.ShapeWireframe + val expectedShapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_WHITE, + opacity = 1f, + cornerRadius = CHECKBOX_CORNER_RADIUS + ) + + assertThat(foregroundWireframe.shapeStyle).isEqualTo(expectedShapeStyle) + } + + @Test + fun `M return fallback fg W map { checked, resolution fail, no fill }`() { + // Given + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.On) + + whenever(mockSemanticsUtils.resolveCheckboxFillColor(mockSemanticsNode)) + .thenReturn(null) + + whenever(mockSemanticsUtils.resolveCheckPath(mockSemanticsNode)) + .thenReturn(null) + + // When + val semanticsWireframe = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockUiContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(semanticsWireframe.wireframes).hasSize(2) + + val foregroundWireframe = semanticsWireframe.wireframes[1] as MobileSegment.Wireframe.ShapeWireframe + val expectedShapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_BLACK, + opacity = 1f, + cornerRadius = CHECKBOX_CORNER_RADIUS + ) + + assertThat(foregroundWireframe.shapeStyle).isEqualTo(expectedShapeStyle) + } + + @Test + fun `M return fallback W map { checked, resolution fail, image wireframe creation fail }`() { + // Given + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.On) + + // When + val semanticsWireframe = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockUiContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + val foregroundWireframe = semanticsWireframe.wireframes[1] as MobileSegment.Wireframe.ShapeWireframe + val expectedShapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_BLACK, + opacity = 1f, + cornerRadius = CHECKBOX_CORNER_RADIUS + ) + + assertThat(foregroundWireframe.shapeStyle).isEqualTo(expectedShapeStyle) + } + + @Test + fun `M return fallback W map { checked, resolution fail, bmp creation from path fail }`() { + // Given + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.On) + + // When + val semanticsWireframe = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockUiContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + val foregroundWireframe = semanticsWireframe.wireframes[1] as MobileSegment.Wireframe.ShapeWireframe + val expectedShapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_BLACK, + opacity = 1f, + cornerRadius = CHECKBOX_CORNER_RADIUS + ) + + assertThat(foregroundWireframe.shapeStyle).isEqualTo(expectedShapeStyle) + } + + @Test + fun `M call image wireframe creation W map { checked, reflection resolution success }`() { + // Given + whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn fakeGlobalBounds + + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.On) + + whenever(mockSemanticsUtils.resolveCheckboxFillColor(mockSemanticsNode)) + .thenReturn(fakeFillColor) + + whenever(mockColorUtils.parseColorSafe(fakeFillColorHexString)) + .thenReturn(fakeFillColor.toInt()) + + whenever(mockColorUtils.parseColorSafe(fakeCheckmarkColorHexString)) + .thenReturn(fakeCheckmarkColor.toInt()) + + whenever( + mockUiContext.imageWireframeHelper.createImageWireframeByPath( + id = any(), + globalBounds = eq(fakeGlobalBounds), + path = any(), + strokeColor = eq(fakeCheckmarkColor.toInt()), + strokeWidth = eq(STROKE_WIDTH_DP.toInt()), + targetWidth = eq(CHECKBOX_SIZE_DP), + targetHeight = eq(CHECKBOX_SIZE_DP), + density = eq(fakeDensity), + imagePrivacy = eq(ImagePrivacy.MASK_NONE), + isContextualImage = eq(false), + asyncJobStatusCallback = eq(mockAsyncJobStatusCallback), + clipping = eq(null), + shapeStyle = eq(null), + border = eq(null), + customResourceIdCacheKey = eq(null) + ) + ).thenReturn(mock()) + + // When + testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockUiContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + val expectedShapeStyle = MobileSegment.ShapeStyle( + backgroundColor = fakeFillColorHexString, + opacity = 1f, + cornerRadius = CHECKBOX_CORNER_RADIUS + ) + + val expectedBorder = MobileSegment.ShapeBorder( + color = fakeBorderColorHexString, + width = BOX_BORDER_WIDTH_DP + ) + + verify(mockUiContext.imageWireframeHelper).createImageWireframeByPath( + id = any(), + globalBounds = eq(fakeGlobalBounds), + path = any(), + strokeColor = eq(fakeCheckmarkColor.toInt()), + strokeWidth = eq(STROKE_WIDTH_DP.toInt()), + targetWidth = eq(CHECKBOX_SIZE_DP), + targetHeight = eq(CHECKBOX_SIZE_DP), + density = eq(fakeDensity), + isContextualImage = eq(false), + imagePrivacy = eq(ImagePrivacy.MASK_NONE), + asyncJobStatusCallback = eq(mockAsyncJobStatusCallback), + clipping = eq(null), + shapeStyle = eq(expectedShapeStyle), + border = eq(expectedBorder), + customResourceIdCacheKey = eq(null) + ) + } + + @Test + fun `M return image wireframe W map { checked, reflection resolution success }`() { + // Given + whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn fakeGlobalBounds + + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.On) + + whenever(mockSemanticsUtils.resolveCheckboxFillColor(mockSemanticsNode)) + .thenReturn(fakeFillColor) + + whenever(mockColorUtils.parseColorSafe(fakeFillColorHexString)) + .thenReturn(fakeFillColor.toInt()) + + whenever(mockColorUtils.parseColorSafe(fakeCheckmarkColorHexString)) + .thenReturn(fakeCheckmarkColor.toInt()) + + whenever( + mockUiContext.imageWireframeHelper.createImageWireframeByPath( + id = any(), + globalBounds = eq(fakeGlobalBounds), + path = any(), + strokeColor = eq(fakeCheckmarkColor.toInt()), + strokeWidth = eq(STROKE_WIDTH_DP.toInt()), + targetWidth = eq(CHECKBOX_SIZE_DP), + targetHeight = eq(CHECKBOX_SIZE_DP), + density = eq(fakeDensity), + isContextualImage = eq(false), + imagePrivacy = eq(ImagePrivacy.MASK_NONE), + asyncJobStatusCallback = eq(mockAsyncJobStatusCallback), + clipping = eq(null), + shapeStyle = anyOrNull(), + border = anyOrNull(), + customResourceIdCacheKey = eq(null) + ) + ).thenReturn(mock()) + + // When + val wireframes = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockUiContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(wireframes.wireframes).hasSize(1) + assertThat(wireframes.wireframes[0]).isInstanceOf(MobileSegment.Wireframe.ImageWireframe::class.java) + } + + @Test + fun `M show unchecked wireframe W map() { masked }`() { + // Given + whenever(mockUiContext.textAndInputPrivacy) + .thenReturn(TextAndInputPrivacy.MASK_ALL_INPUTS) + + // When + val checkboxWireframe = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockUiContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(checkboxWireframe.wireframes).hasSize(1) + val actualWireframe = checkboxWireframe.wireframes[0] as MobileSegment.Wireframe.ShapeWireframe + + verify(mockUiContext.imageWireframeHelper, never()).createImageWireframeByBitmap( + id = any(), + globalBounds = any(), + bitmap = any(), + density = any(), + isContextualImage = any(), + imagePrivacy = any(), + asyncJobStatusCallback = any(), + clipping = anyOrNull(), + shapeStyle = anyOrNull(), + border = anyOrNull() + ) + assertThat(actualWireframe.shapeStyle?.backgroundColor).isEqualTo(DEFAULT_COLOR_WHITE) + assertThat(actualWireframe.border?.color).isEqualTo(DEFAULT_COLOR_BLACK) + } +} diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt index d3aa409f86..a8e401c6c0 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt @@ -71,6 +71,9 @@ class RootSemanticsNodeMapperTest { @Mock private lateinit var mockImageSemanticsNodeMapper: ImageSemanticsNodeMapper + @Mock + private lateinit var mockCheckboxSemanticsNodeMapper: CheckboxSemanticsNodeMapper + @Mock private lateinit var mockComposeHiddenMapper: ComposeHiddenMapper @@ -91,7 +94,8 @@ class RootSemanticsNodeMapperTest { Role.RadioButton to mockRadioButtonSemanticsNodeMapper, Role.Tab to mockTabSemanticsNodeMapper, Role.Button to mockButtonSemanticsNodeMapper, - Role.Image to mockImageSemanticsNodeMapper + Role.Image to mockImageSemanticsNodeMapper, + Role.Checkbox to mockCheckboxSemanticsNodeMapper ), textSemanticsNodeMapper = mockTextSemanticsNodeMapper, containerSemanticsNodeMapper = mockContainerSemanticsNodeMapper, @@ -100,7 +104,7 @@ class RootSemanticsNodeMapperTest { } @Test - fun `M use ContainerSemanticsNodeMapper W map { role is missing }`() { + fun `M use ContainerSemanticsNodeMapper W createComposeWireframes { role is missing }`() { // Given val mockSemanticsNode = mockSemanticsNode(null) @@ -121,7 +125,7 @@ class RootSemanticsNodeMapperTest { } @Test - fun `M use ButtonSemanticsNodeMapper W map { role is Button }`() { + fun `M use ButtonSemanticsNodeMapper W createComposeWireframes { role is Button }`() { // Given val mockSemanticsNode = mockSemanticsNode(Role.Button) @@ -142,7 +146,7 @@ class RootSemanticsNodeMapperTest { } @Test - fun `M use RadioButtonSemanticsNodeMapper W map { role is RadioButton }`() { + fun `M use RadioButtonSemanticsNodeMapper W createComposeWireframes { role is RadioButton }`() { // Given val mockSemanticsNode = mockSemanticsNode(Role.RadioButton) @@ -163,7 +167,7 @@ class RootSemanticsNodeMapperTest { } @Test - fun `M use TabSemanticsNodeMapper W map { role is Tab }`() { + fun `M use TabSemanticsNodeMapper W createComposeWireframes { role is Tab }`() { // Given val mockSemanticsNode = mockSemanticsNode(Role.Tab) @@ -184,7 +188,7 @@ class RootSemanticsNodeMapperTest { } @Test - fun `M use ImageSemanticsNodeMapper W map { role is Image }`() { + fun `M use ImageSemanticsNodeMapper W createComposeWireframes { role is Image }`() { // Given val mockSemanticsNode = mockSemanticsNode(Role.Image) @@ -204,6 +208,27 @@ class RootSemanticsNodeMapperTest { ) } + @Test + fun `M use CheckboxSemanticsNodeMapper W createComposeWireframes { role is Checkbox }`() { + // Given + val mockSemanticsNode = mockSemanticsNode(Role.Checkbox) + + // When + testedRootSemanticsNodeMapper.createComposeWireframes( + mockSemanticsNode, + fakeMappingContext.systemInformation.screenDensity, + fakeMappingContext, + mockAsyncJobStatusCallback + ) + + // Then + verify(mockCheckboxSemanticsNodeMapper).map( + eq(mockSemanticsNode), + any(), + eq(mockAsyncJobStatusCallback) + ) + } + @Test fun `M use ComposeHideMapper W node is hidden`(forge: Forge) { // Given diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt index 31ae4cc5b8..92d8eba653 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay.compose.internal.utils import android.graphics.Bitmap import android.view.View +import androidx.compose.animation.core.AnimationState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composition @@ -16,6 +17,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.vector.VectorPainter @@ -90,6 +92,12 @@ class SemanticsUtilsTest { @Mock private lateinit var mockModifierInfo: ModifierInfo + @Mock + private lateinit var mockOnDraw: Any + + @Mock + private lateinit var mockCheckCache: Any + @Mock private lateinit var mockModifier: Modifier @@ -109,6 +117,9 @@ class SemanticsUtilsTest { whenever(mockModifierInfo.modifier) doReturn mockModifier whenever(mockLayoutInfo.density) doReturn Density(fakeDensity) whenever(mockSemanticsNode.config) doReturn mockConfig + whenever(mockReflectionUtils.isDrawBehindElementClass(mockModifier)) doReturn true + whenever(mockReflectionUtils.getOnDraw(mockModifier)) doReturn mockOnDraw + whenever(mockReflectionUtils.getCheckCache(mockOnDraw)) doReturn mockCheckCache fakeOffset = Offset(x = forge.aFloat(), y = forge.aFloat()) } @@ -146,6 +157,71 @@ class SemanticsUtilsTest { assertThat(result).isEqualTo(mockShape) } + @Test + fun `M return check path W resolveCheckPath`( + @Mock mockPath: Path + ) { + // Given + whenever(mockReflectionUtils.getCheckPath(mockCheckCache)) doReturn mockPath + + // When + val result = testedSemanticsUtils.resolveCheckPath(mockSemanticsNode) + + // Then + assertThat(result).isEqualTo(mockPath) + } + + @Test + fun `M return checkbox fill color W resolveCheckboxFillColor`( + @IntForgery fakeColorValue: Int + ) { + // Given + val fakeColor = Color(fakeColorValue) + val mockAnimationState = mock>() + whenever(mockReflectionUtils.getBoxColor(mockOnDraw)) doReturn mockAnimationState + whenever(mockAnimationState.value).thenReturn(fakeColor) + + // When + val result = testedSemanticsUtils.resolveCheckboxFillColor(mockSemanticsNode) + + // Then + assertThat(result).isEqualTo(fakeColor.value.toLong()) + } + + @Test + fun `M return checkmark color W resolveCheckmarkColor`( + @IntForgery fakeColorValue: Int + ) { + // Given + val fakeColor = Color(fakeColorValue) + val mockAnimationState = mock>() + whenever(mockReflectionUtils.getCheckColor(mockOnDraw)) doReturn mockAnimationState + whenever(mockAnimationState.value).thenReturn(fakeColor) + + // When + val result = testedSemanticsUtils.resolveCheckmarkColor(mockSemanticsNode) + + // Then + assertThat(result).isEqualTo(fakeColor.value.toLong()) + } + + @Test + fun `M return border color W resolveBorderColor`( + @IntForgery fakeColorValue: Int + ) { + // Given + val fakeColor = Color(fakeColorValue) + val mockAnimationState = mock>() + whenever(mockReflectionUtils.getBorderColor(mockOnDraw)) doReturn mockAnimationState + whenever(mockAnimationState.value).thenReturn(fakeColor) + + // When + val result = testedSemanticsUtils.resolveBorderColor(mockSemanticsNode) + + // Then + assertThat(result).isEqualTo(fakeColor.value.toLong()) + } + @Test fun `M return inner bounds W resolveInnerBounds`() { // Given diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index be470a030f..c073ec3f01 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -102,6 +102,12 @@ class com.datadog.android.sessionreplay.recorder.resources.DefaultDrawableCopier override fun copy(android.graphics.drawable.Drawable, android.content.res.Resources): android.graphics.drawable.Drawable? interface com.datadog.android.sessionreplay.recorder.resources.DrawableCopier fun copy(android.graphics.drawable.Drawable, android.content.res.Resources): android.graphics.drawable.Drawable? +class com.datadog.android.sessionreplay.recorder.wrappers.BitmapWrapper + constructor(com.datadog.android.api.InternalLogger = InternalLogger.UNBOUND) + fun createBitmap(Int, Int, android.graphics.Bitmap.Config, android.util.DisplayMetrics? = null): android.graphics.Bitmap? +class com.datadog.android.sessionreplay.recorder.wrappers.CanvasWrapper + constructor(com.datadog.android.api.InternalLogger) + fun createCanvas(android.graphics.Bitmap): android.graphics.Canvas? open class com.datadog.android.sessionreplay.utils.AndroidMDrawableToColorMapper : LegacyDrawableToColorMapper constructor(List = emptyList()) override fun resolveRippleDrawable(android.graphics.drawable.RippleDrawable, com.datadog.android.api.InternalLogger): Int? @@ -134,6 +140,7 @@ 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 createImageWireframeByPath(Long, GlobalBounds, android.graphics.Path, Int, Int, Int, Int, 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, String?): 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, com.datadog.android.sessionreplay.recorder.resources.DrawableCopier = DefaultDrawableCopier(), 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, String?): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe? fun createCompoundDrawableWireframes(android.widget.TextView, com.datadog.android.sessionreplay.recorder.MappingContext, Int, String?, AsyncJobStatusCallback): MutableList 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 4884b056b9..d4021fde85 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 @@ -1520,6 +1520,19 @@ public abstract interface class com/datadog/android/sessionreplay/recorder/resou public abstract fun copy (Landroid/graphics/drawable/Drawable;Landroid/content/res/Resources;)Landroid/graphics/drawable/Drawable; } +public final class com/datadog/android/sessionreplay/recorder/wrappers/BitmapWrapper { + public fun ()V + public fun (Lcom/datadog/android/api/InternalLogger;)V + public synthetic fun (Lcom/datadog/android/api/InternalLogger;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun createBitmap (IILandroid/graphics/Bitmap$Config;Landroid/util/DisplayMetrics;)Landroid/graphics/Bitmap; + public static synthetic fun createBitmap$default (Lcom/datadog/android/sessionreplay/recorder/wrappers/BitmapWrapper;IILandroid/graphics/Bitmap$Config;Landroid/util/DisplayMetrics;ILjava/lang/Object;)Landroid/graphics/Bitmap; +} + +public final class com/datadog/android/sessionreplay/recorder/wrappers/CanvasWrapper { + public fun (Lcom/datadog/android/api/InternalLogger;)V + public final fun createCanvas (Landroid/graphics/Bitmap;)Landroid/graphics/Canvas; +} + public class com/datadog/android/sessionreplay/utils/AndroidMDrawableToColorMapper : com/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper { public fun ()V public fun (Ljava/util/List;)V @@ -1604,6 +1617,7 @@ public abstract interface class com/datadog/android/sessionreplay/utils/ImageWir public abstract fun createCompoundDrawableWireframes (Landroid/widget/TextView;Lcom/datadog/android/sessionreplay/recorder/MappingContext;ILjava/lang/String;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;)Ljava/util/List; 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/recorder/resources/DrawableCopier;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;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; + public abstract fun createImageWireframeByPath (JLcom/datadog/android/sessionreplay/utils/GlobalBounds;Landroid/graphics/Path;IIIIFZLcom/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;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; } public final class com/datadog/android/sessionreplay/utils/ImageWireframeHelper$Companion { @@ -1612,6 +1626,7 @@ public final class com/datadog/android/sessionreplay/utils/ImageWireframeHelper$ public final class com/datadog/android/sessionreplay/utils/ImageWireframeHelper$DefaultImpls { 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/recorder/resources/DrawableCopier;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;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; + public static synthetic fun createImageWireframeByPath$default (Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;JLcom/datadog/android/sessionreplay/utils/GlobalBounds;Landroid/graphics/Path;IIIIFZLcom/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;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/SessionReplayRecorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt index 4552c232ed..04cf968f3a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt @@ -40,6 +40,7 @@ import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreMan import com.datadog.android.sessionreplay.internal.storage.RecordWriter import com.datadog.android.sessionreplay.internal.storage.ResourcesWriter import com.datadog.android.sessionreplay.internal.utils.DrawableUtils +import com.datadog.android.sessionreplay.internal.utils.PathUtils import com.datadog.android.sessionreplay.internal.utils.RumContextProvider import com.datadog.android.sessionreplay.internal.utils.TimeProvider import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector @@ -151,6 +152,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { val resourceResolver = ResourceResolver( applicationId = applicationId, recordedDataQueueHandler = recordedDataQueueHandler, + pathUtils = PathUtils(internalLogger, bitmapCachesManager), bitmapCachesManager = bitmapCachesManager, drawableUtils = DrawableUtils( internalLogger, 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 d9bae6304e..2f84e4ab0a 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 @@ -7,6 +7,7 @@ package com.datadog.android.sessionreplay.internal.recorder.resources import android.graphics.Bitmap +import android.graphics.Path import android.graphics.drawable.Drawable import android.graphics.drawable.InsetDrawable import android.graphics.drawable.LayerDrawable @@ -38,6 +39,93 @@ internal class DefaultImageWireframeHelper( private val imageTypeResolver: ImageTypeResolver ) : ImageWireframeHelper { + @Suppress("ReturnCount") + @UiThread + override fun createImageWireframeByPath( + id: Long, + globalBounds: GlobalBounds, + path: Path, + strokeColor: Int, + strokeWidth: Int, + targetWidth: Int, + targetHeight: Int, + density: Float, + isContextualImage: Boolean, + imagePrivacy: ImagePrivacy, + asyncJobStatusCallback: AsyncJobStatusCallback, + clipping: MobileSegment.WireframeClip?, + shapeStyle: MobileSegment.ShapeStyle?, + border: MobileSegment.ShapeBorder?, + customResourceIdCacheKey: String? + ): MobileSegment.Wireframe { + if (imagePrivacy == ImagePrivacy.MASK_ALL) { + return createContentPlaceholderWireframe( + id = id, + x = globalBounds.x, + y = globalBounds.y, + width = targetWidth.toLong(), + height = targetHeight.toLong(), + label = MASK_ALL_CONTENT_LABEL, + clipping = clipping + ) + } + + // in case we suspect the image is PII, return a placeholder + if (shouldMaskContextualImage( + imagePrivacy = imagePrivacy, + usePIIPlaceholder = isContextualImage, + width = targetWidth.densityNormalized(density), + height = targetHeight.densityNormalized(density) + ) + ) { + return createContentPlaceholderWireframe( + id = id, + x = globalBounds.x, + y = globalBounds.y, + width = targetWidth.toLong(), + height = targetHeight.toLong(), + label = MASK_CONTEXTUAL_CONTENT_LABEL, + clipping = clipping + ) + } + + val imageWireframe = + MobileSegment.Wireframe.ImageWireframe( + id = id, + x = globalBounds.x, + y = globalBounds.y, + width = targetWidth.toLong(), + height = targetHeight.toLong(), + shapeStyle = shapeStyle, + border = border, + clip = clipping, + isEmpty = true + ) + + asyncJobStatusCallback.jobStarted() + + resourceResolver.resolveResourceIdFromPath( + path = path, + strokeColor = strokeColor, + strokeWidth = strokeWidth, + desiredWidth = targetWidth, + desiredHeight = targetHeight, + customResourceIdCacheKey = customResourceIdCacheKey, + 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 createImageWireframeByBitmap( @@ -83,7 +171,7 @@ internal class DefaultImageWireframeHelper( asyncJobStatusCallback.jobStarted() - resourceResolver.resolveResourceId( + resourceResolver.resolveResourceIdFromBitmap( bitmap = bitmap, resourceResolverCallback = object : ResourceResolverCallback { override fun onSuccess(resourceId: String) { @@ -200,7 +288,7 @@ internal class DefaultImageWireframeHelper( asyncJobStatusCallback.jobStarted() - resourceResolver.resolveResourceId( + resourceResolver.resolveResourceIdFromDrawable( resources = resources, applicationContext = applicationContext, displayMetrics = displayMetrics, @@ -363,6 +451,16 @@ internal class DefaultImageWireframeHelper( } } + private fun shouldMaskContextualImage( + imagePrivacy: ImagePrivacy, + usePIIPlaceholder: Boolean, + width: Int, + height: Int + ): Boolean = + imagePrivacy == ImagePrivacy.MASK_LARGE_ONLY && + usePIIPlaceholder && + imageTypeResolver.isPIIByDimensions(width, height) + private fun shouldMaskContextualImage( imagePrivacy: ImagePrivacy, usePIIPlaceholder: Boolean, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ImageTypeResolver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ImageTypeResolver.kt index 4417eb3927..2898eef039 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ImageTypeResolver.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ImageTypeResolver.kt @@ -14,12 +14,15 @@ import com.datadog.android.internal.utils.densityNormalized internal class ImageTypeResolver { fun isDrawablePII(drawable: Drawable, density: Float): Boolean { val isNotGradient = drawable !is GradientDrawable - val widthAboveThreshold = drawable.intrinsicWidth.densityNormalized(density) >= - IMAGE_DIMEN_CONSIDERED_PII_IN_DP - val heightAboveThreshold = drawable.intrinsicHeight.densityNormalized(density) >= - IMAGE_DIMEN_CONSIDERED_PII_IN_DP + val widthDp = drawable.intrinsicWidth.densityNormalized(density) + val heightDp = drawable.intrinsicHeight.densityNormalized(density) - return isNotGradient && (widthAboveThreshold || heightAboveThreshold) + return isNotGradient && isPIIByDimensions(widthDp, heightDp) + } + + fun isPIIByDimensions(width: Int, height: Int): Boolean { + val isGreaterThan = width >= IMAGE_DIMEN_CONSIDERED_PII_IN_DP || height >= IMAGE_DIMEN_CONSIDERED_PII_IN_DP + return isGreaterThan } internal companion object { 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 e436a3cebb..84294b0796 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 @@ -9,6 +9,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.Path import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.util.DisplayMetrics @@ -18,6 +19,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.core.internal.utils.executeSafe import com.datadog.android.sessionreplay.internal.async.DataQueueHandler import com.datadog.android.sessionreplay.internal.utils.DrawableUtils +import com.datadog.android.sessionreplay.internal.utils.PathUtils import com.datadog.android.sessionreplay.recorder.resources.DrawableCopier import java.util.concurrent.ExecutorService import java.util.concurrent.LinkedBlockingDeque @@ -27,6 +29,7 @@ import java.util.concurrent.TimeUnit @Suppress("TooManyFunctions") internal class ResourceResolver( private val bitmapCachesManager: BitmapCachesManager, + private val pathUtils: PathUtils, internal val threadPoolExecutor: ExecutorService = THREADPOOL_EXECUTOR, private val drawableUtils: DrawableUtils, private val webPImageCompression: ImageCompression, @@ -43,37 +46,77 @@ internal class ResourceResolver( // region internal @MainThread - internal fun resolveResourceId( + internal fun resolveResourceIdFromBitmap( bitmap: Bitmap, resourceResolverCallback: ResourceResolverCallback ) { - threadPoolExecutor.executeSafe("resolveResourceId", logger) { - val compressedBitmapBytes = webPImageCompression.compressBitmap(bitmap) + threadPoolExecutor.executeSafe(RESOURCE_RESOLVER_ALIAS, logger) { + getResourceIdFromBitmap(bitmap, resourceResolverCallback) + } + } - // 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() - } + @MainThread + internal fun resolveResourceIdFromPath( + path: Path, + strokeColor: Int, + strokeWidth: Int, + desiredWidth: Int, + desiredHeight: Int, + customResourceIdCacheKey: String?, + resourceResolverCallback: ResourceResolverCallback + ) { + threadPoolExecutor.executeSafe(RESOURCE_RESOLVER_ALIAS, logger) { + val key = + customResourceIdCacheKey + ?: path.let { + pathUtils.generateKeyForPath(it) } - ) + + val resourceId = tryToGetResourceFromCache( + drawable = null, + customResourceIdCacheKey = key + ) + + if (resourceId != null) { + // if we got here it means we saw the bitmap before, + // so we don't need to send the resource again + resourceResolverCallback.onSuccess(resourceId) + return@executeSafe } + + val bitmap = pathUtils.convertPathToBitmap( + checkPath = path, + checkmarkColor = strokeColor, + desiredWidth = desiredWidth, + desiredHeight = desiredHeight, + strokeWidth = strokeWidth + ) + + if (bitmap == null) { + resourceResolverCallback.onFailure() + return@executeSafe + } + + compressAndCacheBitmap( + drawable = null, + bitmap = bitmap, + customResourceIdCacheKey = customResourceIdCacheKey, + 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( + internal fun resolveResourceIdFromDrawable( resources: Resources, applicationContext: Context, displayMetrics: DisplayMetrics, @@ -86,7 +129,8 @@ internal class ResourceResolver( ) { bitmapCachesManager.registerCallbacks(applicationContext) - val resourceId = tryToGetResourceFromCache(drawable = originalDrawable, key = customResourceIdCacheKey) + val resourceId = + tryToGetResourceFromCache(drawable = originalDrawable, customResourceIdCacheKey = customResourceIdCacheKey) if (resourceId != null) { // if we got here it means we saw the bitmap before, @@ -109,8 +153,8 @@ internal class ResourceResolver( } // do in the background - threadPoolExecutor.executeSafe("resolveResourceId", logger) { - createBitmap( + threadPoolExecutor.executeSafe(RESOURCE_RESOLVER_ALIAS, logger) { + createBitmapFromDrawable( drawable = originalDrawable, copiedDrawable = copiedDrawable, drawableWidth = drawableWidth, @@ -137,7 +181,7 @@ internal class ResourceResolver( // region private @WorkerThread - private fun createBitmap( + private fun createBitmapFromDrawable( drawable: Drawable, copiedDrawable: Drawable, drawableWidth: Int, @@ -197,7 +241,7 @@ internal class ResourceResolver( @Suppress("ReturnCount") @WorkerThread private fun resolveResourceHash( - drawable: Drawable, + drawable: Drawable?, bitmap: Bitmap, compressedBitmapBytes: ByteArray, shouldCacheBitmap: Boolean, @@ -236,14 +280,14 @@ internal class ResourceResolver( bitmap: Bitmap, resourceId: String, customResourceIdCacheKey: String?, - drawable: Drawable + drawable: Drawable? ) { if (shouldCacheBitmap) { bitmapCachesManager.putInBitmapPool(bitmap) } val key = customResourceIdCacheKey - ?: bitmapCachesManager.generateResourceKeyFromDrawable(drawable) + ?: generateKey(drawable) ?: return bitmapCachesManager.putInResourceCache(key, resourceId) @@ -267,19 +311,9 @@ internal class ResourceResolver( bitmapCreationCallback = object : BitmapCreationCallback { @WorkerThread override fun onReady(bitmap: Bitmap) { - val compressedBitmapBytes = webPImageCompression.compressBitmap(bitmap) - - // failed to compress bitmap - if (compressedBitmapBytes.isEmpty()) { - resolveResourceCallback.onFailed() - return - } - - resolveResourceHash( + compressAndCacheBitmap( drawable = originalDrawable, bitmap = bitmap, - compressedBitmapBytes = compressedBitmapBytes, - shouldCacheBitmap = true, customResourceIdCacheKey = customResourceIdCacheKey, resolveResourceCallback = resolveResourceCallback ) @@ -293,6 +327,56 @@ internal class ResourceResolver( ) } + @WorkerThread + private fun compressAndCacheBitmap( + drawable: Drawable?, + bitmap: Bitmap, + customResourceIdCacheKey: String?, + resolveResourceCallback: ResolveResourceCallback + ) { + val compressedBitmapBytes = webPImageCompression.compressBitmap(bitmap) + + // failed to compress bitmap + if (compressedBitmapBytes.isEmpty()) { + resolveResourceCallback.onFailed() + return + } + + resolveResourceHash( + drawable = drawable, + bitmap = bitmap, + compressedBitmapBytes = compressedBitmapBytes, + shouldCacheBitmap = true, + customResourceIdCacheKey = customResourceIdCacheKey, + resolveResourceCallback = resolveResourceCallback + ) + } + + @WorkerThread + private fun getResourceIdFromBitmap(bitmap: Bitmap, resourceResolverCallback: ResourceResolverCallback) { + val compressedBitmapBytes = webPImageCompression.compressBitmap(bitmap) + + // failed to compress bitmap + if (compressedBitmapBytes.isEmpty()) { + resourceResolverCallback.onFailure() + return + } 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() + } + } + ) + } + } + @WorkerThread @Suppress("ReturnCount") private fun tryToGetBitmapFromBitmapDrawable( @@ -336,14 +420,22 @@ internal class ResourceResolver( } private fun tryToGetResourceFromCache( - drawable: Drawable, - key: String? + drawable: Drawable?, + customResourceIdCacheKey: String? ): String? { - val cacheKey = key - ?: bitmapCachesManager.generateResourceKeyFromDrawable(drawable) + val key = customResourceIdCacheKey + ?: generateKey(drawable) ?: return null - return bitmapCachesManager.getFromResourceCache(cacheKey) + return bitmapCachesManager.getFromResourceCache(key) + } + + private fun generateKey(drawable: Drawable?): String? { + return if (drawable != null) { + bitmapCachesManager.generateResourceKeyFromDrawable(drawable) + } else { + null + } } private fun shouldUseDrawableBitmap(drawable: BitmapDrawable): Boolean { @@ -370,6 +462,7 @@ internal class ResourceResolver( private const val THREAD_POOL_MAX_KEEP_ALIVE_MS = 5000L private const val CORE_DEFAULT_POOL_SIZE = 1 private const val MAX_THREAD_COUNT = 10 + private const val RESOURCE_RESOLVER_ALIAS = "resolveResourceId" @Suppress("UnsafeThirdPartyFunctionCall") // all parameters are non-negative and queue is not null private val THREADPOOL_EXECUTOR = ThreadPoolExecutor( diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtils.kt index 1e6dadf165..501f5fe923 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtils.kt @@ -18,8 +18,8 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.core.internal.utils.submitSafe import com.datadog.android.sessionreplay.internal.recorder.resources.BitmapCachesManager import com.datadog.android.sessionreplay.internal.recorder.resources.ResourceResolver -import com.datadog.android.sessionreplay.internal.recorder.wrappers.BitmapWrapper -import com.datadog.android.sessionreplay.internal.recorder.wrappers.CanvasWrapper +import com.datadog.android.sessionreplay.recorder.wrappers.BitmapWrapper +import com.datadog.android.sessionreplay.recorder.wrappers.CanvasWrapper import java.util.concurrent.ExecutorService import kotlin.math.sqrt @@ -177,7 +177,12 @@ internal class DrawableUtils( config: Config ): Bitmap? = bitmapCachesManager.getBitmapByProperties(width, height, config) - ?: bitmapWrapper.createBitmap(displayMetrics, width, height, config) + ?: bitmapWrapper.createBitmap( + bitmapWidth = width, + bitmapHeight = height, + config = config, + displayMetrics = displayMetrics + ) internal companion object { @VisibleForTesting diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/PathUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/PathUtils.kt new file mode 100644 index 0000000000..0c07ee6bc4 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/PathUtils.kt @@ -0,0 +1,178 @@ +/* + * 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.internal.utils + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PathMeasure +import android.graphics.RectF +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.internal.recorder.resources.BitmapCachesManager +import com.datadog.android.sessionreplay.internal.recorder.resources.HashGenerator +import com.datadog.android.sessionreplay.internal.recorder.resources.MD5HashGenerator +import com.datadog.android.sessionreplay.recorder.wrappers.BitmapWrapper +import com.datadog.android.sessionreplay.recorder.wrappers.CanvasWrapper + +internal class PathUtils( + private val logger: InternalLogger = InternalLogger.UNBOUND, + private val bitmapCachesManager: BitmapCachesManager, + private val canvasWrapper: CanvasWrapper = CanvasWrapper(logger), + private val bitmapWrapper: BitmapWrapper = BitmapWrapper(), + private val md5Generator: HashGenerator = MD5HashGenerator(logger) +) { + internal fun convertPathToBitmap( + checkPath: Path, + checkmarkColor: Int, + desiredWidth: Int, + desiredHeight: Int, + strokeWidth: Int + ): Bitmap? { + val scaledPath = scalePathToTargetDimensions(checkPath, desiredWidth, desiredHeight) + + val mutableBitmap = bitmapCachesManager.getBitmapByProperties( + width = desiredWidth, + height = desiredHeight, + config = Bitmap.Config.ARGB_8888 + ) + ?: bitmapWrapper.createBitmap(desiredWidth, desiredHeight, Bitmap.Config.ARGB_8888) + ?: return null + + return drawPathOntoBitmap( + bitmap = mutableBitmap, + scaledPath = scaledPath, + strokeWidth = strokeWidth, + checkmarkColor = checkmarkColor + ) + } + + private fun drawPathToBitmap( + checkmarkColor: Int, + path: Path, + targetStrokeWidth: Int, + canvas: Canvas? + ) { + val paint = Paint().apply { + color = checkmarkColor + style = Paint.Style.STROKE + strokeWidth = targetStrokeWidth.toFloat() + isAntiAlias = true + } + + // Draw the Path onto the Canvas + drawPathSafe(canvas, path, paint) + } + + private fun scalePathToTargetDimensions( + path: Path, + targetWidth: Int, + targetHeight: Int + ): Path { + // path initial bounds + val originalBounds = RectF() + + @Suppress("DEPRECATION") // # TODO RUM-7784 replace when possible + path.computeBounds(originalBounds, true) + + // calculate the scale factor + val scaleX = targetWidth / originalBounds.width() + val scaleY = targetHeight / originalBounds.height() + val scaleFactor = minOf(scaleX, scaleY) + + // current center + val currentCenterX = (originalBounds.left + originalBounds.right) / 2 + val currentCenterY = (originalBounds.top + originalBounds.bottom) / 2 + + // new center + val newCenterX = targetWidth / 2 + val newCenterY = targetHeight / 2 + + // center changes after scaling + val scaledCenterX = currentCenterX * scaleFactor + val scaledCenterY = currentCenterY * scaleFactor + + // translation needed to recenter + val translateX = newCenterX - scaledCenterX + val translateY = newCenterY - scaledCenterY + + // the order of operations is important + val matrix = Matrix() + matrix.preTranslate(translateX, translateY) + matrix.preScale(scaleFactor, scaleFactor) + path.transform(matrix) + + return path + } + + private fun drawPathOntoBitmap( + bitmap: Bitmap, + scaledPath: Path, + strokeWidth: Int, + checkmarkColor: Int + ): Bitmap? { + val canvas = canvasWrapper.createCanvas(bitmap) ?: return null + + // draw the checkmark + drawPathToBitmap(checkmarkColor, scaledPath, strokeWidth, canvas) + + return bitmap + } + + @Suppress("UnsafeThirdPartyFunctionCall") // handling IllegalArgumentException + private fun drawPathSafe(canvas: Canvas?, path: Path, paint: Paint) { + try { + canvas?.drawPath(path, paint) + } catch (e: IllegalArgumentException) { + logger.log( + target = InternalLogger.Target.MAINTAINER, + level = InternalLogger.Level.WARN, + messageBuilder = { PATH_DRAW_ERROR }, + throwable = e + ) + } + } + + internal fun generateKeyForPath( + path: Path, + maxPoints: Int = DEFAULT_MAX_PATH_LENGTH, + sampleInterval: Float = DEFAULT_SAMPLE_INTERVAL, + pathMeasure: PathMeasure = PathMeasure(path, false) + ): String? { + val pos = FloatArray(2) + val tan = FloatArray(2) + val sampledPoints = StringBuilder() + var pointCount = 0 + + var distance = 0f + while (distance < pathMeasure.length && pointCount < maxPoints) { + @Suppress("UnsafeThirdPartyFunctionCall") // pos and tan size not lt 2 + pathMeasure.getPosTan(distance, pos, tan) + + sampledPoints.append("${pos[0]},${pos[1]};") + pointCount++ + distance += sampleInterval + if (!pathMeasure.nextContour()) break + } + + val points = sampledPoints.toString() + + return if (points == EMPTY_POINTS) { + null + } else { + md5Generator.generate(points.toByteArray()) + } + } + + internal companion object { + internal const val PATH_DRAW_ERROR = "Failed to draw Path to Canvas" + internal const val EMPTY_POINTS = "0.0,0.0;" + internal const val DEFAULT_MAX_PATH_LENGTH = 1000 + internal const val DEFAULT_SAMPLE_INTERVAL = 10f + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/BitmapWrapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/wrappers/BitmapWrapper.kt similarity index 73% rename from features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/BitmapWrapper.kt rename to features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/wrappers/BitmapWrapper.kt index a14b1f1d6e..0503935b22 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/BitmapWrapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/wrappers/BitmapWrapper.kt @@ -4,24 +4,41 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.sessionreplay.internal.recorder.wrappers +package com.datadog.android.sessionreplay.recorder.wrappers import android.graphics.Bitmap import android.graphics.Bitmap.Config import android.util.DisplayMetrics import com.datadog.android.api.InternalLogger +import com.datadog.android.lint.InternalApi -internal class BitmapWrapper( +/** + * Wraps the Bitmap class to catch potential crashes. + */ +@InternalApi +class BitmapWrapper( private val logger: InternalLogger = InternalLogger.UNBOUND ) { - internal fun createBitmap( - displayMetrics: DisplayMetrics, + /** + * Creates a bitmap with the given parameters. + * @param bitmapWidth the width of the bitmap. + * @param bitmapHeight the height of the bitmap. + * @param config the config of the bitmap. + * @param displayMetrics the optional display metrics to use. + * @return the created bitmap or null if it failed. + */ + fun createBitmap( bitmapWidth: Int, bitmapHeight: Int, - config: Config + config: Config, + displayMetrics: DisplayMetrics? = null ): Bitmap? { return try { - Bitmap.createBitmap(displayMetrics, bitmapWidth, bitmapHeight, config) + if (displayMetrics != null) { + Bitmap.createBitmap(displayMetrics, bitmapWidth, bitmapHeight, config) + } else { + Bitmap.createBitmap(bitmapWidth, bitmapHeight, config) + } } catch (e: IllegalArgumentException) { // should never happen since config is given as valid type and width/height are // normalized to be at least 1 diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/CanvasWrapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/wrappers/CanvasWrapper.kt similarity index 78% rename from features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/CanvasWrapper.kt rename to features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/wrappers/CanvasWrapper.kt index 6f2f5a0e05..bcde43faa1 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/CanvasWrapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/wrappers/CanvasWrapper.kt @@ -4,17 +4,26 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.sessionreplay.internal.recorder.wrappers +package com.datadog.android.sessionreplay.recorder.wrappers import android.graphics.Bitmap import android.graphics.Canvas import com.datadog.android.api.InternalLogger +import com.datadog.android.lint.InternalApi -internal class CanvasWrapper( - private val logger: InternalLogger = InternalLogger.UNBOUND +/** + * Wraps the Canvas class to catch potential crashes. + */ +@InternalApi +class CanvasWrapper( + private val logger: InternalLogger ) { - - internal fun createCanvas(bitmap: Bitmap): Canvas? { + /** + * Creates a canvas with the given bitmap. + * @param bitmap the bitmap to use. + * @return the created canvas or null if it failed. + */ + fun createCanvas(bitmap: Bitmap): Canvas? { if (bitmap.isRecycled || !bitmap.isMutable) { logger.log( level = InternalLogger.Level.ERROR, 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 d627d49fe0..8eb79247b8 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 @@ -7,6 +7,7 @@ package com.datadog.android.sessionreplay.utils import android.graphics.Bitmap +import android.graphics.Path import android.graphics.drawable.Drawable import android.view.View import android.widget.TextView @@ -23,7 +24,45 @@ import com.datadog.android.sessionreplay.recorder.resources.DrawableCopier interface ImageWireframeHelper { /** - * Asks the helper to create an image wireframe based on a given bitmap. + * Creates an image wireframe based on a given path. + * @param id the unique id for the wireframe. + * @param globalBounds the global bounds of the bitmap. + * @param path the path to use to create the wireframe. + * @param strokeColor the color of the stroke. + * @param strokeWidth the width of the stroke. + * @param targetWidth the target width of the image. + * @param targetHeight the target height of the image. + * @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. + * @param customResourceIdCacheKey an optional custom key with which to cache or retrieve from the resource cache. + * If this key is not provided then one will be generated from the path. + */ + @Suppress("LongParameterList") + fun createImageWireframeByPath( + id: Long, + globalBounds: GlobalBounds, + path: Path, + strokeColor: Int, + strokeWidth: Int, + targetWidth: Int, + targetHeight: Int, + density: Float, + isContextualImage: Boolean, + imagePrivacy: ImagePrivacy, + asyncJobStatusCallback: AsyncJobStatusCallback, + clipping: MobileSegment.WireframeClip? = null, + shapeStyle: MobileSegment.ShapeStyle? = null, + border: MobileSegment.ShapeBorder? = null, + customResourceIdCacheKey: String? + ): MobileSegment.Wireframe? + + /** + * Creates 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. @@ -49,7 +88,7 @@ interface ImageWireframeHelper { ): MobileSegment.Wireframe? /** - * Asks the helper to create an image wireframe, and process the provided drawable in the background. + * Creates an image wireframe, and process the provided drawable in the background. * @param view the view owning the drawable * @param imagePrivacy defines which images should be hidden * @param currentWireframeIndex the index of the wireframe in the list of wireframes for the view @@ -65,7 +104,7 @@ interface ImageWireframeHelper { * @param shapeStyle provides a custom shape (e.g. rounded corners) to the image wireframe * @param border provides a custom border to the image wireframe * @param prefix a prefix identifying the drawable in the parent view's context - * @param customResourceIdCacheKey an optional key with which to cache or retrieve from the resource cache. + * @param customResourceIdCacheKey an optional custom key with which to cache or retrieve from the resource cache. * If this key is not provided then one will be generated from the drawable. */ // TODO RUM-3666 limit the number of params to this function @@ -93,7 +132,7 @@ interface ImageWireframeHelper { * @param textView the [TextView] to capture the compound drawables from. * @param mappingContext the [MappingContext] for the [TextView]. * @param prevWireframeIndex the index of the previous wireframe in the list of wireframes for the [TextView]. - * @param customResourceIdCacheKey an optional key with which to cache or retrieve from the resource cache. + * @param customResourceIdCacheKey an optional custom key with which to cache or retrieve from the resource cache. * If this key is not provided then one will be generated from the drawable. * @param asyncJobStatusCallback the callback for the async capture process. */ 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 aa57de1575..0471d5d111 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 @@ -9,6 +9,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.Path import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.InsetDrawable @@ -23,6 +24,7 @@ import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.recorder.ViewUtilsInternal import com.datadog.android.sessionreplay.internal.recorder.resources.DefaultImageWireframeHelper.Companion.APPLICATION_CONTEXT_NULL_ERROR import com.datadog.android.sessionreplay.internal.recorder.resources.DefaultImageWireframeHelper.Companion.RESOURCES_NULL_ERROR +import com.datadog.android.sessionreplay.internal.recorder.resources.ImageTypeResolver.Companion.IMAGE_DIMEN_CONSIDERED_PII_IN_DP import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.SystemInformation @@ -173,6 +175,15 @@ internal class DefaultImageWireframeHelperTest { ) ).thenReturn(fakeGeneratedIdentifier) + whenever( + mockViewUtilsInternal.resolveCompoundDrawableBounds( + view = any(), + drawable = any(), + pixelsDensity = any(), + position = any() + ) + ).thenReturn(fakeBounds) + testedHelper = DefaultImageWireframeHelper( logger = mockLogger, resourceResolver = mockResourceResolver, @@ -182,6 +193,8 @@ internal class DefaultImageWireframeHelperTest { ) } + // region createImageWireframeByBitmap + @Test fun `M return wireframe W createImageWireframeByBitmap`( @Mock mockShapeStyle: MobileSegment.ShapeStyle, @@ -190,7 +203,7 @@ internal class DefaultImageWireframeHelperTest { ) { // Given whenever( - mockResourceResolver.resolveResourceId( + mockResourceResolver.resolveResourceIdFromBitmap( bitmap = any(), resourceResolverCallback = any() ) @@ -227,7 +240,7 @@ internal class DefaultImageWireframeHelperTest { ) // Then - verify(mockResourceResolver).resolveResourceId( + verify(mockResourceResolver).resolveResourceIdFromBitmap( bitmap = any(), resourceResolverCallback = any() ) @@ -237,8 +250,6 @@ internal class DefaultImageWireframeHelperTest { assertThat(wireframe).isEqualTo(expectedWireframe) } - // region createImageWireframeByBitmap - @Test fun `M return content placeholder W createImageWireframeByBitmap { ImagePrivacy MASK_ALL }`() { // When @@ -284,7 +295,7 @@ internal class DefaultImageWireframeHelperTest { .thenReturn(fakeGeneratedIdentifier) whenever( - mockResourceResolver.resolveResourceId( + mockResourceResolver.resolveResourceIdFromBitmap( bitmap = any(), resourceResolverCallback = any() ) @@ -322,7 +333,7 @@ internal class DefaultImageWireframeHelperTest { whenever(mockViewIdentifierResolver.resolveChildUniqueIdentifier(any(), any())) .thenReturn(fakeGeneratedIdentifier) whenever( - mockResourceResolver.resolveResourceId( + mockResourceResolver.resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -390,7 +401,7 @@ internal class DefaultImageWireframeHelperTest { ) // Then - verify(mockResourceResolver).resolveResourceId( + verify(mockResourceResolver).resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -514,7 +525,7 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M send telemetry W createImageWireframeByDrawable() { application context is null }`() { + fun `M send telemetry W createImageWireframeByDrawable { application context is null }`() { // Given whenever(mockView.context.applicationContext).thenReturn(null) @@ -544,7 +555,7 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M log error W createImageWireframeByDrawable() { resources is null }`() { + fun `M log error W createImageWireframeByDrawable { resources is null }`() { // Given whenever(mockView.resources).thenReturn(null) @@ -574,7 +585,7 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M return null W createImageWireframeByDrawable() { id is null }`() { + fun `M return null W createImageWireframeByDrawable { id is null }`() { // Given whenever(mockViewIdentifierResolver.resolveChildUniqueIdentifier(any(), any())) .thenReturn(null) @@ -602,7 +613,7 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M return null W createImageWireframeByDrawable() { drawable has no width }`() { + fun `M return null W createImageWireframeByDrawable { drawable has no width }`() { // When val wireframe = testedHelper.createImageWireframeByDrawable( view = mockView, @@ -625,7 +636,7 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M return null W createImageWireframeByDrawable() { drawable has no height }`() { + fun `M return null W createImageWireframeByDrawable { drawable has no height }`() { // When val wireframe = testedHelper.createImageWireframeByDrawable( view = mockView, @@ -648,7 +659,7 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M return wireframe W createImageWireframeByDrawable()`( + fun `M return wireframe W createImageWireframeByDrawable`( @Mock mockShapeStyle: MobileSegment.ShapeStyle, @Mock mockBorder: MobileSegment.ShapeBorder, @Mock stubWireframeClip: MobileSegment.WireframeClip @@ -657,7 +668,7 @@ internal class DefaultImageWireframeHelperTest { whenever(mockViewIdentifierResolver.resolveChildUniqueIdentifier(any(), any())) .thenReturn(fakeGeneratedIdentifier) whenever( - mockResourceResolver.resolveResourceId( + mockResourceResolver.resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -705,7 +716,7 @@ internal class DefaultImageWireframeHelperTest { ) // Then - verify(mockResourceResolver).resolveResourceId( + verify(mockResourceResolver).resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -730,7 +741,7 @@ internal class DefaultImageWireframeHelperTest { ) { // Given whenever( - mockResourceResolver.resolveResourceId( + mockResourceResolver.resolveResourceIdFromBitmap( bitmap = any(), resourceResolverCallback = any() ) @@ -767,7 +778,7 @@ internal class DefaultImageWireframeHelperTest { ) // Then - verify(mockResourceResolver).resolveResourceId( + verify(mockResourceResolver).resolveResourceIdFromBitmap( bitmap = any(), resourceResolverCallback = any() ) @@ -897,7 +908,7 @@ internal class DefaultImageWireframeHelperTest { // Then val argumentCaptor = argumentCaptor() - verify(mockResourceResolver).resolveResourceId( + verify(mockResourceResolver).resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -945,7 +956,7 @@ internal class DefaultImageWireframeHelperTest { // Then val argumentCaptor = argumentCaptor() - verify(mockResourceResolver, times(2)).resolveResourceId( + verify(mockResourceResolver, times(2)).resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -985,7 +996,7 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M resolve view width and height W createImageWireframe() { RippleDrawable }`( + fun `M resolve view width and height W createImageWireframeByDrawable() { RippleDrawable }`( @Mock mockDrawable: RippleDrawable, @Mock mockInsetDrawable: InsetDrawable, @Mock mockGradientDrawable: GradientDrawable, @@ -1018,7 +1029,7 @@ internal class DefaultImageWireframeHelperTest { // Then val captor = argumentCaptor() - verify(mockResourceResolver).resolveResourceId( + verify(mockResourceResolver).resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -1033,7 +1044,7 @@ internal class DefaultImageWireframeHelperTest { } @Test - fun `M resolve drawable width and height W createImageWireframe() { TextView }`() { + fun `M resolve drawable width and height W createImageWireframeByDrawable { TextView }`() { // When testedHelper.createImageWireframeByDrawable( view = mockView, @@ -1053,7 +1064,7 @@ internal class DefaultImageWireframeHelperTest { // Then val captor = argumentCaptor() - verify(mockResourceResolver).resolveResourceId( + verify(mockResourceResolver).resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -1139,7 +1150,7 @@ internal class DefaultImageWireframeHelperTest { ) // Then - verify(mockResourceResolver).resolveResourceId( + verify(mockResourceResolver).resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -1214,7 +1225,7 @@ internal class DefaultImageWireframeHelperTest { val expectedKey = fakeResourceIdCacheKey + "_$index" // Then - verify(mockResourceResolver).resolveResourceId( + verify(mockResourceResolver).resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -1278,7 +1289,7 @@ internal class DefaultImageWireframeHelperTest { // Then val argumentCaptor = argumentCaptor() - verify(mockResourceResolver).resolveResourceId( + verify(mockResourceResolver).resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -1326,7 +1337,7 @@ internal class DefaultImageWireframeHelperTest { // Then val argumentCaptor = argumentCaptor() - verify(mockResourceResolver, times(2)).resolveResourceId( + verify(mockResourceResolver, times(2)).resolveResourceIdFromDrawable( resources = any(), applicationContext = any(), displayMetrics = any(), @@ -1366,4 +1377,195 @@ internal class DefaultImageWireframeHelperTest { } // endregion + + // region createImageWireframeByPath + + @Test + fun `M return content placeholder W createImageWireframeByPath { ImagePrivacy MASK_ALL }`( + @Mock mockPath: Path, + forge: Forge + ) { + // Given + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeStrokeWidth = forge.aPositiveInt() + val fakeStrokeColor = forge.aPositiveInt() + + // When + val wireframe = testedHelper.createImageWireframeByPath( + id = fakeViewId, + path = mockPath, + imagePrivacy = ImagePrivacy.MASK_ALL, + isContextualImage = false, + globalBounds = fakeBounds, + shapeStyle = null, + border = null, + density = fakeDensity, + strokeWidth = fakeStrokeWidth, + strokeColor = fakeStrokeColor, + targetWidth = fakeWidth, + targetHeight = fakeHeight, + customResourceIdCacheKey = null, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(wireframe).isInstanceOf(MobileSegment.Wireframe.PlaceholderWireframe::class.java) + } + + @Test + fun `M return content placeholder W createImageWireframeByPath { ImagePrivacy MASK_LARGE_ONLY }`( + @Mock mockPath: Path, + forge: Forge + ) { + // Given + val fakeWidth = forge.anInt(min = IMAGE_DIMEN_CONSIDERED_PII_IN_DP) + val fakeHeight = forge.anInt(min = IMAGE_DIMEN_CONSIDERED_PII_IN_DP) + val fakeStrokeWidth = forge.aPositiveInt() + val fakeStrokeColor = forge.aPositiveInt() + whenever(mockImageTypeResolver.isPIIByDimensions(any(), any())).thenReturn(true) + + // When + val wireframe = testedHelper.createImageWireframeByPath( + id = fakeViewId, + path = mockPath, + imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY, + isContextualImage = true, + globalBounds = fakeBounds, + shapeStyle = null, + border = null, + density = fakeDensity, + strokeWidth = fakeStrokeWidth, + strokeColor = fakeStrokeColor, + targetWidth = fakeWidth, + targetHeight = fakeHeight, + customResourceIdCacheKey = null, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(wireframe).isInstanceOf(MobileSegment.Wireframe.PlaceholderWireframe::class.java) + } + + @Test + fun `M return image wireframe W createImageWireframeByPath`( + @Mock mockPath: Path, + forge: Forge + ) { + // Given + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeStrokeWidth = forge.aPositiveInt() + val fakeStrokeColor = forge.aPositiveInt() + + whenever( + mockResourceResolver.resolveResourceIdFromPath( + path = any(), + strokeColor = any(), + strokeWidth = any(), + desiredWidth = any(), + desiredHeight = any(), + customResourceIdCacheKey = anyOrNull(), + resourceResolverCallback = any() + ) + ).thenAnswer { + val callback = it.getArgument(6) + callback.onSuccess(fakeResourceId) + } + + // When + val wireframe = testedHelper.createImageWireframeByPath( + id = fakeViewId, + path = mockPath, + imagePrivacy = ImagePrivacy.MASK_NONE, + isContextualImage = false, + globalBounds = fakeBounds, + shapeStyle = null, + border = null, + density = fakeDensity, + strokeWidth = fakeStrokeWidth, + strokeColor = fakeStrokeColor, + targetWidth = fakeWidth, + targetHeight = fakeHeight, + customResourceIdCacheKey = null, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(wireframe).isInstanceOf(MobileSegment.Wireframe.ImageWireframe::class.java) + verify(mockResourceResolver).resolveResourceIdFromPath( + path = any(), + strokeColor = eq(fakeStrokeColor), + strokeWidth = eq(fakeStrokeWidth), + desiredWidth = eq(fakeWidth), + desiredHeight = eq(fakeHeight), + customResourceIdCacheKey = anyOrNull(), + resourceResolverCallback = any() + ) + verify(mockAsyncJobStatusCallback).jobStarted() + verify(mockAsyncJobStatusCallback).jobFinished() + verifyNoMoreInteractions(mockAsyncJobStatusCallback) + } + + @Test + fun `M call jobFinished W createImageWireframeFromPath { failure }`( + @Mock mockPath: Path, + forge: Forge + ) { + // Given + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeStrokeWidth = forge.aPositiveInt() + val fakeStrokeColor = forge.aPositiveInt() + + whenever( + mockResourceResolver.resolveResourceIdFromPath( + path = any(), + strokeColor = any(), + strokeWidth = any(), + desiredWidth = any(), + desiredHeight = any(), + customResourceIdCacheKey = anyOrNull(), + resourceResolverCallback = any() + ) + ).thenAnswer { + val callback = it.getArgument(6) + callback.onFailure() + } + + // When + val wireframe = testedHelper.createImageWireframeByPath( + id = fakeViewId, + path = mockPath, + imagePrivacy = ImagePrivacy.MASK_NONE, + isContextualImage = false, + globalBounds = fakeBounds, + shapeStyle = null, + border = null, + density = fakeDensity, + strokeWidth = fakeStrokeWidth, + strokeColor = fakeStrokeColor, + targetWidth = fakeWidth, + targetHeight = fakeHeight, + customResourceIdCacheKey = null, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(wireframe).isInstanceOf(MobileSegment.Wireframe.ImageWireframe::class.java) + verify(mockResourceResolver).resolveResourceIdFromPath( + path = any(), + strokeColor = eq(fakeStrokeColor), + strokeWidth = eq(fakeStrokeWidth), + desiredWidth = eq(fakeWidth), + desiredHeight = eq(fakeHeight), + customResourceIdCacheKey = anyOrNull(), + resourceResolverCallback = any() + ) + verify(mockAsyncJobStatusCallback).jobStarted() + verify(mockAsyncJobStatusCallback).jobFinished() + verifyNoMoreInteractions(mockAsyncJobStatusCallback) + } + + // endregion } 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 61a04739f2..b77fc6a346 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 @@ -18,6 +18,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.utils.DrawableUtils +import com.datadog.android.sessionreplay.internal.utils.PathUtils import com.datadog.android.sessionreplay.recorder.resources.DrawableCopier import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery @@ -105,6 +106,9 @@ internal class ResourceResolverTest { @Mock lateinit var mockBitmapDrawable: BitmapDrawable + @Mock + lateinit var mockPathUtils: PathUtils + @Mock lateinit var mockResources: Resources @@ -173,7 +177,7 @@ internal class ResourceResolverTest { } @Test - fun `M get data from cache W resolveResourceId() { cache hit with resourceId }`() { + fun `M get data from cache W resolveResourceIdFromDrawable() { cache hit with resourceId }`() { // Given whenever(mockBitmapCachesManager.getFromResourceCache(fakeResourceKey)).thenReturn(fakeResourceId) @@ -181,7 +185,7 @@ internal class ResourceResolverTest { .thenReturn(fakeImageCompressionByteArray) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -199,7 +203,7 @@ internal class ResourceResolverTest { } @Test - fun `M retry image creation only once W resolveResourceId() { image was recycled while working on it }`() { + fun `M retry image creation once W resolveResourceIdFromDrawable() { image recycled while working on it }`() { // Given whenever(mockDrawableUtils.createScaledBitmap(any(), anyOrNull())) .thenReturn(mockBitmap) @@ -219,7 +223,7 @@ internal class ResourceResolverTest { .thenReturn(fakeImageCompressionByteArray) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -244,7 +248,7 @@ internal class ResourceResolverTest { } @Test - fun `M send onReady W resolveResourceId(Drawable) { failed to get image data }`() { + fun `M send onReady W resolveResourceIdFromDrawable(Drawable) { failed to get image data }`() { // Given whenever(mockBitmap.isRecycled) .thenReturn(true) @@ -256,7 +260,7 @@ internal class ResourceResolverTest { .thenReturn(emptyByteArray) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -283,7 +287,7 @@ internal class ResourceResolverTest { ).doReturn(null) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -300,7 +304,7 @@ internal class ResourceResolverTest { } @Test - fun `M send onReady W resolveResourceId(Bitmap) { failed to get image data }`() { + fun `M send onReady W resolveResourceIdFromDrawable(Bitmap) { failed to get image data }`() { // Given whenever(mockBitmap.isRecycled) .thenReturn(true) @@ -312,7 +316,7 @@ internal class ResourceResolverTest { .thenReturn(emptyByteArray) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromBitmap( bitmap = mockBitmap, resourceResolverCallback = mockSerializerCallback ) @@ -322,12 +326,12 @@ internal class ResourceResolverTest { } @Test - fun `M calculate resourceId W resolveResourceId() { cache miss }`() { + fun `M calculate resourceId W resolveResourceIdFromDrawable() { cache miss }`() { // Given whenever(mockResourcesLRUCache.get(fakeResourceKey)).thenReturn(null) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -352,7 +356,7 @@ internal class ResourceResolverTest { } @Test - fun `M return failure W resolveResourceId { createBitmapOfApproxSizeFromDrawable failed }`() { + fun `M return failure W resolveResourceIdFromDrawable { createBitmapOfApproxSizeFromDrawable failed }`() { // Given whenever(mockResourcesLRUCache.get(fakeResourceKey)).thenReturn(null) whenever( @@ -370,7 +374,7 @@ internal class ResourceResolverTest { } // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -395,6 +399,7 @@ internal class ResourceResolverTest { webPImageCompression = mockWebPImageCompression, drawableUtils = mockDrawableUtils, logger = mockLogger, + pathUtils = mockPathUtils, md5HashGenerator = mockMD5HashGenerator, bitmapCachesManager = mockBitmapCachesManager ) @@ -404,6 +409,7 @@ internal class ResourceResolverTest { webPImageCompression = mockWebPImageCompression, drawableUtils = mockDrawableUtils, logger = mockLogger, + pathUtils = mockPathUtils, md5HashGenerator = mockMD5HashGenerator, bitmapCachesManager = mockBitmapCachesManager ) @@ -415,12 +421,12 @@ internal class ResourceResolverTest { } @Test - fun `M not try to cache resourceId W resolveResourceId() { and did not get resourceId }`() { + fun `M not try to cache resourceId W resolveResourceIdFromDrawable() { and did not get resourceId }`() { // Given whenever(mockMD5HashGenerator.generate(any())).thenReturn(null) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -437,12 +443,12 @@ internal class ResourceResolverTest { } @Test - fun `M not use bitmap from bitmapDrawable W resolveResourceId() { no bitmap }`() { + fun `M not use bitmap from bitmapDrawable W resolveResourceIdFromDrawable() { no bitmap }`() { // Given whenever(mockBitmapDrawable.bitmap).thenReturn(null) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -467,12 +473,12 @@ internal class ResourceResolverTest { } @Test - fun `M not use bitmap from bitmapDrawable W resolveResourceId() { bitmap was recycled }`() { + fun `M not use bitmap from bitmapDrawable W resolveResourceIdFromDrawable() { bitmap was recycled }`() { // Given whenever(mockBitmap.isRecycled).thenReturn(true) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -497,9 +503,9 @@ internal class ResourceResolverTest { } @Test - fun `M use scaled bitmap from bitmapDrawable W resolveResourceId() { has bitmap }`() { + fun `M use scaled bitmap from bitmapDrawable W resolveResourceIdFromDrawable() { has bitmap }`() { // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -519,12 +525,12 @@ internal class ResourceResolverTest { } @Test - fun `M draw bitmap W resolveResourceId() { bitmapDrawable where bitmap has no width }`() { + fun `M draw bitmap W resolveResourceIdFromDrawable() { bitmapDrawable where bitmap has no width }`() { // Given whenever(mockBitmap.width).thenReturn(0) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -553,12 +559,12 @@ internal class ResourceResolverTest { } @Test - fun `M draw bitmap W resolveResourceId() { bitmapDrawable where bitmap has no height }`() { + fun `M draw bitmap W resolveResourceIdFromDrawable() { bitmapDrawable where bitmap has no height }`() { // Given whenever(mockBitmap.height).thenReturn(0) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -587,13 +593,13 @@ internal class ResourceResolverTest { } @Test - fun `M not cache bitmap W resolveResourceId() { BitmapDrawable with bitmap not resized }`() { + fun `M not cache bitmap W resolveResourceIdFromDrawable() { BitmapDrawable with bitmap not resized }`() { // Given whenever(mockDrawableUtils.createScaledBitmap(any(), anyOrNull())) .thenReturn(mockBitmap) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -610,7 +616,7 @@ internal class ResourceResolverTest { } @Test - fun `M cache bitmap W resolveResourceId() { BitmapDrawable width was resized }`( + fun `M cache bitmap W resolveResourceIdFromDrawable() { BitmapDrawable width was resized }`( @Mock mockResizedBitmap: Bitmap, @StringForgery fakeString: String ) { @@ -626,7 +632,7 @@ internal class ResourceResolverTest { whenever(mockDrawableUtils.createScaledBitmap(any(), anyOrNull())).thenReturn(mockResizedBitmap) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -643,7 +649,7 @@ internal class ResourceResolverTest { } @Test - fun `M cache bitmap W resolveResourceId() { BitmapDrawable height was resized }`( + fun `M cache bitmap W resolveResourceIdFromDrawable() { BitmapDrawable height was resized }`( @Mock mockResizedBitmap: Bitmap, @StringForgery fakeString: String ) { @@ -659,7 +665,7 @@ internal class ResourceResolverTest { whenever(mockDrawableUtils.createScaledBitmap(any(), anyOrNull())).thenReturn(mockResizedBitmap) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -676,14 +682,14 @@ internal class ResourceResolverTest { } @Test - fun `M cache bitmap W resolveResourceId() { from BitmapDrawable with null bitmap }`() { + fun `M cache bitmap W resolveResourceIdFromDrawable() { from BitmapDrawable with null bitmap }`() { // Given whenever(mockBitmapCachesManager.getFromResourceCache(fakeResourceKey)) .thenReturn(null) whenever(mockBitmapDrawable.bitmap).thenReturn(null) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -700,12 +706,12 @@ internal class ResourceResolverTest { } @Test - fun `M cache bitmap W resolveResourceId() { not a BitmapDrawable }`() { + fun `M cache bitmap W resolveResourceIdFromDrawable() { not a BitmapDrawable }`() { // Given val mockLayerDrawable = mock() whenever(mockDrawableCopier.copy(any(), any())).thenReturn(mockLayerDrawable) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -722,7 +728,7 @@ internal class ResourceResolverTest { } @Test - fun `M return all callbacks W resolveResourceId(Drawable) { multiple threads, first takes longer }`( + fun `M return all callbacks W resolveResourceIdFromDrawable(Drawable) { multiple threads, first takes longer }`( @Mock mockFirstCallback: ResourceResolverCallback, @Mock mockSecondCallback: ResourceResolverCallback, @Mock mockFirstDrawable: Drawable, @@ -742,7 +748,7 @@ internal class ResourceResolverTest { val countDownLatch = CountDownLatch(2) val thread1 = Thread { - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -757,7 +763,7 @@ internal class ResourceResolverTest { countDownLatch.countDown() } val thread2 = Thread { - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -783,7 +789,7 @@ internal class ResourceResolverTest { } @Test - fun `M return all callbacks W resolveResourceId(Bitmap) { multiple threads, first takes longer }`( + fun `M return all callbacks W resolveResourceIdFromDrawable(Bitmap) { multiple threads, first takes longer }`( @Mock mockFirstCallback: ResourceResolverCallback, @Mock mockSecondCallback: ResourceResolverCallback, @Mock mockFirstBitmap: Bitmap, @@ -801,7 +807,7 @@ internal class ResourceResolverTest { whenever(mockMD5HashGenerator.generate(secondBitmapCompression)).thenReturn(fakeSecondResourceId) val countDownLatch = CountDownLatch(2) val thread1 = Thread { - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromBitmap( bitmap = mockFirstBitmap, resourceResolverCallback = mockFirstCallback ) @@ -809,7 +815,7 @@ internal class ResourceResolverTest { countDownLatch.countDown() } val thread2 = Thread { - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromBitmap( bitmap = mockSecondBitmap, resourceResolverCallback = mockSecondCallback ) @@ -828,7 +834,7 @@ internal class ResourceResolverTest { } @Test - fun `M failover to bitmap creation W resolveResourceId() { bitmapDrawable returned empty bytearray }`( + fun `M failover to bitmap creation W resolveResourceIdFromDrawable() { bitmapDrawable returned empty bytearray }`( @Mock mockCreatedBitmap: Bitmap ) { // Given @@ -852,7 +858,7 @@ internal class ResourceResolverTest { .thenReturn(mockCreatedBitmap) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -877,7 +883,7 @@ internal class ResourceResolverTest { } @Test - fun `M only send resource once W resolveResourceId() { call twice on the same image }`( + fun `M only send resource once W resolveResourceIdFromDrawable() { call twice on the same image }`( @Mock mockCreatedBitmap: Bitmap, @StringForgery fakeResourceId: String, @StringForgery fakeResource: String @@ -904,7 +910,7 @@ internal class ResourceResolverTest { .thenReturn(mockCreatedBitmap) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -919,7 +925,7 @@ internal class ResourceResolverTest { // Then // second time - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -938,7 +944,7 @@ internal class ResourceResolverTest { ) // second time - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -963,7 +969,7 @@ internal class ResourceResolverTest { whenever(mockBitmapCachesManager.generateResourceKeyFromDrawable(mockDrawable)).thenReturn(null) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -987,7 +993,7 @@ internal class ResourceResolverTest { whenever(mockBitmapCachesManager.getFromResourceCache(fakeCacheKey)).thenReturn(fakeResourceId) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -1011,7 +1017,7 @@ internal class ResourceResolverTest { whenever(mockBitmapCachesManager.getFromResourceCache(fakeCacheKey)).thenReturn(null) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -1029,6 +1035,7 @@ internal class ResourceResolverTest { private fun createResourceResolver(): ResourceResolver = ResourceResolver( logger = mockLogger, + pathUtils = mockPathUtils, threadPoolExecutor = mockExecutorService, drawableUtils = mockDrawableUtils, webPImageCompression = mockWebPImageCompression, @@ -1046,7 +1053,7 @@ internal class ResourceResolverTest { whenever(mockDrawableCopier.copy(mockDrawable, mockResources)).thenReturn(mockCopiedDrawable) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -1070,7 +1077,7 @@ internal class ResourceResolverTest { whenever(mockDrawableCopier.copy(mockDrawable, mockResources)).thenReturn(mockCopiedDrawable) // When - testedResourceResolver.resolveResourceId( + testedResourceResolver.resolveResourceIdFromDrawable( resources = mockResources, applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtilsTest.kt index 60b419f7da..2c20ca02cc 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtilsTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableUtilsTest.kt @@ -16,8 +16,8 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.recorder.resources.BitmapCachesManager import com.datadog.android.sessionreplay.internal.recorder.resources.ResourceResolver -import com.datadog.android.sessionreplay.internal.recorder.wrappers.BitmapWrapper -import com.datadog.android.sessionreplay.internal.recorder.wrappers.CanvasWrapper +import com.datadog.android.sessionreplay.recorder.wrappers.BitmapWrapper +import com.datadog.android.sessionreplay.recorder.wrappers.CanvasWrapper import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -30,6 +30,7 @@ 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.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock @@ -103,7 +104,7 @@ internal class DrawableUtilsTest { whenever(mockDrawable.constantState).thenReturn(mockConstantState) whenever(mockCurrentDrawable.constantState).thenReturn(mockConstantState) whenever(mockDrawable.current).thenReturn(mockCurrentDrawable) - whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), any())) + whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), anyOrNull())) .thenReturn(mockBitmap) whenever(mockCanvasWrapper.createCanvas(any())) .thenReturn(mockCanvas) @@ -151,10 +152,10 @@ internal class DrawableUtilsTest { // Then verify(mockBitmapWrapper).createBitmap( - displayMetrics = displayMetricsCaptor.capture(), bitmapWidth = argumentCaptor.capture(), bitmapHeight = argumentCaptor.capture(), - config = any() + config = any(), + displayMetrics = displayMetricsCaptor.capture() ) val width = argumentCaptor.firstValue @@ -186,10 +187,10 @@ internal class DrawableUtilsTest { // Then verify(mockBitmapWrapper).createBitmap( - displayMetrics = displayMetricsCaptor.capture(), bitmapWidth = argumentCaptor.capture(), bitmapHeight = argumentCaptor.capture(), - config = any() + config = any(), + displayMetrics = displayMetricsCaptor.capture() ) val width = argumentCaptor.firstValue @@ -221,10 +222,10 @@ internal class DrawableUtilsTest { // Then verify(mockBitmapWrapper).createBitmap( - displayMetrics = displayMetricsCaptor.capture(), bitmapWidth = argumentCaptor.capture(), bitmapHeight = argumentCaptor.capture(), - config = any() + config = any(), + displayMetrics = displayMetricsCaptor.capture() ) val width = argumentCaptor.firstValue @@ -269,7 +270,7 @@ internal class DrawableUtilsTest { whenever(mockDrawable.intrinsicHeight).thenReturn(1) whenever(mockBitmapCachesManager.getBitmapByProperties(any(), any(), any())) .thenReturn(null) - whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), any())) + whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), anyOrNull())) .thenReturn(null) // When @@ -358,10 +359,10 @@ internal class DrawableUtilsTest { // Then verify(mockBitmapWrapper).createBitmap( - displayMetrics = displayMetricsCaptor.capture(), bitmapWidth = argumentCaptor.capture(), bitmapHeight = argumentCaptor.capture(), - config = any() + config = any(), + displayMetrics = displayMetricsCaptor.capture() ) val width = argumentCaptor.firstValue diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/PathUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/PathUtilsTest.kt new file mode 100644 index 0000000000..7900d9effd --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/PathUtilsTest.kt @@ -0,0 +1,196 @@ +/* + * 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.internal.utils + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Path +import android.graphics.PathMeasure +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.internal.recorder.resources.BitmapCachesManager +import com.datadog.android.sessionreplay.internal.recorder.resources.HashGenerator +import com.datadog.android.sessionreplay.internal.utils.PathUtils.Companion.DEFAULT_MAX_PATH_LENGTH +import com.datadog.android.sessionreplay.recorder.wrappers.BitmapWrapper +import com.datadog.android.sessionreplay.recorder.wrappers.CanvasWrapper +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +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.anyOrNull +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class PathUtilsTest { + private lateinit var testedUtils: PathUtils + + @Mock + private lateinit var mockLogger: InternalLogger + + @Mock + private lateinit var mockCanvasWrapper: CanvasWrapper + + @Mock + private lateinit var mockBitmapWrapper: BitmapWrapper + + @Mock + private lateinit var mockBitmapCachesManager: BitmapCachesManager + + @Mock + private lateinit var mockBitmap: Bitmap + + @Mock + private lateinit var mockCanvas: Canvas + + @LongForgery(min = 0xffffffff) + var fakeCheckmarkColor: Long = 0L + + @Mock lateinit var mockGenerator: HashGenerator + + @Mock lateinit var mockPathMeasure: PathMeasure + + @Mock lateinit var mockPath: Path + + @StringForgery + lateinit var fakeHash: String + + @BeforeEach + fun `set up`(forge: Forge) { + whenever(mockPathMeasure.getPosTan(any(), any(), any())) + .thenReturn(true) + + val fakeContourLength = forge.aFloat(min = DEFAULT_MAX_PATH_LENGTH.toFloat()) + whenever(mockPathMeasure.length).thenReturn(fakeContourLength) + + testedUtils = PathUtils( + logger = mockLogger, + canvasWrapper = mockCanvasWrapper, + bitmapWrapper = mockBitmapWrapper, + bitmapCachesManager = mockBitmapCachesManager, + md5Generator = mockGenerator + ) + } + + @Test + fun `M return null W convertPathToBitmap() { failed to create bitmap }`( + @IntForgery fakeWidth: Int, + @IntForgery fakeHeight: Int, + @IntForgery fakeStrokeWidth: Int + ) { + // Given + whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), anyOrNull())) + .thenReturn(null) + + // When + val result = testedUtils.convertPathToBitmap( + checkPath = mockPath, + desiredWidth = fakeWidth, + desiredHeight = fakeHeight, + strokeWidth = fakeStrokeWidth, + checkmarkColor = fakeCheckmarkColor.toInt() + ) + + // Then + assertThat(result).isNull() + } + + @Test + fun `M return null W convertPathToBitmap() { failed to create canvas }`( + @IntForgery fakeWidth: Int, + @IntForgery fakeHeight: Int, + @IntForgery fakeStrokeWidth: Int + ) { + // Given + whenever(mockCanvasWrapper.createCanvas(any())) + .thenReturn(null) + + // When + val result = testedUtils.convertPathToBitmap( + checkPath = mockPath, + desiredWidth = fakeWidth, + desiredHeight = fakeHeight, + strokeWidth = fakeStrokeWidth, + checkmarkColor = fakeCheckmarkColor.toInt() + ) + + // Then + assertThat(result).isNull() + } + + @Test + fun `M return bitmap W convertPathToBitmap() { success }`( + @IntForgery fakeWidth: Int, + @IntForgery fakeHeight: Int, + @IntForgery fakeStrokeWidth: Int + ) { + // Given + whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), anyOrNull())) + .thenReturn(mockBitmap) + + whenever(mockCanvasWrapper.createCanvas(any())) + .thenReturn(mockCanvas) + + // When + val result = testedUtils.convertPathToBitmap( + checkPath = mockPath, + desiredWidth = fakeWidth, + desiredHeight = fakeHeight, + strokeWidth = fakeStrokeWidth, + checkmarkColor = fakeCheckmarkColor.toInt() + ) + + // Then + assertThat(result).isEqualTo(mockBitmap) + } + + @Test + fun `M return path W generateKeyForPath`() { + // Given + whenever(mockGenerator.generate(any())).thenReturn(fakeHash) + whenever(mockPathMeasure.nextContour()).thenReturn(true) + + // When + val result = testedUtils.generateKeyForPath( + path = mockPath, + pathMeasure = mockPathMeasure + ) + + // Then + assertThat(result).isEqualTo(fakeHash) + } + + @Test + fun `M return null W generateKeyForPath { empty points }`() { + // Given + val emptyPoints = "0.0,0.0;" + whenever(mockGenerator.generate(emptyPoints.toByteArray())).thenReturn("") + whenever(mockPathMeasure.nextContour()).thenReturn(false) + + // When + val result = testedUtils.generateKeyForPath( + path = mockPath, + pathMeasure = mockPathMeasure + ) + + // Then + assertThat(result).isNull() + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/CanvasWrapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/wrappers/CanvasWrapperTest.kt similarity index 96% rename from features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/CanvasWrapperTest.kt rename to features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/wrappers/CanvasWrapperTest.kt index 658c4ae890..4a1323414c 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/CanvasWrapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/wrappers/CanvasWrapperTest.kt @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.sessionreplay.internal.recorder.wrappers +package com.datadog.android.sessionreplay.recorder.wrappers import android.graphics.Bitmap import com.datadog.android.api.InternalLogger