diff --git a/detekt_custom.yml b/detekt_custom.yml index 21b1a95f7f..78c15a2b9c 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -419,7 +419,10 @@ datadog: - "android.graphics.drawable.LayerDrawable.safeGetDrawable(kotlin.Int, com.datadog.android.api.InternalLogger)" - "android.graphics.drawable.RippleDrawable.safeGetDrawable(kotlin.Int, com.datadog.android.api.InternalLogger)" - "android.graphics.Point.constructor()" + - "android.graphics.Rect.centerX()" + - "android.graphics.Rect.centerY()" - "android.graphics.Rect.constructor()" + - "android.graphics.Rect.constructor(kotlin.Int, kotlin.Int, kotlin.Int, kotlin.Int)" - "android.graphics.Rect.height()" - "android.graphics.Rect.width()" # endregion @@ -1084,6 +1087,8 @@ datadog: # endregion # region Kotlin Misc - "kotlin.Any.resolveViewUrl()" + - "kotlin.comparisons.maxOf(kotlin.Float, kotlin.Float)" + - "kotlin.comparisons.minOf(kotlin.Float, kotlin.Float)" - "kotlin.IllegalArgumentException(kotlin.String?)" - "kotlin.IllegalStateException(kotlin.String?)" - "kotlin.Throwable.constructor()" diff --git a/features/dd-sdk-android-session-replay/build.gradle.kts b/features/dd-sdk-android-session-replay/build.gradle.kts index b57ed393fc..b995ef083f 100644 --- a/features/dd-sdk-android-session-replay/build.gradle.kts +++ b/features/dd-sdk-android-session-replay/build.gradle.kts @@ -73,6 +73,7 @@ dependencies { unMock { keep("android.widget.ImageView\$ScaleType") + keep("android.graphics.Rect") } apply(from = "clone_session_replay_schema.gradle.kts") diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacy.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacy.kt index 5887aa4a96..a335cd4b5b 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacy.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacy.kt @@ -11,8 +11,6 @@ import android.view.View import android.widget.Button import android.widget.CheckBox import android.widget.CheckedTextView -import android.widget.EditText -import android.widget.ImageButton import android.widget.ImageView import android.widget.NumberPicker import android.widget.RadioButton @@ -28,8 +26,7 @@ import com.datadog.android.sessionreplay.internal.recorder.mapper.BasePickerMapp import com.datadog.android.sessionreplay.internal.recorder.mapper.ButtonMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckBoxMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckedTextViewMapper -import com.datadog.android.sessionreplay.internal.recorder.mapper.EditTextViewMapper -import com.datadog.android.sessionreplay.internal.recorder.mapper.ImageButtonMapper +import com.datadog.android.sessionreplay.internal.recorder.mapper.ImageViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.MapperTypeWrapper import com.datadog.android.sessionreplay.internal.recorder.mapper.MaskCheckBoxMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.MaskCheckedTextViewMapper @@ -45,8 +42,6 @@ import com.datadog.android.sessionreplay.internal.recorder.mapper.SeekBarWirefra import com.datadog.android.sessionreplay.internal.recorder.mapper.SwitchCompatMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.TextViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.UnsupportedViewMapper -import com.datadog.android.sessionreplay.internal.recorder.mapper.ViewScreenshotWireframeMapper -import com.datadog.android.sessionreplay.internal.recorder.mapper.ViewWireframeMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.WireframeMapper import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator import androidx.appcompat.widget.Toolbar as AppCompatToolbar @@ -86,17 +81,13 @@ enum class SessionReplayPrivacy { val imageWireframeHelper = ImageWireframeHelper(base64Serializer = base64Serializer) val uniqueIdentifierGenerator = UniqueIdentifierGenerator - val viewWireframeMapper = ViewWireframeMapper() val unsupportedViewMapper = UnsupportedViewMapper() - val imageButtonMapper = ImageButtonMapper( - base64Serializer = base64Serializer, + val imageViewMapper = ImageViewMapper( imageWireframeHelper = imageWireframeHelper, uniqueIdentifierGenerator = uniqueIdentifierGenerator ) - val imageMapper: ViewScreenshotWireframeMapper val textMapper: TextViewMapper val buttonMapper: ButtonMapper - val editTextViewMapper: EditTextViewMapper val checkedTextViewMapper: CheckedTextViewMapper val checkBoxMapper: CheckBoxMapper val radioButtonMapper: RadioButtonMapper @@ -105,13 +96,11 @@ enum class SessionReplayPrivacy { val numberPickerMapper: BasePickerMapper? when (this) { ALLOW -> { - imageMapper = ViewScreenshotWireframeMapper(viewWireframeMapper) textMapper = TextViewMapper( imageWireframeHelper = imageWireframeHelper, uniqueIdentifierGenerator = uniqueIdentifierGenerator ) buttonMapper = ButtonMapper(textMapper) - editTextViewMapper = EditTextViewMapper(textMapper) checkedTextViewMapper = CheckedTextViewMapper(textMapper) checkBoxMapper = CheckBoxMapper(textMapper) radioButtonMapper = RadioButtonMapper(textMapper) @@ -120,13 +109,11 @@ enum class SessionReplayPrivacy { numberPickerMapper = getNumberPickerMapper() } MASK -> { - imageMapper = ViewScreenshotWireframeMapper(viewWireframeMapper) textMapper = MaskTextViewMapper( imageWireframeHelper = imageWireframeHelper, uniqueIdentifierGenerator = uniqueIdentifierGenerator ) buttonMapper = ButtonMapper(textMapper) - editTextViewMapper = EditTextViewMapper(textMapper) checkedTextViewMapper = MaskCheckedTextViewMapper(textMapper) checkBoxMapper = MaskCheckBoxMapper(textMapper) radioButtonMapper = MaskRadioButtonMapper(textMapper) @@ -135,13 +122,11 @@ enum class SessionReplayPrivacy { numberPickerMapper = getMaskNumberPickerMapper() } MASK_USER_INPUT -> { - imageMapper = ViewScreenshotWireframeMapper(viewWireframeMapper) textMapper = MaskInputTextViewMapper( imageWireframeHelper = imageWireframeHelper, uniqueIdentifierGenerator = uniqueIdentifierGenerator ) buttonMapper = ButtonMapper(textMapper) - editTextViewMapper = EditTextViewMapper(textMapper) checkedTextViewMapper = MaskCheckedTextViewMapper(textMapper) checkBoxMapper = MaskCheckBoxMapper(textMapper) radioButtonMapper = MaskRadioButtonMapper(textMapper) @@ -156,10 +141,8 @@ enum class SessionReplayPrivacy { MapperTypeWrapper(CheckBox::class.java, checkBoxMapper.toGenericMapper()), MapperTypeWrapper(CheckedTextView::class.java, checkedTextViewMapper.toGenericMapper()), MapperTypeWrapper(Button::class.java, buttonMapper.toGenericMapper()), - MapperTypeWrapper(ImageButton::class.java, imageButtonMapper.toGenericMapper()), - MapperTypeWrapper(EditText::class.java, editTextViewMapper.toGenericMapper()), MapperTypeWrapper(TextView::class.java, textMapper.toGenericMapper()), - MapperTypeWrapper(ImageView::class.java, imageMapper.toGenericMapper()), + MapperTypeWrapper(ImageView::class.java, imageViewMapper.toGenericMapper()), MapperTypeWrapper(AppCompatToolbar::class.java, unsupportedViewMapper.toGenericMapper()) ) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundsUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundsUtils.kt new file mode 100644 index 0000000000..23f8df4704 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundsUtils.kt @@ -0,0 +1,74 @@ +/* + * 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.processor + +import com.datadog.android.sessionreplay.model.MobileSegment + +internal object BoundsUtils { + + internal fun resolveBounds(wireframe: MobileSegment.Wireframe): WireframeBounds { + return when (wireframe) { + is MobileSegment.Wireframe.ShapeWireframe -> wireframe.bounds() + is MobileSegment.Wireframe.TextWireframe -> wireframe.bounds() + is MobileSegment.Wireframe.ImageWireframe -> wireframe.bounds() + is MobileSegment.Wireframe.PlaceholderWireframe -> wireframe.bounds() + } + } + + internal fun isCovering( + top: WireframeBounds, + bottom: WireframeBounds + ): Boolean { + return top.left <= bottom.left && + top.right >= bottom.right && + top.top <= bottom.top && + top.bottom >= bottom.bottom + } + + private fun MobileSegment.Wireframe.ShapeWireframe.bounds(): WireframeBounds { + return WireframeBounds( + left = x + (clip?.left ?: 0), + right = x + width - (clip?.right ?: 0), + top = y + (clip?.top ?: 0), + bottom = y + height - (clip?.bottom ?: 0), + width = width, + height = height + ) + } + + private fun MobileSegment.Wireframe.TextWireframe.bounds(): WireframeBounds { + return WireframeBounds( + left = x + (clip?.left ?: 0), + right = x + width - (clip?.right ?: 0), + top = y + (clip?.top ?: 0), + bottom = y + height - (clip?.bottom ?: 0), + width = width, + height = height + ) + } + private fun MobileSegment.Wireframe.ImageWireframe.bounds(): WireframeBounds { + return WireframeBounds( + left = x + (clip?.left ?: 0), + right = x + width - (clip?.right ?: 0), + top = y + (clip?.top ?: 0), + bottom = y + height - (clip?.bottom ?: 0), + width = width, + height = height + ) + } + + private fun MobileSegment.Wireframe.PlaceholderWireframe.bounds(): WireframeBounds { + return WireframeBounds( + left = x + (clip?.left ?: 0), + right = x + width - (clip?.right ?: 0), + top = y + (clip?.top ?: 0), + bottom = y + height - (clip?.bottom ?: 0), + width = width, + height = height + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeBounds.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeBounds.kt new file mode 100644 index 0000000000..3b973a55cb --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeBounds.kt @@ -0,0 +1,16 @@ +/* + * 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.processor + +internal data class WireframeBounds( + val left: Long, + val right: Long, + val top: Long, + val bottom: Long, + val width: Long, + val height: Long +) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt index f72ae6df0c..10ac6f4589 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt @@ -10,19 +10,19 @@ import com.datadog.android.sessionreplay.internal.utils.hasOpaqueBackground import com.datadog.android.sessionreplay.model.MobileSegment import kotlin.math.max -internal class WireframeUtils { +internal class WireframeUtils(private val boundsUtils: BoundsUtils = BoundsUtils) { internal fun resolveWireframeClip( wireframe: MobileSegment.Wireframe, parents: List ): MobileSegment.WireframeClip? { - var clipTop = 0L - var clipLeft = 0L - var clipRight = 0L - var clipBottom = 0L - val wireframeBounds = wireframe.bounds() - - parents.map { it.bounds() }.forEach { + val previousClip = wireframe.clip() + var clipTop = previousClip?.top ?: 0L + var clipLeft = previousClip?.left ?: 0L + var clipRight = previousClip?.right ?: 0L + var clipBottom = previousClip?.bottom ?: 0L + val wireframeBounds = boundsUtils.resolveBounds(wireframe) + parents.map { boundsUtils.resolveBounds(it) }.forEach { clipTop = max(it.top - wireframeBounds.top, clipTop) clipBottom = max(wireframeBounds.bottom - it.bottom, clipBottom) clipLeft = max(it.left - wireframeBounds.left, clipLeft) @@ -46,9 +46,12 @@ internal class WireframeUtils { wireframe: MobileSegment.Wireframe, topWireframes: List ): Boolean { - val wireframeBounds = wireframe.bounds() + val wireframeBounds = boundsUtils.resolveBounds(wireframe) topWireframes.forEach { - if (it.bounds().isCovering(wireframeBounds) && it.hasOpaqueBackground()) { + val topBounds = boundsUtils.resolveBounds(it) + if (boundsUtils.isCovering(topBounds, wireframeBounds) && + it.hasOpaqueBackground() + ) { return true } } @@ -56,7 +59,7 @@ internal class WireframeUtils { } internal fun checkWireframeIsValid(wireframe: MobileSegment.Wireframe): Boolean { - val wireframeBounds = wireframe.bounds() + val wireframeBounds = boundsUtils.resolveBounds(wireframe) return ( wireframeBounds.width > 0 && wireframeBounds.height > 0 && @@ -68,71 +71,12 @@ internal class WireframeUtils { ) } - private fun Bounds.isCovering(other: Bounds): Boolean { - return left <= other.left && - right >= other.right && - top <= other.top && - bottom >= other.bottom - } - - private fun MobileSegment.Wireframe.bounds(): Bounds { + private fun MobileSegment.Wireframe.clip(): MobileSegment.WireframeClip? { return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> this.bounds() - is MobileSegment.Wireframe.TextWireframe -> this.bounds() - is MobileSegment.Wireframe.ImageWireframe -> this.bounds() - is MobileSegment.Wireframe.PlaceholderWireframe -> this.bounds() + is MobileSegment.Wireframe.ShapeWireframe -> this.clip + is MobileSegment.Wireframe.TextWireframe -> this.clip + is MobileSegment.Wireframe.ImageWireframe -> this.clip + is MobileSegment.Wireframe.PlaceholderWireframe -> this.clip } } - - private fun MobileSegment.Wireframe.ShapeWireframe.bounds(): Bounds { - return Bounds( - left = x + (clip?.left ?: 0), - right = x + width - (clip?.right ?: 0), - top = y + (clip?.top ?: 0), - bottom = y + height - (clip?.bottom ?: 0), - width = width, - height = height - ) - } - - private fun MobileSegment.Wireframe.TextWireframe.bounds(): Bounds { - return Bounds( - left = x + (clip?.left ?: 0), - right = x + width - (clip?.right ?: 0), - top = y + (clip?.top ?: 0), - bottom = y + height - (clip?.bottom ?: 0), - width = width, - height = height - ) - } - private fun MobileSegment.Wireframe.ImageWireframe.bounds(): Bounds { - return Bounds( - left = x + (clip?.left ?: 0), - right = x + width - (clip?.right ?: 0), - top = y + (clip?.top ?: 0), - bottom = y + height - (clip?.bottom ?: 0), - width = width, - height = height - ) - } - - private fun MobileSegment.Wireframe.PlaceholderWireframe.bounds(): Bounds { - return Bounds( - left = x + (clip?.left ?: 0), - right = x + width - (clip?.right ?: 0), - top = y + (clip?.top ?: 0), - bottom = y + height - (clip?.bottom ?: 0), - width = width, - height = height - ) - } - - internal data class Bounds( - val left: Long, - val right: Long, - val top: Long, - val bottom: Long, - val width: Long, - val height: Long - ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64Serializer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64Serializer.kt index 0844672f26..d955f65cc7 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64Serializer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64Serializer.kt @@ -12,19 +12,17 @@ import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.util.DisplayMetrics -import android.widget.ImageView import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import com.datadog.android.api.InternalLogger +import com.datadog.android.core.internal.utils.executeSafe import com.datadog.android.sessionreplay.internal.recorder.base64.Cache.Companion.DOES_NOT_IMPLEMENT_COMPONENTCALLBACKS import com.datadog.android.sessionreplay.internal.utils.Base64Utils -import com.datadog.android.sessionreplay.internal.utils.DrawableDimensions import com.datadog.android.sessionreplay.internal.utils.DrawableUtils import com.datadog.android.sessionreplay.model.MobileSegment import java.util.concurrent.ExecutorService import java.util.concurrent.LinkedBlockingDeque -import java.util.concurrent.RejectedExecutionException import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit @@ -51,29 +49,31 @@ internal class Base64Serializer private constructor( drawableWidth: Int, drawableHeight: Int, imageWireframe: MobileSegment.Wireframe.ImageWireframe, - callback: Base64SerializerCallback? = null + base64SerializerCallback: Base64SerializerCallback ) { registerCallbacks(applicationContext) - tryToGetBase64FromCache(drawable, imageWireframe, callback) - ?: tryToGetBitmapFromBitmapDrawable(drawable, imageWireframe, callback) + tryToGetBase64FromCache(drawable, imageWireframe, base64SerializerCallback) + ?: tryToGetBitmapFromBitmapDrawable( + drawable = drawable, + displayMetrics = displayMetrics, + imageWireframe = imageWireframe, + base64SerializerCallback = base64SerializerCallback + ) ?: tryToDrawNewBitmap( - drawable, - drawableWidth, - drawableHeight, - displayMetrics, - imageWireframe, - callback + drawable = drawable, + drawableWidth = drawableWidth, + drawableHeight = drawableHeight, + displayMetrics = displayMetrics, + imageWireframe = imageWireframe, + base64SerializerCallback = base64SerializerCallback, + + // this parameter is used to avoid infinite recursion + // basically we only allow one attempt to recreate the bitmap + didCallOriginateFromFailover = false ) - ?: callback?.onReady() } - internal fun getDrawableScaledDimensions( - view: ImageView, - drawable: Drawable, - density: Float - ): DrawableDimensions = drawableUtils.getDrawableScaledDimensions(view, drawable, density) - // endregion // region testing @@ -86,15 +86,31 @@ internal class Base64Serializer private constructor( // region private @WorkerThread - private fun serialiseBitmap( + private fun serializeBitmap( drawable: Drawable, + displayMetrics: DisplayMetrics, bitmap: Bitmap, shouldCacheBitmap: Boolean, imageWireframe: MobileSegment.Wireframe.ImageWireframe, - base64SerializerCallback: Base64SerializerCallback? + base64SerializerCallback: Base64SerializerCallback, + + // this parameter is used to avoid infinite recursion + // basically we only allow one attempt to recreate the bitmap + didCallOriginateFromFailover: Boolean ) { - val base64String = convertBmpToBase64(drawable, bitmap, shouldCacheBitmap) - finalizeRecordedDataItem(base64String, imageWireframe, base64SerializerCallback) + val base64String = convertBitmapToBase64( + drawable = drawable, + displayMetrics = displayMetrics, + bitmap = bitmap, + shouldCacheBitmap = shouldCacheBitmap, + imageWireframe = imageWireframe, + base64SerializerCallback = base64SerializerCallback, + didCallOriginateFromFailover = didCallOriginateFromFailover + ) + + if (base64String.isNotEmpty()) { + finalizeRecordedDataItem(base64String, imageWireframe, base64SerializerCallback) + } } @MainThread @@ -124,11 +140,38 @@ internal class Base64Serializer private constructor( } @WorkerThread - private fun convertBmpToBase64(drawable: Drawable, bitmap: Bitmap, shouldCacheBitmap: Boolean): String { + private fun convertBitmapToBase64( + drawable: Drawable, + displayMetrics: DisplayMetrics, + bitmap: Bitmap, + shouldCacheBitmap: Boolean, + imageWireframe: MobileSegment.Wireframe.ImageWireframe, + base64SerializerCallback: Base64SerializerCallback, + + // this parameter is used to avoid infinite recursion + // basically we only allow one attempt to recreate the bitmap + didCallOriginateFromFailover: Boolean + ): String { val base64Result: String val byteArray = webPImageCompression.compressBitmap(bitmap) + // failed to get byteArray + // Try once to recreate bitmap from the drawable + if (byteArray.isEmpty() && bitmap.isRecycled && !didCallOriginateFromFailover) { + tryToDrawNewBitmap( + drawable = drawable, + drawableWidth = bitmap.width, + drawableHeight = bitmap.height, + displayMetrics = displayMetrics, + imageWireframe = imageWireframe, + base64SerializerCallback = base64SerializerCallback, + didCallOriginateFromFailover = true + ) + + return "" + } + base64Result = base64Utils.serializeToBase64String(byteArray) if (base64Result.isNotEmpty()) { @@ -143,65 +186,89 @@ internal class Base64Serializer private constructor( return base64Result } - @MainThread private fun tryToDrawNewBitmap( drawable: Drawable, drawableWidth: Int, drawableHeight: Int, displayMetrics: DisplayMetrics, imageWireframe: MobileSegment.Wireframe.ImageWireframe, - base64SerializerCallback: Base64SerializerCallback? - ): Bitmap? { + base64SerializerCallback: Base64SerializerCallback, + didCallOriginateFromFailover: Boolean + ) { drawableUtils.createBitmapOfApproxSizeFromDrawable( - drawable, - drawableWidth, - drawableHeight, - displayMetrics - )?.let { resizedBitmap -> - serializeBitmapAsynchronously( - drawable, - bitmap = resizedBitmap, - shouldCacheBitmap = true, - imageWireframe, - base64SerializerCallback - ) - return resizedBitmap - } - - return null + drawable = drawable, + drawableWidth = drawableWidth, + drawableHeight = drawableHeight, + displayMetrics = displayMetrics, + base64SerializerCallback = base64SerializerCallback, + bitmapCreationCallback = object : BitmapCreationCallback { + override fun onReady(bitmap: Bitmap) { + Runnable { + @Suppress("ThreadSafety") // this runs inside an executor + serializeBitmap( + drawable = drawable, + displayMetrics = displayMetrics, + bitmap = bitmap, + shouldCacheBitmap = true, + imageWireframe = imageWireframe, + base64SerializerCallback = base64SerializerCallback, + didCallOriginateFromFailover = didCallOriginateFromFailover + ) + }.let { + threadPoolExecutor.executeSafe("tryToDrawNewBitmap", logger, it) + } + } + } + ) } @MainThread private fun tryToGetBitmapFromBitmapDrawable( drawable: Drawable, + displayMetrics: DisplayMetrics, imageWireframe: MobileSegment.Wireframe.ImageWireframe, - base64SerializerCallback: Base64SerializerCallback? + base64SerializerCallback: Base64SerializerCallback ): Bitmap? { - var result: Bitmap? = null - if (shouldUseDrawableBitmap(drawable)) { - drawableUtils.createScaledBitmap( - (drawable as BitmapDrawable).bitmap - )?.let { scaledBitmap -> - val shouldCacheBitmap = scaledBitmap != drawable.bitmap - - serializeBitmapAsynchronously( - drawable, - scaledBitmap, - shouldCacheBitmap, - imageWireframe, - base64SerializerCallback - ) - - result = scaledBitmap + if (drawable is BitmapDrawable && shouldUseDrawableBitmap(drawable)) { + val bitmap = drawable.bitmap // cannot be null - we already checked in shouldUseDrawableBitmap + Runnable { + @Suppress("ThreadSafety") // this runs inside an executor + drawableUtils.createScaledBitmap( + bitmap + )?.let { scaledBitmap -> + + /** + * Check whether the scaled bitmap is the same as the original. + * Since Bitmap.createScaledBitmap will return the original bitmap if the + * requested dimensions match the dimensions of the original + */ + val shouldCacheBitmap = scaledBitmap != drawable.bitmap + + serializeBitmap( + drawable = drawable, + displayMetrics = displayMetrics, + bitmap = scaledBitmap, + shouldCacheBitmap = shouldCacheBitmap, + imageWireframe = imageWireframe, + base64SerializerCallback = base64SerializerCallback, + didCallOriginateFromFailover = false + ) + } + }.let { + threadPoolExecutor.executeSafe("tryToGetBitmapFromBitmapDrawable", logger, it) } + + // return a value to indicate that we are handling the bitmap + return bitmap } - return result + + return null } private fun tryToGetBase64FromCache( drawable: Drawable, imageWireframe: MobileSegment.Wireframe.ImageWireframe, - base64SerializerCallback: Base64SerializerCallback? + base64SerializerCallback: Base64SerializerCallback ): String? { return base64LRUCache?.get(drawable)?.let { base64String -> finalizeRecordedDataItem(base64String, imageWireframe, base64SerializerCallback) @@ -209,53 +276,21 @@ internal class Base64Serializer private constructor( } } - private fun serializeBitmapAsynchronously( - drawable: Drawable, - bitmap: Bitmap, - shouldCacheBitmap: Boolean, - imageWireframe: MobileSegment.Wireframe.ImageWireframe, - base64SerializerCallback: Base64SerializerCallback? - ) { - Runnable { - @Suppress("ThreadSafety") // this runs inside an executor - serialiseBitmap( - drawable, - bitmap, - shouldCacheBitmap, - imageWireframe, - base64SerializerCallback - ) - }.let { executeRunnable(it) } - } - private fun finalizeRecordedDataItem( base64String: String, wireframe: MobileSegment.Wireframe.ImageWireframe, - base64SerializerCallback: Base64SerializerCallback? + base64SerializerCallback: Base64SerializerCallback ) { if (base64String.isNotEmpty()) { wireframe.base64 = base64String wireframe.isEmpty = false } - base64SerializerCallback?.onReady() + base64SerializerCallback.onReady() } - private fun executeRunnable(runnable: Runnable) { - @Suppress("SwallowedException", "TooGenericExceptionCaught") - try { - threadPoolExecutor.submit(runnable) - } catch (e: RejectedExecutionException) { - // TODO: REPLAY-1364 Add logs here once the sdkLogger is added - } catch (e: NullPointerException) { - // TODO: REPLAY-1364 Add logs here once the sdkLogger is added - // should never happen since task is not null - } - } - - private fun shouldUseDrawableBitmap(drawable: Drawable): Boolean { - return drawable is BitmapDrawable && - drawable.bitmap != null && + private fun shouldUseDrawableBitmap(drawable: BitmapDrawable): Boolean { + return drawable.bitmap != null && !drawable.bitmap.isRecycled && drawable.bitmap.width > 0 && drawable.bitmap.height > 0 @@ -276,11 +311,14 @@ internal class Base64Serializer private constructor( private var threadPoolExecutor: ExecutorService = THREADPOOL_EXECUTOR, private var bitmapPool: BitmapPool? = null, private var base64LRUCache: Cache? = null, - private var drawableUtils: DrawableUtils = DrawableUtils(bitmapPool = bitmapPool), + private var drawableUtils: DrawableUtils = DrawableUtils( + bitmapPool = bitmapPool, + threadPoolExecutor = threadPoolExecutor, + logger = logger + ), private var base64Utils: Base64Utils = Base64Utils(), private var webPImageCompression: ImageCompression = WebPImageCompression() ) { - internal fun build() = Base64Serializer( logger = logger, @@ -308,5 +346,9 @@ internal class Base64Serializer private constructor( } } + internal interface BitmapCreationCallback { + fun onReady(bitmap: Bitmap) + } + // endregion } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageTypeResolver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageTypeResolver.kt index a2c38f9047..5eaf3dd8d7 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageTypeResolver.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageTypeResolver.kt @@ -9,17 +9,21 @@ package com.datadog.android.sessionreplay.internal.recorder.base64 import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import androidx.annotation.VisibleForTesting +import com.datadog.android.sessionreplay.internal.recorder.densityNormalized internal class ImageTypeResolver { - fun isDrawablePII(drawable: Drawable, widthInDp: Int, heightInDp: Int): Boolean = - drawable !is GradientDrawable && - ( - widthInDp >= IMAGE_DIMEN_CONSIDERED_PII_IN_DP || - heightInDp >= IMAGE_DIMEN_CONSIDERED_PII_IN_DP - ) + 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 + + return isNotGradient && (widthAboveThreshold || heightAboveThreshold) + } internal companion object { - // material design icon size is up to 48x48 - @VisibleForTesting internal const val IMAGE_DIMEN_CONSIDERED_PII_IN_DP = 49 + // material design icon size is up to 48x48, but use 100 to match more images + @VisibleForTesting internal const val IMAGE_DIMEN_CONSIDERED_PII_IN_DP = 100 } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelper.kt index ed519536de..a9deca76a6 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelper.kt @@ -40,13 +40,15 @@ internal class ImageWireframeHelper( currentWireframeIndex: Int, x: Long, y: Long, - width: Long, - height: Long, + width: Int, + height: Int, + usePIIPlaceholder: Boolean, + clipping: MobileSegment.WireframeClip? = null, drawable: Drawable? = null, shapeStyle: MobileSegment.ShapeStyle? = null, border: MobileSegment.ShapeBorder? = null, - prefix: String = DRAWABLE_CHILD_NAME, - callback: ImageWireframeHelperCallback? = null + imageWireframeHelperCallback: ImageWireframeHelperCallback, + prefix: String? = DRAWABLE_CHILD_NAME ): MobileSegment.Wireframe? { if (drawable == null) return null val id = uniqueIdentifierGenerator.resolveChildUniqueIdentifier(view, prefix + currentWireframeIndex) @@ -60,49 +62,40 @@ internal class ImageWireframeHelper( val density = displayMetrics.density // in case we suspect the image is PII, return a placeholder - @Suppress("UnsafeCallOnNullableType") // drawable already checked for null in isValid - if (imageTypeResolver.isDrawablePII( - drawableProperties.drawable, - drawableProperties.drawableWidth.densityNormalized(density), - drawableProperties.drawableHeight.densityNormalized(density) - ) - ) { - return MobileSegment.Wireframe.PlaceholderWireframe( - id, - x, - y, - width, - height, - label = PLACEHOLDER_CONTENT_LABEL - ) + if (usePIIPlaceholder && imageTypeResolver.isDrawablePII(drawable, density)) { + return createContentPlaceholderWireframe(view, id, density) } + val drawableWidthDp = width.densityNormalized(density).toLong() + val drawableHeightDp = height.densityNormalized(density).toLong() + val imageWireframe = MobileSegment.Wireframe.ImageWireframe( id = id, - x = x, - y = y, - width = width, - height = height, + x, + y, + width = drawableWidthDp, + height = drawableHeightDp, shapeStyle = shapeStyle, border = border, base64 = "", + clip = clipping, mimeType = mimeType, isEmpty = true ) - callback?.onStart() + imageWireframeHelperCallback.onStart() base64Serializer.handleBitmap( applicationContext = applicationContext, displayMetrics = displayMetrics, drawable = drawableProperties.drawable, - drawableWidth = drawableProperties.drawableWidth, - drawableHeight = drawableProperties.drawableHeight, + drawableWidth = width, + drawableHeight = height, imageWireframe = imageWireframe, - object : Base64SerializerCallback { + base64SerializerCallback = object : Base64SerializerCallback { override fun onReady() { - callback?.onFinished() + imageWireframeHelperCallback.onFinished() } } ) @@ -115,7 +108,7 @@ internal class ImageWireframeHelper( view: TextView, mappingContext: MappingContext, prevWireframeIndex: Int, - callback: ImageWireframeHelperCallback? + imageWireframeHelperCallback: ImageWireframeHelperCallback ): MutableList { val result = mutableListOf() var wireframeIndex = prevWireframeIndex @@ -147,14 +140,14 @@ internal class ImageWireframeHelper( currentWireframeIndex = ++wireframeIndex, x = drawableCoordinates.x, y = drawableCoordinates.y, - width = drawable.intrinsicWidth - .densityNormalized(density).toLong(), - height = drawable.intrinsicHeight - .densityNormalized(density).toLong(), + width = drawable.intrinsicWidth, + height = drawable.intrinsicHeight, drawable = drawable, shapeStyle = null, border = null, - callback = callback + usePIIPlaceholder = true, + clipping = MobileSegment.WireframeClip(), + imageWireframeHelperCallback = imageWireframeHelperCallback )?.let { resultWireframe -> result.add(resultWireframe) } @@ -187,6 +180,25 @@ internal class ImageWireframeHelper( } } + private fun createContentPlaceholderWireframe(view: View, id: Long, density: Float): + MobileSegment.Wireframe.PlaceholderWireframe { + val coordinates = IntArray(2) + // this will always have size >= 2 + @Suppress("UnsafeThirdPartyFunctionCall") + view.getLocationOnScreen(coordinates) + val viewX = coordinates[0].densityNormalized(density).toLong() + val viewY = coordinates[1].densityNormalized(density).toLong() + + return MobileSegment.Wireframe.PlaceholderWireframe( + id, + viewX, + viewY, + view.width.densityNormalized(density).toLong(), + view.height.densityNormalized(density).toLong(), + label = PLACEHOLDER_CONTENT_LABEL + ) + } + @Suppress("MagicNumber") private fun convertIndexToCompoundDrawablePosition(compoundDrawableIndex: Int): CompoundDrawablePositions? { return when (compoundDrawableIndex) { @@ -216,7 +228,7 @@ internal class ImageWireframeHelper( } internal companion object { - @VisibleForTesting internal const val DRAWABLE_CHILD_NAME = "drawable" + internal const val DRAWABLE_CHILD_NAME = "drawable" @VisibleForTesting internal const val PLACEHOLDER_CONTENT_LABEL = "Content Image" } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompression.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompression.kt index 929f5fcb00..4061c49d07 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompression.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompression.kt @@ -25,8 +25,17 @@ internal class WebPImageCompression : ImageCompression { // preallocate stream size val byteArrayOutputStream = ByteArrayOutputStream(bitmap.allocationByteCount) val imageFormat = getImageCompressionFormat() - @Suppress("UnsafeThirdPartyFunctionCall") // stream is not null and image quality is between 0 and 100 - bitmap.compress(imageFormat, IMAGE_QUALITY, byteArrayOutputStream) + + @Suppress("SwallowedException") + try { + // stream is not null and image quality is between 0 and 100 + @Suppress("UnsafeThirdPartyFunctionCall") + bitmap.compress(imageFormat, IMAGE_QUALITY, byteArrayOutputStream) + } catch (e: IllegalStateException) { + // if the bitmap was recycled while we were working on it + return EMPTY_BYTEARRAY + } + return byteArrayOutputStream.toByteArray() } @@ -39,6 +48,7 @@ internal class WebPImageCompression : ImageCompression { } companion object { + private val EMPTY_BYTEARRAY = ByteArray(0) private const val WEBP_EXTENSION = "webp" // This is the default compression for webp when writing to the output stream - diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt index dacb3af525..b1613a4e47 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt @@ -64,8 +64,8 @@ abstract class BaseAsyncBackgroundWireframeMapper( val resources = view.resources val density = resources.displayMetrics.density val bounds = resolveViewGlobalBounds(view, density) - val width = view.width.densityNormalized(density).toLong() - val height = view.height.densityNormalized(density).toLong() + val width = view.width + val height = view.height return if (border == null && shapeStyle == null) { resolveBackgroundAsImageWireframe( @@ -90,8 +90,8 @@ abstract class BaseAsyncBackgroundWireframeMapper( private fun resolveBackgroundAsShapeWireframe( view: View, bounds: GlobalBounds, - width: Long, - height: Long, + width: Int, + height: Int, shapeStyle: MobileSegment.ShapeStyle?, border: MobileSegment.ShapeBorder? ): MobileSegment.Wireframe.ShapeWireframe? { @@ -99,13 +99,15 @@ abstract class BaseAsyncBackgroundWireframeMapper( view, PREFIX_BACKGROUND_DRAWABLE ) ?: return null + val resources = view.resources + val density = resources.displayMetrics.density return MobileSegment.Wireframe.ShapeWireframe( id, x = bounds.x, y = bounds.y, - width = width, - height = height, + width = width.densityNormalized(density).toLong(), + height = height.densityNormalized(density).toLong(), shapeStyle = shapeStyle, border = border ) @@ -114,8 +116,8 @@ abstract class BaseAsyncBackgroundWireframeMapper( private fun resolveBackgroundAsImageWireframe( view: View, bounds: GlobalBounds, - width: Long, - height: Long, + width: Int, + height: Int, asyncJobStatusCallback: AsyncJobStatusCallback ): MobileSegment.Wireframe? { val resources = view.resources @@ -129,11 +131,13 @@ abstract class BaseAsyncBackgroundWireframeMapper( y = bounds.y, width, height, - drawableCopy, + clipping = MobileSegment.WireframeClip(), + drawable = drawableCopy, shapeStyle = null, border = null, prefix = PREFIX_BACKGROUND_DRAWABLE, - callback = object : ImageWireframeHelperCallback { + usePIIPlaceholder = false, + imageWireframeHelperCallback = object : ImageWireframeHelperCallback { override fun onFinished() { asyncJobStatusCallback.jobFinished() } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EditTextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EditTextViewMapper.kt deleted file mode 100644 index 1387402499..0000000000 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EditTextViewMapper.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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.recorder.mapper - -import android.widget.EditText -import com.datadog.android.sessionreplay.SessionReplayPrivacy -import com.datadog.android.sessionreplay.internal.AsyncJobStatusCallback -import com.datadog.android.sessionreplay.internal.recorder.MappingContext -import com.datadog.android.sessionreplay.model.MobileSegment -import com.datadog.android.sessionreplay.utils.StringUtils -import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator -import com.datadog.android.sessionreplay.utils.ViewUtils - -/** - * A [WireframeMapper] implementation to map a [EditText] component in case the - * [SessionReplayPrivacy.ALLOW] rule was used in the configuration. - * In this case the mapper will use the provided [textViewMapper] used for the current privacy - * level and will only mask the [EditText] for which the input type is considered sensible - * (password, email, address, postal address, numeric password) with the static mask: [***]. - */ -internal open class EditTextViewMapper( - internal val textViewMapper: TextViewMapper, - private val uniqueIdentifierGenerator: UniqueIdentifierGenerator = UniqueIdentifierGenerator, - viewUtils: ViewUtils = ViewUtils, - stringUtils: StringUtils = StringUtils -) : BaseWireframeMapper( - viewUtils = viewUtils, - stringUtils = stringUtils -) { - - override fun map( - view: EditText, - mappingContext: MappingContext, - asyncJobStatusCallback: AsyncJobStatusCallback - ): - List { - val mainWireframeList = textViewMapper.map(view, mappingContext, asyncJobStatusCallback) - resolveUnderlineWireframe(view, mappingContext.systemInformation.screenDensity) - ?.let { wireframe -> - return mainWireframeList + wireframe - } - return mainWireframeList - } - - private fun resolveUnderlineWireframe( - parent: EditText, - pixelsDensity: Float - ): MobileSegment.Wireframe? { - val identifier = uniqueIdentifierGenerator.resolveChildUniqueIdentifier( - parent, - UNDERLINE_KEY_NAME - ) ?: return null - val viewGlobalBounds = resolveViewGlobalBounds(parent, pixelsDensity) - val fieldUnderlineColor = resolveUnderlineColor(parent) - return MobileSegment.Wireframe.ShapeWireframe( - identifier, - viewGlobalBounds.x, - viewGlobalBounds.y + viewGlobalBounds.height - UNDERLINE_HEIGHT_IN_PIXELS, - viewGlobalBounds.width, - UNDERLINE_HEIGHT_IN_PIXELS, - shapeStyle = MobileSegment.ShapeStyle( - backgroundColor = fieldUnderlineColor, - opacity = parent.alpha - ) - ) - } - - private fun resolveUnderlineColor(view: EditText): String { - view.backgroundTintList?.let { - return colorAndAlphaAsStringHexa( - it.defaultColor, - OPAQUE_ALPHA_VALUE - ) - } - return colorAndAlphaAsStringHexa(view.currentTextColor, OPAQUE_ALPHA_VALUE) - } - - companion object { - internal const val UNDERLINE_HEIGHT_IN_PIXELS = 1L - internal const val UNDERLINE_KEY_NAME = "underline" - } -} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageButtonMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapper.kt similarity index 63% rename from features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageButtonMapper.kt rename to features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapper.kt index 1ca0dec8a9..71b7cf075e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageButtonMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapper.kt @@ -6,26 +6,26 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper -import android.widget.ImageButton +import android.widget.ImageView import com.datadog.android.sessionreplay.internal.AsyncJobStatusCallback import com.datadog.android.sessionreplay.internal.recorder.MappingContext -import com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer import com.datadog.android.sessionreplay.internal.recorder.base64.ImageWireframeHelper import com.datadog.android.sessionreplay.internal.recorder.base64.ImageWireframeHelperCallback import com.datadog.android.sessionreplay.internal.recorder.densityNormalized +import com.datadog.android.sessionreplay.internal.utils.ImageViewUtils import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator -internal class ImageButtonMapper( - private val base64Serializer: Base64Serializer, +internal class ImageViewMapper( private val imageWireframeHelper: ImageWireframeHelper, + private val imageViewUtils: ImageViewUtils = ImageViewUtils, uniqueIdentifierGenerator: UniqueIdentifierGenerator -) : BaseAsyncBackgroundWireframeMapper( +) : BaseAsyncBackgroundWireframeMapper( imageWireframeHelper = imageWireframeHelper, uniqueIdentifierGenerator = uniqueIdentifierGenerator ) { override fun map( - view: ImageButton, + view: ImageView, mappingContext: MappingContext, asyncJobStatusCallback: AsyncJobStatusCallback ): List { @@ -35,32 +35,35 @@ internal class ImageButtonMapper( wireframes.addAll(super.map(view, mappingContext, asyncJobStatusCallback)) val drawable = view.drawable?.current ?: return wireframes - val resources = view.resources - val density = resources.displayMetrics.density - val bounds = resolveViewGlobalBounds(view, density) - // This method should not be part of the serializer - // TODO: RUM-0000 remove this method from the serializer and remove - // the serializer dependency from this class - val (scaledDrawableWidth, scaledDrawableHeight) = - base64Serializer.getDrawableScaledDimensions(view, drawable, density) + val parentRect = imageViewUtils.resolveParentRectAbsPosition(view) + val contentRect = imageViewUtils.resolveContentRectWithScaling(view, drawable) - val centerX = (bounds.x + view.width.densityNormalized(density) / 2) - (scaledDrawableWidth / 2) - val centerY = (bounds.y + view.height.densityNormalized(density) / 2) - (scaledDrawableHeight / 2) + val resources = view.resources + val density = resources.displayMetrics.density + val clipping = imageViewUtils.calculateClipping(parentRect, contentRect, density) + val contentXPosInDp = contentRect.left.densityNormalized(density).toLong() + val contentYPosInDp = contentRect.top.densityNormalized(density).toLong() + val contentWidthPx = contentRect.width() + val contentHeightPx = contentRect.height() + val contentDrawable = drawable.constantState?.newDrawable(resources) // resolve foreground @Suppress("ThreadSafety") // TODO REPLAY-1861 caller thread of .map is unknown? imageWireframeHelper.createImageWireframe( view = view, currentWireframeIndex = wireframes.size, - x = centerX, - y = centerY, - width = scaledDrawableWidth, - height = scaledDrawableHeight, - drawable = drawable.constantState?.newDrawable(resources), + x = contentXPosInDp, + y = contentYPosInDp, + width = contentWidthPx, + height = contentHeightPx, + drawable = contentDrawable, + usePIIPlaceholder = true, shapeStyle = null, border = null, - callback = object : ImageWireframeHelperCallback { + clipping = clipping, + prefix = ImageWireframeHelper.DRAWABLE_CHILD_NAME, + imageWireframeHelperCallback = object : ImageWireframeHelperCallback { override fun onFinished() { asyncJobStatusCallback.jobFinished() } 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 76200b470d..19a04f7ad7 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 @@ -12,104 +12,144 @@ import android.graphics.Bitmap.Config import android.graphics.Color import android.graphics.PorterDuff import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.Looper import android.util.DisplayMetrics -import android.widget.ImageView -import android.widget.ImageView.ScaleType import androidx.annotation.MainThread +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.internal.utils.executeSafe +import com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer +import com.datadog.android.sessionreplay.internal.recorder.base64.Base64SerializerCallback import com.datadog.android.sessionreplay.internal.recorder.base64.BitmapPool -import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.internal.recorder.wrappers.BitmapWrapper import com.datadog.android.sessionreplay.internal.recorder.wrappers.CanvasWrapper +import java.util.concurrent.ExecutorService import kotlin.math.sqrt internal class DrawableUtils( private val bitmapWrapper: BitmapWrapper = BitmapWrapper(), private val canvasWrapper: CanvasWrapper = CanvasWrapper(), - private val bitmapPool: BitmapPool? = null + private val bitmapPool: BitmapPool? = null, + private val threadPoolExecutor: ExecutorService, + private val mainThreadHandler: Handler = Handler(Looper.getMainLooper()), + private val logger: InternalLogger ) { + /** * This method attempts to create a bitmap from a drawable, such that the bitmap file size will * be equal or less than a given size. It does so by modifying the dimensions of the * bitmap, since the file size of a bitmap can be known by the formula width*height*color depth */ - @MainThread - @Suppress("ReturnCount") internal fun createBitmapOfApproxSizeFromDrawable( drawable: Drawable, drawableWidth: Int, drawableHeight: Int, displayMetrics: DisplayMetrics, requestedSizeInBytes: Int = MAX_BITMAP_SIZE_IN_BYTES, - config: Config = Config.ARGB_8888 - ): Bitmap? { - val (width, height) = getScaledWidthAndHeight(drawableWidth, drawableHeight, requestedSizeInBytes) - - val bitmap = getBitmapBySize(displayMetrics, width, height, config) ?: return null - val canvas = canvasWrapper.createCanvas(bitmap) ?: return null - - // erase the canvas - // needed because overdrawing an already used bitmap causes unusual visual artifacts - canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.MULTIPLY) - - drawable.setBounds(0, 0, canvas.width, canvas.height) - drawable.draw(canvas) - return bitmap + config: Config = Config.ARGB_8888, + base64SerializerCallback: Base64SerializerCallback, + bitmapCreationCallback: Base64Serializer.BitmapCreationCallback + ) { + Runnable { + @Suppress("ThreadSafety") // this runs inside an executor + createScaledBitmap( + drawableWidth, + drawableHeight, + requestedSizeInBytes, + displayMetrics, + config, + resizeBitmapCallback = object : + ResizeBitmapCallback { + override fun onSuccess(bitmap: Bitmap) { + mainThreadHandler.post { + @Suppress("ThreadSafety") // this runs on the main thread + drawOnCanvas( + bitmap, + drawable, + base64SerializerCallback, + bitmapCreationCallback + ) + } + } + + override fun onFailure() { + base64SerializerCallback.onReady() + } + } + ) + }.let { + threadPoolExecutor.executeSafe( + "createBitmapOfApproxSizeFromDrawable", + logger, + it + ) + } } - @MainThread + @WorkerThread internal fun createScaledBitmap( bitmap: Bitmap, requestedSizeInBytes: Int = MAX_BITMAP_SIZE_IN_BYTES ): Bitmap? { - val (width, height) = getScaledWidthAndHeight(bitmap.width, bitmap.height, requestedSizeInBytes) + val (width, height) = getScaledWidthAndHeight( + bitmap.width, + bitmap.height, + requestedSizeInBytes + ) return bitmapWrapper.createScaledBitmap(bitmap, width, height, false) } - internal fun getDrawableScaledDimensions( - view: ImageView, + internal interface ResizeBitmapCallback { + fun onSuccess(bitmap: Bitmap) + fun onFailure() + } + + @MainThread + private fun drawOnCanvas( + bitmap: Bitmap, drawable: Drawable, - density: Float - ): DrawableDimensions { - val viewWidth = view.width.densityNormalized(density).toLong() - val viewHeight = view.height.densityNormalized(density).toLong() - val drawableWidth = drawable.intrinsicWidth.densityNormalized(density).toLong() - val drawableHeight = drawable.intrinsicHeight.densityNormalized(density).toLong() - - val scaleType = view.scaleType ?: return DrawableDimensions( - width = drawableWidth, - height = drawableHeight + base64SerializerCallback: Base64SerializerCallback, + bitmapCreationCallback: Base64Serializer.BitmapCreationCallback + ) { + val canvas = canvasWrapper.createCanvas(bitmap) + + if (canvas == null) { + base64SerializerCallback.onReady() + } else { + // erase the canvas + // needed because overdrawing an already used bitmap causes unusual visual artifacts + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.MULTIPLY) + + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bitmapCreationCallback.onReady(bitmap) + } + } + + @WorkerThread + private fun createScaledBitmap( + drawableWidth: Int, + drawableHeight: Int, + requestedSizeInBytes: Int, + displayMetrics: DisplayMetrics, + config: Config, + resizeBitmapCallback: ResizeBitmapCallback + ) { + val (width, height) = getScaledWidthAndHeight( + drawableWidth, + drawableHeight, + requestedSizeInBytes ) - val scaledDrawableWidth: Long - val scaledDrawableHeight: Long - - when (scaleType) { - ScaleType.FIT_START, - ScaleType.FIT_END, - ScaleType.FIT_CENTER, - ScaleType.CENTER_INSIDE, - ScaleType.CENTER, - ScaleType.MATRIX -> { - // TODO: REPLAY-1974 Implement remaining scaletype methods - scaledDrawableWidth = drawableWidth - scaledDrawableHeight = drawableHeight - } - ScaleType.FIT_XY -> { - scaledDrawableWidth = viewWidth - scaledDrawableHeight = viewHeight - } - ScaleType.CENTER_CROP -> { - if (drawableWidth * viewHeight > viewWidth * drawableHeight) { - scaledDrawableWidth = viewWidth - scaledDrawableHeight = (viewWidth * drawableHeight) / drawableWidth - } else { - scaledDrawableHeight = viewHeight - scaledDrawableWidth = (viewHeight * drawableWidth) / drawableHeight - } - } - } + val result = getBitmapBySize(displayMetrics, width, height, config) - return DrawableDimensions(scaledDrawableWidth, scaledDrawableHeight) + if (result == null) { + resizeBitmapCallback.onFailure() + } else { + resizeBitmapCallback.onSuccess(result) + } } private fun getScaledWidthAndHeight( @@ -149,7 +189,7 @@ internal class DrawableUtils( ?: bitmapWrapper.createBitmap(displayMetrics, width, height, config) internal companion object { - private const val MAX_BITMAP_SIZE_IN_BYTES = 10240 // 10kb + @VisibleForTesting internal const val MAX_BITMAP_SIZE_IN_BYTES = 15000 // 15kb private const val ARGB_8888_PIXEL_SIZE_BYTES = 4 } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/ImageViewUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/ImageViewUtils.kt new file mode 100644 index 0000000000..df6d28a678 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/ImageViewUtils.kt @@ -0,0 +1,221 @@ +/* + * 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.Rect +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import com.datadog.android.sessionreplay.internal.recorder.densityNormalized +import com.datadog.android.sessionreplay.model.MobileSegment + +internal object ImageViewUtils { + internal fun resolveParentRectAbsPosition(view: View): Rect { + val coords = IntArray(2) + // this will always have size >= 2 + @Suppress("UnsafeThirdPartyFunctionCall") + view.getLocationOnScreen(coords) + + return Rect( + coords[0], + coords[1], + coords[0] + view.width, + coords[1] + view.height + ) + } + + internal fun calculateClipping(parentRect: Rect, childRect: Rect, density: Float): MobileSegment.WireframeClip { + val left = if (childRect.left < parentRect.left) { + parentRect.left - childRect.left + } else { + 0 + } + val top = if (childRect.top < parentRect.top) { + parentRect.top - childRect.top + } else { + 0 + } + val right = if (childRect.right > parentRect.right) { + childRect.right - parentRect.right + } else { + 0 + } + val bottom = if (childRect.bottom > parentRect.bottom) { + childRect.bottom - parentRect.bottom + } else { + 0 + } + + return MobileSegment.WireframeClip( + left = left.densityNormalized(density).toLong(), + top = top.densityNormalized(density).toLong(), + right = right.densityNormalized(density).toLong(), + bottom = bottom.densityNormalized(density).toLong() + ) + } + + internal fun resolveContentRectWithScaling( + view: ImageView, + drawable: Drawable + ): Rect { + val drawableWidthPx = drawable.intrinsicWidth + val drawableHeightPx = drawable.intrinsicHeight + + val parentRect = resolveParentRectAbsPosition(view) + + val childRect = Rect( + 0, + 0, + drawableWidthPx, + drawableHeightPx + ) + + val resultRect: Rect + + when (view.scaleType) { + ImageView.ScaleType.FIT_START -> { + val contentRect = scaleRectToFitParent(parentRect, childRect) + resultRect = positionRectAtStart(parentRect, contentRect) + } + ImageView.ScaleType.FIT_END -> { + val contentRect = scaleRectToFitParent(parentRect, childRect) + resultRect = positionRectAtEnd(parentRect, contentRect) + } + ImageView.ScaleType.FIT_CENTER -> { + val contentRect = scaleRectToFitParent(parentRect, childRect) + resultRect = positionRectInCenter(parentRect, contentRect) + } + ImageView.ScaleType.CENTER_INSIDE -> { + val contentRect = scaleRectToCenterInsideParent(parentRect, childRect) + resultRect = positionRectInCenter(parentRect, contentRect) + } + ImageView.ScaleType.CENTER -> { + resultRect = positionRectInCenter(parentRect, childRect) + } + ImageView.ScaleType.CENTER_CROP -> { + val contentRect = scaleRectToCenterCrop(parentRect, childRect) + resultRect = positionRectInCenter(parentRect, contentRect) + } + ImageView.ScaleType.FIT_XY, + ImageView.ScaleType.MATRIX, + null -> { + resultRect = Rect( + parentRect.left, + parentRect.top, + parentRect.right, + parentRect.bottom + ) + } + } + + return resultRect + } + + private fun scaleRectToCenterInsideParent( + parentRect: Rect, + childRect: Rect + ): Rect { + // it already fits inside the parent + if (parentRect.width() > childRect.width() && parentRect.height() > childRect.height()) { + return childRect + } + + val scaleX: Float = parentRect.width().toFloat() / childRect.width().toFloat() + val scaleY: Float = parentRect.height().toFloat() / childRect.height().toFloat() + + var scaleFactor: Float = minOf(scaleX, scaleY) + + // center inside doesn't enlarge, it only reduces + if (scaleFactor >= 1F) scaleFactor = 1F + + val newWidth = childRect.width() * scaleFactor + val newHeight = childRect.height() * scaleFactor + + val resultRect = Rect() + resultRect.left = parentRect.left + resultRect.top = parentRect.top + resultRect.right = resultRect.left + newWidth.toInt() + resultRect.bottom = resultRect.top + newHeight.toInt() + return resultRect + } + + private fun scaleRectToCenterCrop( + parentRect: Rect, + childRect: Rect + ): Rect { + val scaleX: Float = parentRect.width().toFloat() / childRect.width().toFloat() + val scaleY: Float = parentRect.height().toFloat() / childRect.height().toFloat() + val scaleFactor = maxOf(scaleX, scaleY) + + val newWidth = childRect.width() * scaleFactor + val newHeight = childRect.height() * scaleFactor + + val resultRect = Rect() + resultRect.left = 0 + resultRect.top = 0 + resultRect.right = newWidth.toInt() + resultRect.bottom = newHeight.toInt() + return resultRect + } + + private fun scaleRectToFitParent( + parentRect: Rect, + childRect: Rect + ): Rect { + val scaleX: Float = parentRect.width().toFloat() / childRect.width().toFloat() + val scaleY: Float = parentRect.height().toFloat() / childRect.height().toFloat() + val scaleFactor = minOf(scaleX, scaleY) + + val newWidth = childRect.width() * scaleFactor + val newHeight = childRect.height() * scaleFactor + + val resultRect = Rect() + resultRect.left = 0 + resultRect.top = 0 + resultRect.right = newWidth.toInt() + resultRect.bottom = newHeight.toInt() + return resultRect + } + + private fun positionRectInCenter(parentRect: Rect, childRect: Rect): Rect { + val centerXParentPx = parentRect.centerX() + val centerYParentPx = parentRect.centerY() + val childRectWidthPx = childRect.width() + val childRectHeightPx = childRect.height() + + val resultRect = Rect() + resultRect.left = centerXParentPx - (childRectWidthPx / 2) + resultRect.top = centerYParentPx - (childRectHeightPx / 2) + resultRect.right = resultRect.left + childRectWidthPx + resultRect.bottom = resultRect.top + childRectHeightPx + return resultRect + } + + private fun positionRectAtStart(parentRect: Rect, childRect: Rect): Rect { + val childRectWidthPx = childRect.width() + val childRectHeightPx = childRect.height() + + val resultRect = Rect() + resultRect.left = parentRect.left + resultRect.top = parentRect.top + resultRect.right = resultRect.left + childRectWidthPx + resultRect.bottom = resultRect.top + childRectHeightPx + return resultRect + } + + private fun positionRectAtEnd(parentRect: Rect, childRect: Rect): Rect { + val childRectWidthPx = childRect.width() + val childRectHeightPx = childRect.height() + + val resultRect = Rect() + resultRect.right = parentRect.right + resultRect.bottom = parentRect.bottom + resultRect.left = parentRect.right - childRectWidthPx + resultRect.top = parentRect.bottom - childRectHeightPx + return resultRect + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt index d5fadf1056..33995a69ee 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt @@ -10,7 +10,9 @@ import com.datadog.android.sessionreplay.model.MobileSegment internal fun MobileSegment.Wireframe.hasOpaqueBackground(): Boolean { return when (this) { - is MobileSegment.Wireframe.ImageWireframe -> this.hasOpaqueBackground() + // we return false from ImageWireframe because we don't know if the image is opaque or not + // and ImageWireframe ShapeStyle is always null + is MobileSegment.Wireframe.ImageWireframe -> false is MobileSegment.Wireframe.ShapeWireframe -> this.hasOpaqueBackground() is MobileSegment.Wireframe.TextWireframe -> this.hasOpaqueBackground() is MobileSegment.Wireframe.PlaceholderWireframe -> true @@ -24,9 +26,6 @@ private fun MobileSegment.Wireframe.ShapeWireframe.hasOpaqueBackground(): Boolea private fun MobileSegment.Wireframe.TextWireframe.hasOpaqueBackground(): Boolean { return shapeStyle.isOpaque() } -private fun MobileSegment.Wireframe.ImageWireframe.hasOpaqueBackground(): Boolean { - return !(base64.isNullOrEmpty()) || shapeStyle.isOpaque() -} private fun MobileSegment.ShapeStyle?.isOpaque(): Boolean { return this != null && this.isFullyOpaque() && this.hasNonTranslucentColor() diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacyTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacyTest.kt index 6571daae18..c8803ce2d4 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacyTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacyTest.kt @@ -11,8 +11,6 @@ import android.view.View import android.widget.Button import android.widget.CheckBox import android.widget.CheckedTextView -import android.widget.EditText -import android.widget.ImageButton import android.widget.ImageView import android.widget.NumberPicker import android.widget.RadioButton @@ -23,8 +21,7 @@ import androidx.appcompat.widget.SwitchCompat import com.datadog.android.sessionreplay.internal.recorder.mapper.ButtonMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckBoxMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckedTextViewMapper -import com.datadog.android.sessionreplay.internal.recorder.mapper.EditTextViewMapper -import com.datadog.android.sessionreplay.internal.recorder.mapper.ImageButtonMapper +import com.datadog.android.sessionreplay.internal.recorder.mapper.ImageViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.MapperTypeWrapper import com.datadog.android.sessionreplay.internal.recorder.mapper.MaskCheckBoxMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.MaskCheckedTextViewMapper @@ -40,7 +37,6 @@ import com.datadog.android.sessionreplay.internal.recorder.mapper.SeekBarWirefra import com.datadog.android.sessionreplay.internal.recorder.mapper.SwitchCompatMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.TextViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.UnsupportedViewMapper -import com.datadog.android.sessionreplay.internal.recorder.mapper.ViewScreenshotWireframeMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.WireframeMapper import com.datadog.tools.unit.setStaticValue import org.assertj.core.api.Assertions.assertThat @@ -110,16 +106,12 @@ internal class SessionReplayPrivacyTest { // BASE private val mockButtonMapper: ButtonMapper = mock() - private val mockEditTextViewMapper: EditTextViewMapper = mock() - private val mockImageMapper: ViewScreenshotWireframeMapper = mock() private val mockUnsupportedViewMapper: UnsupportedViewMapper = mock() - private val mockImageButtonViewMapper: ImageButtonMapper = mock() + private val mockImageViewMapper: ImageViewMapper = mock() private val baseMappers = listOf( MapperTypeWrapper(Button::class.java, mockButtonMapper.toGenericMapper()), - MapperTypeWrapper(EditText::class.java, mockEditTextViewMapper.toGenericMapper()), - MapperTypeWrapper(ImageView::class.java, mockImageMapper.toGenericMapper()), - MapperTypeWrapper(ImageButton::class.java, mockImageButtonViewMapper.toGenericMapper()), + MapperTypeWrapper(ImageView::class.java, mockImageViewMapper.toGenericMapper()), MapperTypeWrapper(AppCompatToolbar::class.java, mockUnsupportedViewMapper.toGenericMapper()) ) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt index 3a3b19ffeb..9523a580f2 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt @@ -49,6 +49,7 @@ internal class ForgeConfigurator : BaseConfigurator() { forge.addFactory(PlaceholderWireframeForgeryFactory()) forge.addFactory(SnapshotRecordedDataQueueItemForgeryFactory()) forge.addFactory(TouchEventRecordedDataQueueItemForgeryFactory()) + forge.addFactory(WireframeBoundsForgeryFactory()) forge.useJvmFactories() } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/WireframeBoundsForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/WireframeBoundsForgeryFactory.kt new file mode 100644 index 0000000000..24494fd01b --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/WireframeBoundsForgeryFactory.kt @@ -0,0 +1,28 @@ +/* + * 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.forge + +import com.datadog.android.sessionreplay.internal.processor.WireframeBounds +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class WireframeBoundsForgeryFactory : ForgeryFactory { + override fun getForgery(forge: Forge): WireframeBounds { + val left = forge.aLong(min = 0, max = 1000) + val right = left + forge.aLong(min = 1, max = 1000) + val top = forge.aLong(min = 0, max = 1000) + val bottom = top + forge.aLong(min = 1, max = 1000) + return WireframeBounds( + left = left, + right = right, + top = top, + bottom = bottom, + width = forge.aPositiveLong(), + height = forge.aPositiveLong() + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt index 3c2de5c464..f5ad4a401a 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt @@ -9,6 +9,7 @@ package com.datadog.android.sessionreplay.internal.processor import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.model.MobileSegment import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -16,12 +17,11 @@ 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.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.whenever import org.mockito.quality.Strictness -import kotlin.math.abs @Extensions( ExtendWith(MockitoExtension::class), @@ -33,383 +33,347 @@ internal class WireframeUtilsTest { lateinit var testedWireframeUtils: WireframeUtils + @Mock + private lateinit var mockBoundsUtils: BoundsUtils + @BeforeEach fun `set up`() { - testedWireframeUtils = WireframeUtils() + testedWireframeUtils = WireframeUtils(mockBoundsUtils) } // region Clipping resolver - @ParameterizedTest - @MethodSource("resolveClipWireframes") - fun `M correctly resolve the Wireframe clip W resolveWireframeClip {clipLeft}`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M correctly resolve the Wireframe clip W resolveWireframeClip(smaller parent)`( forge: Forge ) { // Given - val fakeExpectedClipLeft = forge.aLong(min = 1, max = 100) - - val fakeParentWireframe = fakeWireframe.copy( - x = fakeWireframe.x() + fakeExpectedClipLeft, - y = fakeWireframe.y(), - width = fakeWireframe.width(), - height = fakeWireframe.height() + val fakeWireframe: MobileSegment.Wireframe = forge.getForgery() + .copy( + clip = null + ) + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } + val fakeExpectedClipTop = forge.aLong(min = 0, max = 100) + val fakeExpectedClipLeft = forge.aLong(min = 0, max = 100) + val top = fakeWireframeBounds.top + fakeExpectedClipTop + val left = fakeWireframeBounds.left + fakeExpectedClipLeft + val fakeExpectedClipRight = forge.aLong(min = 0, max = fakeWireframeBounds.right) + val fakeExpectedClipBottom = forge.aLong(min = 0, max = fakeWireframeBounds.bottom) + val fakeParentBounds = fakeWireframeBounds.copy( + left = left, + top = top, + right = fakeWireframeBounds.right - fakeExpectedClipRight, + bottom = fakeWireframeBounds.bottom - fakeExpectedClipBottom ) + val fakeParentWireframe: MobileSegment.Wireframe = + forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(fakeParentBounds) + } val fakExpectedClip = MobileSegment.WireframeClip( - top = 0, - left = fakeExpectedClipLeft, - right = 0, - bottom = 0 + top = fakeExpectedClipTop.longOrNull(), + bottom = fakeExpectedClipBottom.longOrNull(), + right = fakeExpectedClipRight.longOrNull(), + left = fakeExpectedClipLeft.longOrNull() ) - val fakeParents = listOf(fakeParentWireframe) + + val fakeRandomParents: List = forge.aList { + forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(fakeWireframeBounds) + } + }.toMutableList() + val randomIndex = forge.anInt(min = 0, max = fakeRandomParents.size) + val fakeParents = fakeRandomParents.toMutableList().apply { + add(randomIndex, fakeParentWireframe) + } // Then assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeParents)) .isEqualTo(fakExpectedClip) } - @ParameterizedTest - @MethodSource("resolveClipWireframes") - fun `M correctly resolve the Wireframe clip W resolveWireframeClip {clipRight}`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M return Wireframe clip W resolveWireframeClip(bigger parent, clip is not empty)`( forge: Forge ) { // Given - val fakeExpectedClipRight = forge.aLong(min = 1, max = 100) - - val fakeParentWireframe = fakeWireframe.copy( - x = fakeWireframe.x(), - y = fakeWireframe.y(), - width = fakeWireframe.width() - fakeExpectedClipRight, - height = fakeWireframe.height() + val fakeWireframe: MobileSegment.Wireframe = forge.getForgery().copy( + clip = MobileSegment.WireframeClip( + left = forge.aPositiveLong(true), + right = forge.aPositiveLong(true), + top = forge.aPositiveLong(true), + bottom = forge.aPositiveLong(true) + ) ) - val fakExpectedClip = MobileSegment.WireframeClip( - top = 0, - right = fakeExpectedClipRight, - left = 0, - bottom = 0 + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } + val fakeParentBounds = fakeWireframeBounds.copy( + left = fakeWireframeBounds.left - forge.aLong(min = 0, max = fakeWireframeBounds.left), + top = fakeWireframeBounds.top - forge.aLong(min = 0, max = fakeWireframeBounds.top), + right = fakeWireframeBounds.right + + forge.aLong(min = 0, max = fakeWireframeBounds.right), + bottom = fakeWireframeBounds.bottom + + forge.aLong(min = 0, max = fakeWireframeBounds.bottom) ) - val fakeParents = listOf(fakeParentWireframe) - + val fakeRandomParents: List = forge.aList { + forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(fakeParentBounds) + } + }.toMutableList() // Then - assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeParents)) - .isEqualTo(fakExpectedClip) + assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeRandomParents)) + .isEqualTo(fakeWireframe.clip()) } - @ParameterizedTest - @MethodSource("resolveClipWireframes") - fun `M correctly resolve the Wireframe clip W resolveWireframeClip {clipTop}`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M return Wireframe clip W resolveWireframeClip(bigger parent, clip left is null)`( forge: Forge ) { // Given - val fakeExpectedClipTop = forge.aLong(min = 1, max = 100) - - val fakeParentWireframe = fakeWireframe.copy( - x = fakeWireframe.x(), - y = fakeWireframe.y() + fakeExpectedClipTop, - width = fakeWireframe.width(), - height = fakeWireframe.height() + val fakeWireframe: MobileSegment.Wireframe = forge.getForgery().copy( + clip = MobileSegment.WireframeClip( + left = null, + right = forge.aPositiveLong(true), + top = forge.aPositiveLong(true), + bottom = forge.aPositiveLong(true) + ) ) - val fakExpectedClip = MobileSegment.WireframeClip( - top = fakeExpectedClipTop, - right = 0, - left = 0, - bottom = 0 + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } + val fakeParentBounds = fakeWireframeBounds.copy( + left = fakeWireframeBounds.left - forge.aLong(min = 0, max = fakeWireframeBounds.left), + top = fakeWireframeBounds.top - forge.aLong(min = 0, max = fakeWireframeBounds.top), + right = fakeWireframeBounds.right + + forge.aLong(min = 0, max = fakeWireframeBounds.right), + bottom = fakeWireframeBounds.bottom + + forge.aLong(min = 0, max = fakeWireframeBounds.bottom) ) - val fakeParents = listOf(fakeParentWireframe) - + val fakeRandomParents: List = forge.aList { + forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(fakeParentBounds) + } + }.toMutableList() // Then - assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeParents)) - .isEqualTo(fakExpectedClip) + assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeRandomParents)) + .isEqualTo(fakeWireframe.clip()) } - @ParameterizedTest - @MethodSource("resolveClipWireframes") - fun `M correctly resolve the Wireframe clip W resolveWireframeClip {clipBottom}`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M return Wireframe clip W resolveWireframeClip(bigger parent, clip right is null)`( forge: Forge ) { // Given - val fakeExpectedClipBottom = forge.aLong(min = 1, max = 100) - - val fakeParentWireframe = fakeWireframe.copy( - x = fakeWireframe.x(), - y = fakeWireframe.y(), - width = fakeWireframe.width(), - height = fakeWireframe.height() - fakeExpectedClipBottom + val fakeWireframe: MobileSegment.Wireframe = forge.getForgery().copy( + clip = MobileSegment.WireframeClip( + left = forge.aPositiveLong(true), + right = null, + top = forge.aPositiveLong(true), + bottom = forge.aPositiveLong(true) + ) ) - val fakExpectedClip = MobileSegment.WireframeClip( - top = 0, - right = 0, - left = 0, - bottom = fakeExpectedClipBottom + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } + val fakeParentBounds = fakeWireframeBounds.copy( + left = fakeWireframeBounds.left - forge.aLong(min = 0, max = fakeWireframeBounds.left), + top = fakeWireframeBounds.top - forge.aLong(min = 0, max = fakeWireframeBounds.top), + right = fakeWireframeBounds.right + + forge.aLong(min = 0, max = fakeWireframeBounds.right), + bottom = fakeWireframeBounds.bottom + + forge.aLong(min = 0, max = fakeWireframeBounds.bottom) ) - val fakeParents = listOf(fakeParentWireframe) - + val fakeRandomParents: List = forge.aList { + forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(fakeParentBounds) + } + }.toMutableList() // Then - assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeParents)) - .isEqualTo(fakExpectedClip) + assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeRandomParents)) + .isEqualTo(fakeWireframe.clip()) } - @ParameterizedTest - @MethodSource("resolveClipWireframes") - fun `M correctly resolve the Wireframe clip W resolveWireframeClip {clip all sides}`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M return Wireframe clip W resolveWireframeClip(bigger parent, clip top is null)`( forge: Forge ) { // Given - val fakeExpectedClipBottom = forge.aLong(min = 1, max = 5) - val fakeExpectedClipTop = forge.aLong(min = 1, max = 5) - val fakeExpectedClipLeft = forge.aLong(min = 1, max = 5) - val fakeExpectedClipRight = forge.aLong(min = 1, max = 5) - - val fakeParentWireframe = fakeWireframe.copy( - x = fakeWireframe.x() + fakeExpectedClipLeft, - y = fakeWireframe.y() + fakeExpectedClipTop, - width = fakeWireframe.width() - fakeExpectedClipRight - fakeExpectedClipLeft, - height = fakeWireframe.height() - fakeExpectedClipBottom - fakeExpectedClipTop + val fakeWireframe: MobileSegment.Wireframe = forge.getForgery().copy( + clip = MobileSegment.WireframeClip( + left = forge.aPositiveLong(true), + right = forge.aPositiveLong(true), + top = null, + bottom = forge.aPositiveLong(true) + ) ) - val fakExpectedClip = MobileSegment.WireframeClip( - top = fakeExpectedClipTop, - right = fakeExpectedClipRight, - left = fakeExpectedClipLeft, - bottom = fakeExpectedClipBottom + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } + val fakeParentBounds = fakeWireframeBounds.copy( + left = fakeWireframeBounds.left - forge.aLong(min = 0, max = fakeWireframeBounds.left), + top = fakeWireframeBounds.top - forge.aLong(min = 0, max = fakeWireframeBounds.top), + right = fakeWireframeBounds.right + + forge.aLong(min = 0, max = fakeWireframeBounds.right), + bottom = fakeWireframeBounds.bottom + + forge.aLong(min = 0, max = fakeWireframeBounds.bottom) ) - val fakeRandomParents: List = forge.aList { - val aClipLeft = forge.aLong(min = -100, max = fakeExpectedClipLeft) - val aClipTop = forge.aLong(min = -100, max = fakeExpectedClipTop) - val aClipRight = forge.aLong(min = -100, max = fakeExpectedClipRight) - aClipLeft - val aClipBottom = forge.aLong(min = -100, max = fakeExpectedClipBottom) - aClipTop - fakeWireframe.apply { - copy( - x = fakeWireframe.x() + aClipLeft, - y = fakeWireframe.y() + aClipTop, - width = fakeWireframe.width() - aClipRight, - height = fakeWireframe.height() - aClipBottom - ) + forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(fakeParentBounds) } }.toMutableList() - val randomIndex = forge.anInt(min = 0, max = fakeRandomParents.size) - val fakeParents = fakeRandomParents.toMutableList().apply { - add(randomIndex, fakeParentWireframe) - } - // Then - assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeParents)) - .isEqualTo(fakExpectedClip) + assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeRandomParents)) + .isEqualTo(fakeWireframe.clip()) } - @ParameterizedTest - @MethodSource("resolveClipWireframes") - fun `M return null W resolveWireframeClip {inside parents bounds}`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M return Wireframe clip W resolveWireframeClip(bigger parent, clip bottom is null)`( forge: Forge ) { // Given - val fakeParents: List = forge.aList { - val fakeSpaceTop = forge.aLong(min = 0, max = 100) - val fakeSpaceLeft = forge.aLong(min = 0, max = 100) - fakeWireframe.copy( - x = fakeWireframe.x() - fakeSpaceLeft, - y = fakeWireframe.y() - fakeSpaceTop, - width = fakeWireframe.width() + fakeSpaceLeft + forge.aLong(min = 0, max = 100), - height = fakeWireframe.height() + fakeSpaceTop + forge.aLong(min = 0, max = 100) + val fakeWireframe: MobileSegment.Wireframe = forge.getForgery().copy( + clip = MobileSegment.WireframeClip( + left = forge.aPositiveLong(true), + right = forge.aPositiveLong(true), + top = forge.aPositiveLong(true), + bottom = null ) - } - + ) + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } + val fakeParentBounds = fakeWireframeBounds.copy( + left = fakeWireframeBounds.left - forge.aLong(min = 0, max = fakeWireframeBounds.left), + top = fakeWireframeBounds.top - forge.aLong(min = 0, max = fakeWireframeBounds.top), + right = fakeWireframeBounds.right + + forge.aLong(min = 0, max = fakeWireframeBounds.right), + bottom = fakeWireframeBounds.bottom + + forge.aLong(min = 0, max = fakeWireframeBounds.bottom) + ) + val fakeRandomParents: List = forge.aList { + forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(fakeParentBounds) + } + }.toMutableList() // Then - assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeParents)) - .isNull() + assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeRandomParents)) + .isEqualTo(fakeWireframe.clip()) } @Test - fun `M return null W resolveWireframeClip{no parents}`(forge: Forge) { - // Given - val wireframe: MobileSegment.Wireframe = forge.getForgery() - - // Then - assertThat(testedWireframeUtils.resolveWireframeClip(wireframe, emptyList())).isNull() - } - - // endregion - - // region is checkWireframeIsCovered - - @ParameterizedTest - @MethodSource("wireframesWithShapeStyle") - fun `M return true W checkWireframeIsCovered(){ shapeStyle wireframe with solid background }`( - fakeWireframe: MobileSegment.Wireframe, + fun `M return null W resolveWireframeClip(bigger parent, clip is empty)`( forge: Forge ) { // Given - val topWireframes = forge.aList { - val fakeX = forge.aLong(min = -100, max = fakeWireframe.x()) - val fakeY = forge.aLong(min = -100, max = fakeWireframe.y()) - val fakeMinWidth = abs(fakeX) - abs(fakeWireframe.x()) + fakeWireframe.width() - val fakeMinHeight = abs(fakeY) - abs(fakeWireframe.y()) + fakeWireframe.height() - val fakeWidth = forge.aLong(min = fakeMinWidth, max = Int.MAX_VALUE.toLong()) - val fakeHeight = forge.aLong(min = fakeMinHeight, max = Int.MAX_VALUE.toLong()) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeX, - y = fakeY, - width = fakeWidth, - height = fakeHeight, - shapeStyle = forge.forgeNonTransparentShapeStyle() + val fakeWireframe: MobileSegment.Wireframe = forge.getForgery() + .copy( + clip = MobileSegment.WireframeClip( + left = 0, + right = 0, + top = 0, + bottom = 0 + ) ) - fakeCoverAllWireframe - } - + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } + val fakeParentBounds = fakeWireframeBounds.copy( + left = fakeWireframeBounds.left - forge.aLong(min = 0, max = fakeWireframeBounds.left), + top = fakeWireframeBounds.top - forge.aLong(min = 0, max = fakeWireframeBounds.top), + right = fakeWireframeBounds.right + + forge.aLong(min = 0, max = fakeWireframeBounds.right), + bottom = fakeWireframeBounds.bottom + + forge.aLong(min = 0, max = fakeWireframeBounds.bottom) + ) + val fakeRandomParents: List = forge.aList { + forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(fakeParentBounds) + } + }.toMutableList() // Then - assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) - .isTrue + assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeRandomParents)) + .isNull() } - @ParameterizedTest - @MethodSource("wireframesWithShapeStyle") - fun `M return false W checkWireframeIsCovered(){ shapeStyle wireframe without background }`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M return null W resolveWireframeClip(bigger parent, clip is null)`( forge: Forge ) { // Given - val topWireframes = forge.aList { - val fakeX = forge.aLong(min = -100, max = fakeWireframe.x()) - val fakeY = forge.aLong(min = -100, max = fakeWireframe.y()) - val fakeMinWidth = abs(fakeX) - abs(fakeWireframe.x()) + fakeWireframe.width() - val fakeMinHeight = abs(fakeY) - abs(fakeWireframe.y()) + fakeWireframe.height() - val fakeWidth = forge.aLong(min = fakeMinWidth, max = Int.MAX_VALUE.toLong()) - val fakeHeight = forge.aLong(min = fakeMinHeight, max = Int.MAX_VALUE.toLong()) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeX, - y = fakeY, - width = fakeWidth, - height = fakeHeight, - shapeStyle = null - ) - fakeCoverAllWireframe - } - + val fakeWireframe: MobileSegment.Wireframe = + forge.getForgery().copy(clip = null) + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } + val fakeParentBounds = fakeWireframeBounds.copy( + left = fakeWireframeBounds.left - forge.aLong(min = 0, max = fakeWireframeBounds.left), + top = fakeWireframeBounds.top - forge.aLong(min = 0, max = fakeWireframeBounds.top), + right = fakeWireframeBounds.right + + forge.aLong(min = 0, max = fakeWireframeBounds.right), + bottom = fakeWireframeBounds.bottom + + forge.aLong(min = 0, max = fakeWireframeBounds.bottom) + ) + val fakeRandomParents: List = forge.aList { + forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(fakeParentBounds) + } + }.toMutableList() // Then - assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) - .isFalse + assertThat(testedWireframeUtils.resolveWireframeClip(fakeWireframe, fakeRandomParents)) + .isNull() } - @ParameterizedTest - @MethodSource("wireframesWithShapeStyle") - fun `M return false W checkWireframeIsCovered(){ shapeStyleWireframe translucent background }`( - fakeWireframe: MobileSegment.Wireframe, - forge: Forge - ) { + @Test + fun `M return null W resolveWireframeClip{no parents, clip is empty}`(forge: Forge) { // Given - val topWireframes = forge.aList { - val fakeX = forge.aLong(min = -100, max = fakeWireframe.x()) - val fakeY = forge.aLong(min = -100, max = fakeWireframe.y()) - val fakeMinWidth = abs(fakeX) - abs(fakeWireframe.x()) + fakeWireframe.width() - val fakeMinHeight = abs(fakeY) - abs(fakeWireframe.y()) + fakeWireframe.height() - val fakeWidth = forge.aLong(min = fakeMinWidth, max = Int.MAX_VALUE.toLong()) - val fakeHeight = forge.aLong(min = fakeMinHeight, max = Int.MAX_VALUE.toLong()) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeX, - y = fakeY, - width = fakeWidth, - height = fakeHeight, - shapeStyle = forge.forgeNonTransparentShapeStyle() - .copy(opacity = forge.aFloat(min = 0f, max = 1f)) + val wireframe: MobileSegment.Wireframe = forge.getForgery().copy( + clip = MobileSegment.WireframeClip( + left = 0, + right = 0, + top = 0, + bottom = 0 ) - fakeCoverAllWireframe - } + ) // Then - assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) - .isFalse + assertThat(testedWireframeUtils.resolveWireframeClip(wireframe, emptyList())).isNull() } - @ParameterizedTest - @MethodSource("wireframesWithShapeStyle") - fun `M return false W checkWireframeIsCovered(){shapeStyleWireframe background with no color}`( - fakeWireframe: MobileSegment.Wireframe, - forge: Forge - ) { + @Test + fun `M return null W resolveWireframeClip{no parents, clip is null}`(forge: Forge) { // Given - val topWireframes = forge.aList { - val fakeX = forge.aLong(min = -100, max = fakeWireframe.x()) - val fakeY = forge.aLong(min = -100, max = fakeWireframe.y()) - val fakeMinWidth = abs(fakeX) - abs(fakeWireframe.x()) + fakeWireframe.width() - val fakeMinHeight = abs(fakeY) - abs(fakeWireframe.y()) + fakeWireframe.height() - val fakeWidth = forge.aLong(min = fakeMinWidth, max = Int.MAX_VALUE.toLong()) - val fakeHeight = forge.aLong(min = fakeMinHeight, max = Int.MAX_VALUE.toLong()) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeX, - y = fakeY, - width = fakeWidth, - height = fakeHeight, - shapeStyle = forge.forgeNonTransparentShapeStyle() - .copy(backgroundColor = null) - ) - fakeCoverAllWireframe - } + val wireframe: MobileSegment.Wireframe = forge.getForgery().copy( + clip = null + ) // Then - assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) - .isFalse + assertThat(testedWireframeUtils.resolveWireframeClip(wireframe, emptyList())).isNull() } - @ParameterizedTest - @MethodSource("wireframesWithShapeStyle") - fun `M return false W checkWireframeIsCovered(){shapeStyleWireframe translucent color}`( - fakeWireframe: MobileSegment.Wireframe, - forge: Forge - ) { - // Given - val topWireframes = forge.aList { - val fakeX = forge.aLong(min = -100, max = fakeWireframe.x()) - val fakeY = forge.aLong(min = -100, max = fakeWireframe.y()) - val fakeMinWidth = abs(fakeX) - abs(fakeWireframe.x()) + fakeWireframe.width() - val fakeMinHeight = abs(fakeY) - abs(fakeWireframe.y()) + fakeWireframe.height() - val fakeWidth = forge.aLong(min = fakeMinWidth, max = Int.MAX_VALUE.toLong()) - val fakeHeight = forge.aLong(min = fakeMinHeight, max = Int.MAX_VALUE.toLong()) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeX, - y = fakeY, - width = fakeWidth, - height = fakeHeight, - shapeStyle = forge.forgeNonTransparentShapeStyle() - .copy( - backgroundColor = forge.aStringMatching( - "#[0-9A-Fa-f]{6}[0-9A-Ea-e]{2}" - ) - ) - ) - fakeCoverAllWireframe - } + // endregion - // Then - assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) - .isFalse - } + // region is checkWireframeIsCovered - @ParameterizedTest - @MethodSource("placeholderWireframes") - fun `M return true W checkWireframeIsCovered(){ PlaceHolder wireframe }`( - fakeWireframe: MobileSegment.Wireframe.PlaceholderWireframe, + @Test + fun `M return true W checkWireframeIsCovered(){top wireframe with solid background }`( + @Forgery fakeWireframe: MobileSegment.Wireframe, forge: Forge ) { // Given - val topWireframes = forge.aList { - val fakeX = forge.aLong(min = -100, max = fakeWireframe.x()) - val fakeY = forge.aLong(min = -100, max = fakeWireframe.y()) - val fakeMinWidth = abs(fakeX) - abs(fakeWireframe.x()) + fakeWireframe.width() - val fakeMinHeight = abs(fakeY) - abs(fakeWireframe.y()) + fakeWireframe.height() - val fakeWidth = forge.aLong(min = fakeMinWidth, max = Int.MAX_VALUE.toLong()) - val fakeHeight = forge.aLong(min = fakeMinHeight, max = Int.MAX_VALUE.toLong()) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeX, - y = fakeY, - width = fakeWidth, - height = fakeHeight - ) - fakeCoverAllWireframe + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } + val topWireframes = forge.opaqueWireframes() + topWireframes.forEach { + val topWireframeBounds: WireframeBounds = forge.getForgery() + it.copy(shapeStyle = forge.forgeNonTransparentShapeStyle()).apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) + } } // Then @@ -417,58 +381,21 @@ internal class WireframeUtilsTest { .isTrue } - @ParameterizedTest - @MethodSource("imageWireframesWithoutShapeStyle") - fun `M return true W checkWireframeIsCovered { valid ImageWireframe, no solid background }`( - fakeWireframe: MobileSegment.Wireframe.ImageWireframe + @Test + fun `M return false W checkWireframeIsCovered(){top wireframes with transparent back}`( + @Forgery fakeWireframe: MobileSegment.Wireframe, + forge: Forge ) { // Given - val topWireframes = forge.aList { - val fakeX = forge.aLong(min = -100, max = fakeWireframe.x()) - val fakeY = forge.aLong(min = -100, max = fakeWireframe.y()) - val fakeMinWidth = abs(fakeX) - abs(fakeWireframe.x()) + - fakeWireframe.width() - val fakeMinHeight = abs(fakeY) - abs(fakeWireframe.y()) + - fakeWireframe.height() - val fakeWidth = forge.aLong(min = fakeMinWidth, max = Int.MAX_VALUE.toLong()) - val fakeHeight = forge.aLong(min = fakeMinHeight, max = Int.MAX_VALUE.toLong()) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeX, - y = fakeY, - width = fakeWidth, - height = fakeHeight - ) - fakeCoverAllWireframe + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) } - - // Then - assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) - .isTrue - } - - @ParameterizedTest - @MethodSource("imageWireframesWithoutShapeStyle") - fun `M return false W checkWireframeIsCovered { invalid ImageWireframe, no solid background }`( - fakeWireframe: MobileSegment.Wireframe.ImageWireframe - ) { - // Given - val topWireframes = forge.aList { - val fakeX = forge.aLong(min = -100, max = fakeWireframe.x()) - val fakeY = forge.aLong(min = -100, max = fakeWireframe.y()) - val fakeMinWidth = abs(fakeX) - abs(fakeWireframe.x()) + - fakeWireframe.width() - val fakeMinHeight = abs(fakeY) - abs(fakeWireframe.y()) + - fakeWireframe.height() - val fakeWidth = forge.aLong(min = fakeMinWidth, max = Int.MAX_VALUE.toLong()) - val fakeHeight = forge.aLong(min = fakeMinHeight, max = Int.MAX_VALUE.toLong()) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeX, - y = fakeY, - width = fakeWidth, - height = fakeHeight, - base64 = null - ) - fakeCoverAllWireframe + val topWireframes = forge.wireframesWithNoBackground() + topWireframes.forEach { + val topWireframeBounds: WireframeBounds = forge.getForgery() + whenever(mockBoundsUtils.resolveBounds(it)).thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) } // Then @@ -476,45 +403,26 @@ internal class WireframeUtilsTest { .isFalse } - @ParameterizedTest - @MethodSource("wireframesWithSolidBackground") - fun `M return false W checkWireframeIsCovered(){ top is bigger }`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M return false W checkWireframeIsCovered(){top wireframes with no background color}`( + @Forgery fakeWireframe: MobileSegment.Wireframe, forge: Forge ) { // Given - val topWireframes = forge.aList { - val fakeY = forge.aLong(min = fakeWireframe.y() + 1) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeWireframe.x(), - y = fakeY, - width = fakeWireframe.width(), - height = fakeWireframe.height() - ) - fakeCoverAllWireframe + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) } - - // Then - assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) - .isFalse - } - - @ParameterizedTest - @MethodSource("wireframesWithSolidBackground") - fun `M return false W checkWireframeIsCovered(){ bottom is bigger }`( - fakeWireframe: MobileSegment.Wireframe, - forge: Forge - ) { - // Given - val topWireframes = forge.aList { - val fakeHeight = forge.aLong(min = 0, max = fakeWireframe.height() - 1) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeWireframe.x(), - y = fakeWireframe.y(), - width = fakeWireframe.width(), - height = fakeHeight - ) - fakeCoverAllWireframe + val topWireframes = forge.wireframesWithNoBackgroundColor() + topWireframes.forEach { + val topWireframeBounds: WireframeBounds = forge.getForgery() + it.copy( + shapeStyle = forge.forgeNonTransparentShapeStyle() + .copy(backgroundColor = null) + ).apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) + } } // Then @@ -522,45 +430,21 @@ internal class WireframeUtilsTest { .isFalse } - @ParameterizedTest - @MethodSource("wireframesWithSolidBackground") - fun `M return false W checkWireframeIsCovered(){ left is bigger }`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M return false W checkWireframeIsCovered(){top wireframes with translucent background}`( + @Forgery fakeWireframe: MobileSegment.Wireframe, forge: Forge ) { // Given - val topWireframes = forge.aList { - val fakeX = forge.aLong(min = fakeWireframe.x() + 1) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeX, - y = fakeWireframe.y(), - width = fakeWireframe.width(), - height = fakeWireframe.height() - ) - fakeCoverAllWireframe + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) } - - // Then - assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) - .isFalse - } - - @ParameterizedTest - @MethodSource("wireframesWithSolidBackground") - fun `M return false W checkWireframeIsCovered(){ right is bigger }`( - fakeWireframe: MobileSegment.Wireframe, - forge: Forge - ) { - // Given - val topWireframes = forge.aList { - val fakeWidth = forge.aLong(min = 0, max = fakeWireframe.width() - 1) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeWireframe.x(), - y = fakeWireframe.y(), - width = fakeWidth, - height = fakeWireframe.height() - ) - fakeCoverAllWireframe + val topWireframes = forge.wireframesWithTranslucentBackgroundColor() + topWireframes.forEach { + val topWireframeBounds: WireframeBounds = forge.getForgery() + whenever(mockBoundsUtils.resolveBounds(it)).thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) } // Then @@ -568,97 +452,53 @@ internal class WireframeUtilsTest { .isFalse } - @ParameterizedTest - @MethodSource("wireframesWithSolidBackground") - fun `M return false W checkWireframeIsCovered(){ all sides are bigger }`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M return true W checkWireframeIsCovered(){top Placeholder wireframe, no shapeStyle}`( + @Forgery fakeWireframe: MobileSegment.Wireframe, forge: Forge ) { // Given - val topWireframes = forge.aList { - val fakeX = forge.aLong(min = fakeWireframe.x() + 1) - val fakeY = forge.aLong(min = fakeWireframe.y() + 1) - val fakeWidth = forge.aLong(min = 0, max = fakeWireframe.width() - 1) - val fakeHeight = forge.aLong(min = 0, max = fakeWireframe.height() - 1) - val fakeCoverAllWireframe = fakeWireframe.copy( - x = fakeX, - y = fakeY, - width = fakeWidth, - height = fakeHeight - ) - fakeCoverAllWireframe + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) } - - // Then - assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) - .isFalse - } - - @ParameterizedTest - @MethodSource("wireframesWithSolidBackground") - fun `M return false W checkWireframeIsCovered(){ parent clip left is bigger }`( - fakeWireframe: MobileSegment.Wireframe, - forge: Forge - ) { - // Given val topWireframes = forge.aList { - val fakeWireframeClipLeft = fakeWireframe.clip()?.left ?: 0 - val fakeParentClipLeft = forge.aLong( - min = fakeWireframeClipLeft + 1, - max = fakeWireframeClipLeft + 10 - ) - val fakeParentClip = fakeWireframe.clip()?.copy(left = fakeParentClipLeft) - ?: MobileSegment.WireframeClip(left = fakeParentClipLeft) - val fakeCoverAllWireframe = fakeWireframe.copy(clip = fakeParentClip) - fakeCoverAllWireframe + forge.getForgery() + } + topWireframes.forEach { + val topWireframeBounds: WireframeBounds = forge.getForgery() + it.copy(shapeStyle = null).apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) + } } // Then assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) - .isFalse + .isTrue } - @ParameterizedTest - @MethodSource("wireframesWithSolidBackground") - fun `M return false W checkWireframeIsCovered(){ parent clip right is bigger }`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M return false W checkWireframeIsCovered { top empty ImageWireframe }`( + @Forgery fakeWireframe: MobileSegment.Wireframe.ImageWireframe, forge: Forge ) { // Given - val topWireframes = forge.aList { - val fakeWireframeClipRight = fakeWireframe.clip()?.right ?: 0 - val fakeParentClipRight = forge.aLong( - min = fakeWireframeClipRight + 1, - max = fakeWireframeClipRight + 10 - ) - val fakeParentClip = fakeWireframe.clip()?.copy(right = fakeParentClipRight) - ?: MobileSegment.WireframeClip(right = fakeParentClipRight) - val fakeCoverAllWireframe = fakeWireframe.copy(clip = fakeParentClip) - fakeCoverAllWireframe + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) } - - // Then - assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) - .isFalse - } - - @ParameterizedTest - @MethodSource("wireframesWithSolidBackground") - fun `M return false W checkWireframeIsCovered(){ parent clip top is bigger }`( - fakeWireframe: MobileSegment.Wireframe, - forge: Forge - ) { - // Given val topWireframes = forge.aList { - val fakeWireframeClipTop = fakeWireframe.clip()?.top ?: 0 - val fakeParentClipTop = forge.aLong( - min = fakeWireframeClipTop + 1, - max = fakeWireframeClipTop + 10 - ) - val fakeParentClip = fakeWireframe.clip()?.copy(top = fakeParentClipTop) - ?: MobileSegment.WireframeClip(top = fakeParentClipTop) - val fakeCoverAllWireframe = fakeWireframe.copy(clip = fakeParentClip) - fakeCoverAllWireframe + forge.getForgery() + .copy(shapeStyle = null, base64 = null) + } + topWireframes.forEach { + val topWireframeBounds: WireframeBounds = forge.getForgery() + it.copy(shapeStyle = null).apply { + whenever(mockBoundsUtils.resolveBounds(this)) + .thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) + } } // Then @@ -666,23 +506,27 @@ internal class WireframeUtilsTest { .isFalse } - @ParameterizedTest - @MethodSource("wireframesWithSolidBackground") - fun `M return false W checkWireframeIsCovered(){ parent clip bottom is bigger }`( - fakeWireframe: MobileSegment.Wireframe, + @Test + fun `M return false W checkWireframeIsCovered { top base64 ImageWireframe, no ShapeStyle}`( + @Forgery fakeWireframe: MobileSegment.Wireframe.ImageWireframe, forge: Forge ) { // Given + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } val topWireframes = forge.aList { - val fakeWireframeClipBottom = fakeWireframe.clip()?.bottom ?: 0 - val fakeParentClipBottom = forge.aLong( - min = fakeWireframeClipBottom + 1, - max = fakeWireframeClipBottom + 10 - ) - val fakeParentClip = fakeWireframe.clip()?.copy(bottom = fakeParentClipBottom) - ?: MobileSegment.WireframeClip(bottom = fakeParentClipBottom) - val fakeCoverAllWireframe = fakeWireframe.copy(clip = fakeParentClip) - fakeCoverAllWireframe + forge.getForgery() + .copy(shapeStyle = null, base64 = forge.anAlphabeticalString()) + } + topWireframes.forEach { + val topWireframeBounds: WireframeBounds = forge.getForgery() + it.copy(shapeStyle = null).apply { + whenever(mockBoundsUtils.resolveBounds(this)) + .thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) + } } // Then @@ -696,10 +540,13 @@ internal class WireframeUtilsTest { @Test fun `M return false W checkWireframeIsValid(){ wireframe width is 0 }`( + @Forgery fakeWireframe: MobileSegment.Wireframe, forge: Forge ) { // Given - val fakeWireframe = forge.getForgeryWithIntRangeCoordinates().copyWithWidth(width = 0) + forge.getForgery().copy(width = 0).apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } // Then assertThat(testedWireframeUtils.checkWireframeIsValid(fakeWireframe)).isFalse @@ -707,10 +554,13 @@ internal class WireframeUtilsTest { @Test fun `M return false W checkWireframeIsValid(){ wireframe height is 0 }`( + @Forgery fakeWireframe: MobileSegment.Wireframe, forge: Forge ) { // Given - val fakeWireframe = forge.getForgeryWithIntRangeCoordinates().copyWithHeight(height = 0) + forge.getForgery().copy(height = 0).apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } // Then assertThat(testedWireframeUtils.checkWireframeIsValid(fakeWireframe)).isFalse @@ -723,6 +573,12 @@ internal class WireframeUtilsTest { // Given val fakeWireframe = forge.getForgery() .copy(shapeStyle = null, border = null) + forge.getForgery().copy( + width = forge.aPositiveLong(true), + height = forge.aPositiveLong(true) + ).apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } // Then assertThat(testedWireframeUtils.checkWireframeIsValid(fakeWireframe)).isFalse @@ -735,6 +591,12 @@ internal class WireframeUtilsTest { // Given val fakeWireframe = forge.getForgery() .copy(shapeStyle = forge.getForgery(), border = null) + forge.getForgery().copy( + width = forge.aPositiveLong(true), + height = forge.aPositiveLong(true) + ).apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } // Then assertThat(testedWireframeUtils.checkWireframeIsValid(fakeWireframe)).isTrue @@ -747,6 +609,12 @@ internal class WireframeUtilsTest { // Given val fakeWireframe = forge.getForgery() .copy(shapeStyle = null, border = forge.getForgery()) + forge.getForgery().copy( + width = forge.aPositiveLong(true), + height = forge.aPositiveLong(true) + ).apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } // Then assertThat(testedWireframeUtils.checkWireframeIsValid(fakeWireframe)).isTrue @@ -759,6 +627,12 @@ internal class WireframeUtilsTest { // Given val fakeWireframe = forge.getForgery() .copy(shapeStyle = null, border = null) + forge.getForgery().copy( + width = forge.aPositiveLong(true), + height = forge.aPositiveLong(true) + ).apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } // Then assertThat(testedWireframeUtils.checkWireframeIsValid(fakeWireframe)).isTrue @@ -768,551 +642,142 @@ internal class WireframeUtilsTest { // region Internal - private fun MobileSegment.Wireframe.copy( - x: Long, - y: Long, - width: Long, - height: Long, - clip: MobileSegment.WireframeClip? - ): - MobileSegment.Wireframe { - return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> copy( - x = x, - y = y, - width = width, - height = height, - clip = clip - ) - is MobileSegment.Wireframe.TextWireframe -> copy( - x = x, - y = y, - width = width, - height = height, - clip = clip - ) - is MobileSegment.Wireframe.ImageWireframe -> copy( - x = x, - y = y, - width = width, - height = height, - clip = clip - ) - is MobileSegment.Wireframe.PlaceholderWireframe -> copy( - x = x, - y = y, - width = width, - height = height, - clip = clip - ) - } - } - - private fun MobileSegment.Wireframe.copy(x: Long, y: Long, width: Long, height: Long): - MobileSegment.Wireframe { - return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> copy( - x = x, - y = y, - width = width, - height = height - ) - is MobileSegment.Wireframe.TextWireframe -> copy( - x = x, - y = y, - width = width, - height = height - ) - is MobileSegment.Wireframe.ImageWireframe -> copy( - x = x, - y = y, - width = width, - height = height - ) - is MobileSegment.Wireframe.PlaceholderWireframe -> copy( - x = x, - y = y, - width = width, - height = height - ) - } - } - - private fun MobileSegment.Wireframe.copyWithWidth(width: Long): - MobileSegment.Wireframe { + private fun MobileSegment.Wireframe.clip(): MobileSegment.WireframeClip? { return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> copy(width = width) - is MobileSegment.Wireframe.TextWireframe -> copy(width = width) - is MobileSegment.Wireframe.ImageWireframe -> copy(width = width) - is MobileSegment.Wireframe.PlaceholderWireframe -> copy(width = width) + is MobileSegment.Wireframe.ShapeWireframe -> clip?.normalized() + is MobileSegment.Wireframe.TextWireframe -> clip?.normalized() + is MobileSegment.Wireframe.ImageWireframe -> clip?.normalized() + is MobileSegment.Wireframe.PlaceholderWireframe -> clip?.normalized() } } - private fun MobileSegment.Wireframe.copyWithHeight(height: Long): - MobileSegment.Wireframe { - return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> copy(height = height) - is MobileSegment.Wireframe.TextWireframe -> copy(height = height) - is MobileSegment.Wireframe.ImageWireframe -> copy(height = height) - is MobileSegment.Wireframe.PlaceholderWireframe -> copy(height = height) - } + private fun Forge.forgeNonTransparentShapeStyle(): MobileSegment.ShapeStyle { + return MobileSegment.ShapeStyle( + backgroundColor = aStringMatching("#[0-9A-Fa-f]{6}[fF]{2}"), + opacity = 1f, + cornerRadius = aPositiveLong() + ) } - private fun MobileSegment.Wireframe.clip(): MobileSegment.WireframeClip? { - return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> clip - is MobileSegment.Wireframe.TextWireframe -> clip - is MobileSegment.Wireframe.ImageWireframe -> clip - is MobileSegment.Wireframe.PlaceholderWireframe -> clip + private fun Long.longOrNull(): Long? { + return if (this == 0L) { + null + } else { + this } } - private fun MobileSegment.Wireframe.x(): Long { - return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> x - is MobileSegment.Wireframe.TextWireframe -> x - is MobileSegment.Wireframe.ImageWireframe -> x - is MobileSegment.Wireframe.PlaceholderWireframe -> x - } + private fun Long?.toLong(): Long { + return this ?: 0L } - private fun MobileSegment.Wireframe.y(): Long { - return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> y - is MobileSegment.Wireframe.TextWireframe -> y - is MobileSegment.Wireframe.ImageWireframe -> y - is MobileSegment.Wireframe.PlaceholderWireframe -> y - } + private fun MobileSegment.WireframeClip.normalized(): MobileSegment.WireframeClip { + return MobileSegment.WireframeClip( + top = top.toLong(), + bottom = bottom.toLong(), + left = left.toLong(), + right = right.toLong() + ) } - private fun MobileSegment.Wireframe.width(): Long { + private fun MobileSegment.Wireframe.copy( + shapeStyle: MobileSegment.ShapeStyle? + ): + MobileSegment.Wireframe { return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> width - is MobileSegment.Wireframe.TextWireframe -> width - is MobileSegment.Wireframe.ImageWireframe -> width - is MobileSegment.Wireframe.PlaceholderWireframe -> width - } - } + is MobileSegment.Wireframe.ShapeWireframe -> copy( + shapeStyle = shapeStyle + ) - private fun MobileSegment.Wireframe.height(): Long { - return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> height - is MobileSegment.Wireframe.TextWireframe -> height - is MobileSegment.Wireframe.ImageWireframe -> height - is MobileSegment.Wireframe.PlaceholderWireframe -> height - } - } + is MobileSegment.Wireframe.TextWireframe -> copy( + shapeStyle = shapeStyle + ) - private fun MobileSegment.Wireframe.shapeStyle(): MobileSegment.ShapeStyle? { - return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> shapeStyle - is MobileSegment.Wireframe.TextWireframe -> shapeStyle - is MobileSegment.Wireframe.ImageWireframe -> shapeStyle - is MobileSegment.Wireframe.PlaceholderWireframe -> null - } - } + is MobileSegment.Wireframe.ImageWireframe -> copy( + shapeStyle = shapeStyle + ) - private fun Forge.getForgeryWithIntRangeCoordinates(): MobileSegment.Wireframe { - return when (val wireframe = aValidWireframe()) { - is MobileSegment.Wireframe.ShapeWireframe -> { - wireframe.copy( - x = aLong(min = 1, max = 100), - y = aLong(min = 1, max = 100), - width = aLong(min = 1, max = 100), - height = aLong(min = 1, max = 100) - ) - } - is MobileSegment.Wireframe.TextWireframe -> { - wireframe.copy( - x = aLong(min = 1, max = 100), - y = aLong(min = 1, max = 100), - width = aLong(min = 1, max = 100), - height = aLong(min = 1, max = 100) - ) - } - is MobileSegment.Wireframe.ImageWireframe -> { - wireframe.copy( - x = aLong(min = 1, max = 100), - y = aLong(min = 1, max = 100), - width = aLong(min = 1, max = 100), - height = aLong(min = 1, max = 100) - ) - } - is MobileSegment.Wireframe.PlaceholderWireframe -> { - wireframe.copy( - x = aLong(min = 1, max = 100), - y = aLong(min = 1, max = 100), - width = aLong(min = 1, max = 100), - height = aLong(min = 1, max = 100) - ) - } + is MobileSegment.Wireframe.PlaceholderWireframe -> this } } - private fun Forge.forgeNonTransparentShapeStyle(): MobileSegment.ShapeStyle { - return MobileSegment.ShapeStyle( - backgroundColor = aStringMatching("#[0-9A-Fa-f]{6}[fF]{2}"), - opacity = 1f, - cornerRadius = aPositiveLong() + private fun Forge.opaqueWireframes(): List { + return listOf( + getForgery() + .copy(shapeStyle = getForgery(), border = getForgery()), + getForgery() + .copy(shapeStyle = getForgery(), border = getForgery()), + getForgery().copy( + shapeStyle = getForgery(), + border = getForgery() + ), + getForgery() ) } - // endregion - - companion object { - val forge = Forge() - - private fun Forge.forgeNonTransparentShapeStyle(): MobileSegment.ShapeStyle { - return MobileSegment.ShapeStyle( - backgroundColor = aStringMatching("#[0-9A-Fa-f]{6}[fF]{2}"), - opacity = 1f, - cornerRadius = aPositiveLong() - ) - } - private fun aValidWireframe(): MobileSegment.Wireframe { - return when (val wireframe = forge.getForgery()) { - is MobileSegment.Wireframe.ShapeWireframe -> - wireframe.copy(shapeStyle = forge.getForgery(), border = forge.getForgery()) - is MobileSegment.Wireframe.TextWireframe -> - wireframe.copy(shapeStyle = forge.getForgery(), border = forge.getForgery()) - is MobileSegment.Wireframe.ImageWireframe -> - wireframe.copy(shapeStyle = forge.getForgery(), border = forge.getForgery()) - else -> wireframe - } - } - private fun aValidShapeStyleWireframe(): MobileSegment.Wireframe { - // in case of Image we make sure the base64 is null to not influence the test - // an give false positives - return when (forge.anInt(min = 0, max = 3)) { - 0 -> forge.getForgery() - .copy(shapeStyle = forge.getForgery(), border = forge.getForgery()) - 1 -> forge.getForgery() - .copy(shapeStyle = forge.getForgery(), border = forge.getForgery(), base64 = null) - else -> forge.getForgery() - .copy(shapeStyle = forge.getForgery(), border = forge.getForgery()) - } - } - private fun aValidSolidBackgroundWireframe(): MobileSegment.Wireframe { - return when (forge.anInt(min = 0, max = 4)) { - 0 -> forge.getForgery() - .copy( - shapeStyle = forge.forgeNonTransparentShapeStyle(), - border = forge.getForgery() - ) - 1 -> forge.getForgery() - .copy( - shapeStyle = forge.forgeNonTransparentShapeStyle(), - border = forge.getForgery(), - base64 = forge.aString() - ) - 2 -> forge.getForgery() - .copy( - shapeStyle = forge.forgeNonTransparentShapeStyle(), - border = forge.getForgery() - ) - else -> forge.getForgery() - } - } - - private fun MobileSegment.Wireframe.copy( - x: Long, - y: Long, - width: Long, - height: Long, - clip: MobileSegment.WireframeClip? - ): - MobileSegment.Wireframe { - return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> copy( - x = x, - y = y, - width = width, - height = height, - clip = clip - ) - is MobileSegment.Wireframe.TextWireframe -> copy( - x = x, - y = y, - width = width, - height = height, - clip = clip - ) - is MobileSegment.Wireframe.ImageWireframe -> copy( - x = x, - y = y, - width = width, - height = height, - clip = clip - ) - is MobileSegment.Wireframe.PlaceholderWireframe -> copy( - x = x, - y = y, - width = width, - height = height, - clip = clip - ) - } - } - - private fun MobileSegment.Wireframe.copy( - x: Long, - y: Long, - width: Long, - height: Long, - shapeStyle: MobileSegment.ShapeStyle? - ): - MobileSegment.Wireframe { - return when (this) { - is MobileSegment.Wireframe.ShapeWireframe -> copy( - x = x, - y = y, - width = width, - height = height, - shapeStyle = shapeStyle - ) - is MobileSegment.Wireframe.TextWireframe -> copy( - x = x, - y = y, - width = width, - height = height, - shapeStyle = shapeStyle - ) - is MobileSegment.Wireframe.ImageWireframe -> copy( - x = x, - y = y, - width = width, - height = height, - shapeStyle = shapeStyle - ) - is MobileSegment.Wireframe.PlaceholderWireframe -> copy( - x = x, - y = y, - width = width, - height = height - ) - } - } - - @JvmStatic - fun resolveClipWireframes(): List { - ForgeConfigurator().configure(forge) - val negativeCoordinatesWireframe = forge.getForgery() + private fun Forge.wireframesWithNoBackground(): List { + return listOf( + getForgery() .copy( - x = forge.aLong(min = -100, max = 0), - y = forge.aLong(min = -100, max = 0), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = null - ) - val positiveCoordinatesWireframe = forge.getForgery() + shapeStyle = forgeNonTransparentShapeStyle() + .copy(opacity = aFloat(min = 0f, max = 1f)) + ), + getForgery() .copy( - x = forge.aLong(min = 0, max = 100), - y = forge.aLong(min = 0, max = 100), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = null - ) - return listOf(negativeCoordinatesWireframe, positiveCoordinatesWireframe) - } - - @JvmStatic - fun wireframesWithSolidBackground(): List { - ForgeConfigurator().configure(forge) - // we need to start from 2 when generating the random width and height as we have - // some scenarios in our tests where we generate a test wireframe from these - // wireframe using forge.min(0, fakeWireframe.height/width - 1). If we do not - // start from 2 the code will crash in the case of: forge.min(0, 0) with IAE - val negativeCoordinatesWireframe = aValidSolidBackgroundWireframe() + shapeStyle = forgeNonTransparentShapeStyle() + .copy(opacity = aFloat(min = 0f, max = 1f)) + ), + getForgery() .copy( - x = forge.aLong(min = -99, max = 0), - y = forge.aLong(min = -99, max = 0), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = forge.getForgery() + shapeStyle = forgeNonTransparentShapeStyle() + .copy(opacity = aFloat(min = 0f, max = 1f)), + base64 = null ) - val positiveCoordinatesWireframe = aValidSolidBackgroundWireframe() + ) + } + + private fun Forge.wireframesWithNoBackgroundColor(): List { + return listOf( + getForgery() .copy( - x = forge.aLong(min = 0, max = 100), - y = forge.aLong(min = 0, max = 100), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = forge.getForgery() - ) - val negativeCoordinatesWireframeNoClip = aValidSolidBackgroundWireframe() + shapeStyle = forgeNonTransparentShapeStyle() + .copy(backgroundColor = null) + ), + getForgery() .copy( - x = forge.aLong(min = -99, max = 0), - y = forge.aLong(min = -99, max = 0), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = null - ) - val positiveCoordinatesWireframeNoClip = aValidSolidBackgroundWireframe() + shapeStyle = forgeNonTransparentShapeStyle() + .copy(backgroundColor = null) + ), + getForgery() .copy( - x = forge.aLong(min = 0, max = 100), - y = forge.aLong(min = 0, max = 100), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = null + shapeStyle = forgeNonTransparentShapeStyle() + .copy(backgroundColor = null), + base64 = null ) - return listOf( - negativeCoordinatesWireframe, - positiveCoordinatesWireframe, - negativeCoordinatesWireframeNoClip, - positiveCoordinatesWireframeNoClip - ) - } + ) + } - @JvmStatic - fun wireframesWithShapeStyle(): List { - ForgeConfigurator().configure(forge) - // we need to start from 2 when generating the random width and height as we have - // some scenarios in our tests where we generate a test wireframe from these - // wireframe using forge.min(0, fakeWireframe.height/width - 1). If we do not - // start from 2 the code will crash in the case of: forge.min(0, 0) with IAE - val negativeCoordinatesWireframe = aValidShapeStyleWireframe() - .copy( - x = forge.aLong(min = -99, max = 0), - y = forge.aLong(min = -99, max = 0), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = forge.getForgery() - ) - val positiveCoordinatesWireframe = aValidShapeStyleWireframe() + private fun Forge.wireframesWithTranslucentBackgroundColor(): + List { + return listOf( + getForgery() .copy( - x = forge.aLong(min = 0, max = 100), - y = forge.aLong(min = 0, max = 100), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = forge.getForgery() - ) - val negativeCoordinatesWireframeNoClip = aValidShapeStyleWireframe() + shapeStyle = forgeNonTransparentShapeStyle() + .copy(backgroundColor = null) + ), + getForgery() .copy( - x = forge.aLong(min = -99, max = 0), - y = forge.aLong(min = -99, max = 0), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = null - ) - val positiveCoordinatesWireframeNoClip = aValidShapeStyleWireframe() + shapeStyle = forgeNonTransparentShapeStyle() + .copy(backgroundColor = null) + ), + getForgery() .copy( - x = forge.aLong(min = 0, max = 100), - y = forge.aLong(min = 0, max = 100), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = null - ) - return listOf( - negativeCoordinatesWireframe, - positiveCoordinatesWireframe, - negativeCoordinatesWireframeNoClip, - positiveCoordinatesWireframeNoClip - ) - } - - @JvmStatic - fun imageWireframesWithoutShapeStyle(): List { - ForgeConfigurator().configure(forge) - // we need to start from 2 when generating the random width and height as we have - // some scenarios in our tests where we generate a test wireframe from these - // wireframe using forge.min(0, fakeWireframe.height/width - 1). If we do not - // start from 2 the code will crash in the case of: forge.min(0, 0) with IAE - val negativeCoordinatesWireframe = - forge.getForgery().copy( - x = forge.aLong(min = -99, max = 0), - y = forge.aLong(min = -99, max = 0), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = forge.getForgery(), - shapeStyle = null, - border = null, - base64 = forge.aString() - ) - val positiveCoordinatesWireframe = - forge.getForgery().copy( - x = forge.aLong(min = 0, max = 100), - y = forge.aLong(min = 0, max = 100), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = forge.getForgery(), - shapeStyle = null, - border = null, - base64 = forge.aString() - ) - val negativeCoordinatesWireframeNoClip = - forge.getForgery().copy( - x = forge.aLong(min = -99, max = 0), - y = forge.aLong(min = -99, max = 0), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = null, - shapeStyle = null, - border = null, - base64 = forge.aString() - ) - val positiveCoordinatesWireframeNoClip = - forge.getForgery().copy( - x = forge.aLong(min = 0, max = 100), - y = forge.aLong(min = 0, max = 100), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = null, - shapeStyle = null, - border = null, - base64 = forge.aString() - ) - return listOf( - negativeCoordinatesWireframe, - positiveCoordinatesWireframe, - negativeCoordinatesWireframeNoClip, - positiveCoordinatesWireframeNoClip - ) - } - - @JvmStatic - fun placeholderWireframes(): List { - ForgeConfigurator().configure(forge) - // we need to start from 2 when generating the random width and height as we have - // some scenarios in our tests where we generate a test wireframe from these - // wireframe using forge.min(0, fakeWireframe.height/width - 1). If we do not - // start from 2 the code will crash in the case of: forge.min(0, 0) with IAE - val negativeCoordinatesWireframe = - forge.getForgery().copy( - x = forge.aLong(min = -99, max = 0), - y = forge.aLong(min = -99, max = 0), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = forge.getForgery() - ) - val positiveCoordinatesWireframe = - forge.getForgery().copy( - x = forge.aLong(min = 0, max = 100), - y = forge.aLong(min = 0, max = 100), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = forge.getForgery() - ) - val negativeCoordinatesWireframeNoClip = - forge.getForgery().copy( - x = forge.aLong(min = -99, max = 0), - y = forge.aLong(min = -99, max = 0), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = null - ) - val positiveCoordinatesWireframeNoClip = - forge.getForgery().copy( - x = forge.aLong(min = 0, max = 100), - y = forge.aLong(min = 0, max = 100), - width = forge.aLong(min = 2, max = 100), - height = forge.aLong(min = 2, max = 100), - clip = null + shapeStyle = forgeNonTransparentShapeStyle() + .copy(backgroundColor = null), + base64 = null ) - return listOf( - negativeCoordinatesWireframe, - positiveCoordinatesWireframe, - negativeCoordinatesWireframeNoClip, - positiveCoordinatesWireframeNoClip - ) - } + ) } + + // endregion } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64SerializerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64SerializerTest.kt index 74bdd27a86..7a9f33ed28 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64SerializerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64SerializerTest.kt @@ -13,15 +13,14 @@ import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.graphics.drawable.StateListDrawable import android.util.DisplayMetrics -import android.widget.ImageView import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.recorder.base64.Cache.Companion.DOES_NOT_IMPLEMENT_COMPONENTCALLBACKS import com.datadog.android.sessionreplay.internal.utils.Base64Utils import com.datadog.android.sessionreplay.internal.utils.DrawableUtils +import com.datadog.android.sessionreplay.internal.utils.DrawableUtils.Companion.MAX_BITMAP_SIZE_IN_BYTES import com.datadog.android.sessionreplay.model.MobileSegment import fr.xgouchet.elmyr.Forge -import fr.xgouchet.elmyr.annotation.FloatForgery import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -88,9 +87,6 @@ internal class Base64SerializerTest { @Mock lateinit var mockDisplayMetrics: DisplayMetrics - @Mock - lateinit var mockImageView: ImageView - @Mock lateinit var mockDrawable: Drawable @@ -112,9 +108,6 @@ internal class Base64SerializerTest { @IntForgery(min = 1) var fakeBitmapHeight: Int = 0 - @FloatForgery(min = 0f, max = 1f) - var mockDensity: Float = 0f - @Forgery lateinit var fakeImageWireframe: MobileSegment.Wireframe.ImageWireframe @@ -137,11 +130,15 @@ internal class Base64SerializerTest { drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), - config = anyOrNull() + config = anyOrNull(), + base64SerializerCallback = any(), + bitmapCreationCallback = any() ) - ).thenReturn(mockBitmap) + ).then { + (it.arguments[7] as Base64Serializer.BitmapCreationCallback).onReady(mockBitmap) + } - whenever(mockExecutorService.submit(any())).then { + whenever(mockExecutorService.execute(any())).then { (it.arguments[0] as Runnable).run() mock>() } @@ -164,9 +161,11 @@ internal class Base64SerializerTest { drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), - config = anyOrNull() + config = anyOrNull(), + base64SerializerCallback = any(), + bitmapCreationCallback = any() ) - ).thenReturn(null) + ).then { mockSerializerCallback.onReady() } // When testedBase64Serializer.handleBitmap( @@ -176,7 +175,7 @@ internal class Base64SerializerTest { drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe, - callback = mockSerializerCallback + base64SerializerCallback = mockSerializerCallback ) // Then @@ -185,18 +184,6 @@ internal class Base64SerializerTest { @Test fun `M callback with finishProcessingImage W handleBitmap() { created bmp async }`() { - // Given - whenever( - mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( - drawable = any(), - drawableWidth = any(), - drawableHeight = any(), - displayMetrics = any(), - requestedSizeInBytes = anyOrNull(), - config = anyOrNull() - ) - ).thenReturn(mockBitmap) - // When testedBase64Serializer.handleBitmap( applicationContext = mockApplicationContext, @@ -205,7 +192,7 @@ internal class Base64SerializerTest { drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe, - callback = mockSerializerCallback + base64SerializerCallback = mockSerializerCallback ) @@ -221,17 +208,6 @@ internal class Base64SerializerTest { val fakeBase64String = forge.anAsciiString() whenever(mockBase64LRUCache.get(mockDrawable)).thenReturn(fakeBase64String) - whenever( - mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( - drawable = any(), - drawableWidth = any(), - drawableHeight = any(), - displayMetrics = any(), - requestedSizeInBytes = anyOrNull(), - config = anyOrNull() - ) - ) - .thenReturn(mockBitmap) whenever(mockWebPImageCompression.compressBitmap(any())) .thenReturn(fakeByteArray) @@ -243,7 +219,7 @@ internal class Base64SerializerTest { drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe, - callback = mockSerializerCallback + base64SerializerCallback = mockSerializerCallback ) // Then @@ -261,7 +237,8 @@ internal class Base64SerializerTest { drawable = mockDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) } @@ -290,7 +267,8 @@ internal class Base64SerializerTest { drawable = mockDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -318,7 +296,8 @@ internal class Base64SerializerTest { drawable = mockDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) } @@ -338,7 +317,8 @@ internal class Base64SerializerTest { drawable = mockDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -348,7 +328,9 @@ internal class Base64SerializerTest { drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), - config = anyOrNull() + config = anyOrNull(), + base64SerializerCallback = any(), + bitmapCreationCallback = any() ) } @@ -373,7 +355,8 @@ internal class Base64SerializerTest { drawable = mockStateListDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -392,7 +375,8 @@ internal class Base64SerializerTest { drawable = mockStateListDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -411,7 +395,8 @@ internal class Base64SerializerTest { drawable = mockBitmapDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -421,7 +406,9 @@ internal class Base64SerializerTest { drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), - config = anyOrNull() + config = anyOrNull(), + base64SerializerCallback = any(), + bitmapCreationCallback = any() ) } @@ -437,7 +424,8 @@ internal class Base64SerializerTest { drawable = mockBitmapDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -447,7 +435,9 @@ internal class Base64SerializerTest { drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), - config = anyOrNull() + config = anyOrNull(), + base64SerializerCallback = any(), + bitmapCreationCallback = any() ) } @@ -460,7 +450,8 @@ internal class Base64SerializerTest { drawable = mockBitmapDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -482,7 +473,8 @@ internal class Base64SerializerTest { drawable = mockBitmapDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -496,7 +488,9 @@ internal class Base64SerializerTest { drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), - config = anyOrNull() + config = anyOrNull(), + base64SerializerCallback = any(), + bitmapCreationCallback = any() ) } @@ -512,7 +506,8 @@ internal class Base64SerializerTest { drawable = mockBitmapDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -526,7 +521,9 @@ internal class Base64SerializerTest { drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), - config = anyOrNull() + config = anyOrNull(), + base64SerializerCallback = any(), + bitmapCreationCallback = any() ) } @@ -543,7 +540,8 @@ internal class Base64SerializerTest { drawable = mockBitmapDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -565,7 +563,8 @@ internal class Base64SerializerTest { drawable = mockBitmapDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -584,7 +583,8 @@ internal class Base64SerializerTest { drawable = mockBitmapDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then @@ -603,30 +603,14 @@ internal class Base64SerializerTest { drawable = mockLayerDrawable, drawableWidth = mockDrawable.intrinsicWidth, drawableHeight = mockDrawable.intrinsicHeight, - imageWireframe = fakeImageWireframe + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback ) // Then verify(mockBitmapPool, times(1)).put(any()) } - @Test - fun `M call drawableUtils W getDrawableScaledDimensions()`() { - // When - testedBase64Serializer.getDrawableScaledDimensions( - view = mockImageView, - drawable = mockDrawable, - density = mockDensity - ) - - // Then - verify(mockDrawableUtils).getDrawableScaledDimensions( - view = mockImageView, - drawable = mockDrawable, - density = mockDensity - ) - } - @Test fun `M return correct callback W handleBitmap() { multiple threads, first takes longer }`( @Mock mockFirstCallback: Base64SerializerCallback, @@ -642,7 +626,7 @@ internal class Base64SerializerTest { drawableWidth = fakeBitmapWidth, drawableHeight = fakeBitmapHeight, imageWireframe = fakeImageWireframe, - callback = mockFirstCallback + base64SerializerCallback = mockFirstCallback ) Thread.sleep(1500) countDownLatch.countDown() @@ -655,7 +639,7 @@ internal class Base64SerializerTest { drawableWidth = fakeBitmapWidth, drawableHeight = fakeBitmapHeight, imageWireframe = fakeImageWireframe, - callback = mockSecondCallback + base64SerializerCallback = mockSecondCallback ) Thread.sleep(500) countDownLatch.countDown() @@ -671,6 +655,72 @@ internal class Base64SerializerTest { verify(mockSecondCallback).onReady() } + @Test + fun `M failover to manual bitmap creation W handleBitmap { bitmapDrawable returned empty bytearray }`( + @Mock mockCreatedBitmap: Bitmap + ) { + // Given + whenever(mockBitmapDrawable.bitmap).thenReturn(mockBitmap) + whenever(mockBitmap.width).thenReturn(fakeBitmapWidth) + whenever(mockBitmap.height).thenReturn(fakeBitmapHeight) + + whenever(mockBitmap.isRecycled) + .thenReturn(true) + .thenReturn(false) + + val emptyByteArray = ByteArray(0) + whenever(mockWebPImageCompression.compressBitmap(mockBitmap)) + .thenReturn(emptyByteArray) + + whenever(mockWebPImageCompression.compressBitmap(mockCreatedBitmap)) + .thenReturn(fakeByteArray) + + whenever(mockDrawableUtils.createScaledBitmap(mockBitmap)) + .thenReturn(mockBitmap) + .thenReturn(mockCreatedBitmap) + + whenever(mockBase64Utils.serializeToBase64String(fakeByteArray)) + .thenReturn(fakeBase64String) + + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockBitmapDrawable, + drawableWidth = fakeBitmapWidth, + drawableHeight = fakeBitmapHeight, + imageWireframe = fakeImageWireframe, + base64SerializerCallback = mockSerializerCallback + ) + + val drawableCaptor = argumentCaptor() + val intCaptor = argumentCaptor() + val displayMetricsCaptor = argumentCaptor() + val configCaptor = argumentCaptor() + val base64SerializerCallbackCaptor = argumentCaptor() + val bitmapCreationCallbackCaptor = argumentCaptor() + + // Then + verify(mockDrawableUtils, times(1)).createBitmapOfApproxSizeFromDrawable( + drawable = drawableCaptor.capture(), + drawableWidth = intCaptor.capture(), + drawableHeight = intCaptor.capture(), + displayMetrics = displayMetricsCaptor.capture(), + requestedSizeInBytes = intCaptor.capture(), + config = configCaptor.capture(), + base64SerializerCallback = base64SerializerCallbackCaptor.capture(), + bitmapCreationCallback = bitmapCreationCallbackCaptor.capture() + ) + + assertThat(drawableCaptor.firstValue).isEqualTo(mockBitmapDrawable) + assertThat(intCaptor.firstValue).isEqualTo(fakeBitmapWidth) + assertThat(intCaptor.secondValue).isEqualTo(fakeBitmapHeight) + assertThat(displayMetricsCaptor.firstValue).isEqualTo(mockDisplayMetrics) + assertThat(intCaptor.thirdValue).isEqualTo(MAX_BITMAP_SIZE_IN_BYTES) + assertThat(configCaptor.firstValue).isEqualTo(Bitmap.Config.ARGB_8888) + assertThat(base64SerializerCallbackCaptor.firstValue).isEqualTo(mockSerializerCallback) + } + private fun createBase64Serializer(): Base64Serializer { val builder = Base64Serializer.Builder( logger = mockLogger, diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageTypeResolverTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageTypeResolverTest.kt index 073f474ff4..35cba11327 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageTypeResolverTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageTypeResolverTest.kt @@ -21,6 +21,7 @@ import org.junit.jupiter.api.extension.Extensions import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @Extensions( @@ -48,8 +49,12 @@ internal class ImageTypeResolverTest { @IntForgery(min = IMAGE_DIMEN_CONSIDERED_PII_IN_DP) fakeWidth: Int, @IntForgery(min = 0, max = IMAGE_DIMEN_CONSIDERED_PII_IN_DP) fakeHeight: Int ) { + // Given + whenever(mockBitmapDrawable.intrinsicWidth).thenReturn(fakeWidth) + whenever(mockBitmapDrawable.intrinsicHeight).thenReturn(fakeHeight) + // When - val result = testedTypeResolver.isDrawablePII(mockBitmapDrawable, fakeWidth, fakeHeight) + val result = testedTypeResolver.isDrawablePII(mockBitmapDrawable, density = 1f) // Then assertThat(result).isTrue @@ -60,8 +65,12 @@ internal class ImageTypeResolverTest { @IntForgery(min = 0, max = IMAGE_DIMEN_CONSIDERED_PII_IN_DP) fakeWidth: Int, @IntForgery(min = IMAGE_DIMEN_CONSIDERED_PII_IN_DP) fakeHeight: Int ) { + // Given + whenever(mockBitmapDrawable.intrinsicWidth).thenReturn(fakeWidth) + whenever(mockBitmapDrawable.intrinsicHeight).thenReturn(fakeHeight) + // When - val result = testedTypeResolver.isDrawablePII(mockBitmapDrawable, fakeWidth, fakeHeight) + val result = testedTypeResolver.isDrawablePII(mockBitmapDrawable, density = 1f) // Then assertThat(result).isTrue @@ -72,8 +81,12 @@ internal class ImageTypeResolverTest { @IntForgery(min = IMAGE_DIMEN_CONSIDERED_PII_IN_DP) fakeWidth: Int, @IntForgery(min = IMAGE_DIMEN_CONSIDERED_PII_IN_DP) fakeHeight: Int ) { + // Given + whenever(mockBitmapDrawable.intrinsicWidth).thenReturn(fakeWidth) + whenever(mockBitmapDrawable.intrinsicHeight).thenReturn(fakeHeight) + // When - val result = testedTypeResolver.isDrawablePII(mockGradientDrawable, fakeWidth, fakeHeight) + val result = testedTypeResolver.isDrawablePII(mockGradientDrawable, density = 1f) // Then assertThat(result).isFalse @@ -84,8 +97,12 @@ internal class ImageTypeResolverTest { @IntForgery(min = 0, max = IMAGE_DIMEN_CONSIDERED_PII_IN_DP) fakeWidth: Int, @IntForgery(min = 0, max = IMAGE_DIMEN_CONSIDERED_PII_IN_DP) fakeHeight: Int ) { + // Given + whenever(mockBitmapDrawable.intrinsicWidth).thenReturn(fakeWidth) + whenever(mockBitmapDrawable.intrinsicHeight).thenReturn(fakeHeight) + // When - val result = testedTypeResolver.isDrawablePII(mockBitmapDrawable, fakeWidth, fakeHeight) + val result = testedTypeResolver.isDrawablePII(mockBitmapDrawable, density = 1f) // Then assertThat(result).isFalse diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelperTest.kt index bd28f591f9..62f92c9fa6 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelperTest.kt @@ -23,6 +23,7 @@ import com.datadog.android.sessionreplay.internal.recorder.ViewUtilsInternal import com.datadog.android.sessionreplay.internal.recorder.base64.ImageWireframeHelper.Companion.DRAWABLE_CHILD_NAME import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator +import com.datadog.android.utils.isCloseTo import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.annotation.LongForgery @@ -40,6 +41,7 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -67,7 +69,7 @@ internal class ImageWireframeHelperTest { lateinit var mockImageCompression: ImageCompression @Mock - lateinit var mockCallback: ImageWireframeHelperCallback + lateinit var mockImageWireframeHelperCallback: ImageWireframeHelperCallback @Mock lateinit var mockImageTypeResolver: ImageTypeResolver @@ -105,11 +107,11 @@ internal class ImageWireframeHelperTest { @LongForgery(min = 1) var fakeGeneratedIdentifier: Long = 0L - @LongForgery(min = 1, max = 300) - var fakeDrawableWidth: Long = 0L + @IntForgery(min = 1, max = 300) + var fakeDrawableWidth: Int = 0 - @LongForgery(min = 1, max = 300) - var fakeDrawableHeight: Long = 0L + @IntForgery(min = 1, max = 300) + var fakeDrawableHeight: Int = 0 private lateinit var fakeDrawableXY: Pair @@ -124,8 +126,8 @@ internal class ImageWireframeHelperTest { val fakeScreenWidth = 1000 val fakeScreenHeight = 1000 - val randomXLocation = forge.aLong(min = 1, max = fakeScreenWidth - fakeDrawableWidth) - val randomYLocation = forge.aLong(min = 1, max = fakeScreenHeight - fakeDrawableHeight) + val randomXLocation = forge.aLong(min = 1, max = (fakeScreenWidth - fakeDrawableWidth).toLong()) + val randomYLocation = forge.aLong(min = 1, max = (fakeScreenHeight - fakeDrawableHeight).toLong()) fakeDrawableXY = Pair(randomXLocation, randomYLocation) whenever(mockMappingContext.systemInformation).thenReturn(mockSystemInformation) whenever(mockSystemInformation.screenDensity).thenReturn(0f) @@ -154,8 +156,8 @@ internal class ImageWireframeHelperTest { DRAWABLE_CHILD_NAME + 1 ) ).thenReturn(fakeGeneratedIdentifier) - whenever(mockBounds.width).thenReturn(fakeDrawableWidth) - whenever(mockBounds.height).thenReturn(fakeDrawableHeight) + whenever(mockBounds.width).thenReturn(fakeDrawableWidth.toLong()) + whenever(mockBounds.height).thenReturn(fakeDrawableHeight.toLong()) whenever(mockBounds.x).thenReturn(0L) whenever(mockBounds.y).thenReturn(0L) @@ -180,12 +182,13 @@ internal class ImageWireframeHelperTest { y = 0, width = 0, height = 0, - callback = mockCallback + usePIIPlaceholder = true, + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) // Then assertThat(wireframe).isNull() - verifyNoInteractions(mockCallback) + verifyNoInteractions(mockImageWireframeHelperCallback) } @Test @@ -205,12 +208,13 @@ internal class ImageWireframeHelperTest { drawable = mockDrawable, shapeStyle = null, border = null, - callback = mockCallback + usePIIPlaceholder = true, + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) // Then assertThat(wireframe).isNull() - verifyNoInteractions(mockCallback) + verifyNoInteractions(mockImageWireframeHelperCallback) } @Test @@ -229,7 +233,8 @@ internal class ImageWireframeHelperTest { drawable = mockDrawable, shapeStyle = null, border = null, - callback = mockCallback + usePIIPlaceholder = true, + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) // Then @@ -252,7 +257,8 @@ internal class ImageWireframeHelperTest { drawable = mockDrawable, shapeStyle = null, border = null, - callback = mockCallback + usePIIPlaceholder = true, + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) // Then @@ -262,24 +268,23 @@ internal class ImageWireframeHelperTest { @Test fun `M return wireframe W createImageWireframe()`( @Mock mockShapeStyle: MobileSegment.ShapeStyle, - @Mock mockBorder: MobileSegment.ShapeBorder + @Mock mockBorder: MobileSegment.ShapeBorder, + @Mock stubWireframeClip: MobileSegment.WireframeClip ) { // Given - whenever( - mockUniqueIdentifierGenerator - .resolveChildUniqueIdentifier(any(), any()) - ) + whenever(mockUniqueIdentifierGenerator.resolveChildUniqueIdentifier(any(), any())) .thenReturn(fakeGeneratedIdentifier) val expectedWireframe = MobileSegment.Wireframe.ImageWireframe( id = fakeGeneratedIdentifier, x = fakeDrawableXY.first, y = fakeDrawableXY.second, - width = fakeDrawableWidth, - height = fakeDrawableHeight, + width = fakeDrawableWidth.toLong(), + height = fakeDrawableHeight.toLong(), shapeStyle = mockShapeStyle, border = mockBorder, base64 = "", + clip = stubWireframeClip, mimeType = fakeMimeType, isEmpty = true ) @@ -295,7 +300,9 @@ internal class ImageWireframeHelperTest { drawable = mockDrawable, shapeStyle = mockShapeStyle, border = mockBorder, - callback = mockCallback + imageWireframeHelperCallback = mockImageWireframeHelperCallback, + usePIIPlaceholder = true, + clipping = stubWireframeClip ) // Then @@ -312,9 +319,9 @@ internal class ImageWireframeHelperTest { argumentCaptor.allValues.forEach { it.onReady() } - verify(mockCallback).onStart() - verify(mockCallback).onFinished() - verifyNoMoreInteractions(mockCallback) + verify(mockImageWireframeHelperCallback).onStart() + verify(mockImageWireframeHelperCallback).onFinished() + verifyNoMoreInteractions(mockImageWireframeHelperCallback) assertThat(wireframe).isEqualTo(expectedWireframe) } @@ -333,11 +340,11 @@ internal class ImageWireframeHelperTest { mockTextView, mockMappingContext, 0, - callback = mockCallback + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) // Then - verifyNoInteractions(mockCallback) + verifyNoInteractions(mockImageWireframeHelperCallback) assertThat(wireframes).isEmpty() } @@ -361,7 +368,7 @@ internal class ImageWireframeHelperTest { mockTextView, mockMappingContext, 0, - callback = mockCallback + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) wireframes[0] as MobileSegment.Wireframe.ImageWireframe @@ -379,8 +386,8 @@ internal class ImageWireframeHelperTest { argumentCaptor.allValues.forEach { it.onReady() } - verify(mockCallback).onStart() - verify(mockCallback).onFinished() + verify(mockImageWireframeHelperCallback).onStart() + verify(mockImageWireframeHelperCallback).onFinished() assertThat(wireframes.size).isEqualTo(1) } @@ -405,7 +412,7 @@ internal class ImageWireframeHelperTest { mockTextView, mockMappingContext, 0, - callback = mockCallback + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) wireframes[0] as MobileSegment.Wireframe.ImageWireframe @@ -423,8 +430,8 @@ internal class ImageWireframeHelperTest { argumentCaptor.allValues.forEach { it.onReady() } - verify(mockCallback, times(2)).onStart() - verify(mockCallback, times(2)).onFinished() + verify(mockImageWireframeHelperCallback, times(2)).onStart() + verify(mockImageWireframeHelperCallback, times(2)).onFinished() assertThat(wireframes.size).isEqualTo(2) } @@ -439,11 +446,11 @@ internal class ImageWireframeHelperTest { mockTextView, mockMappingContext, 0, - callback = mockCallback + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) // Then - verifyNoInteractions(mockCallback) + verifyNoInteractions(mockImageWireframeHelperCallback) assertThat(wireframes).isEmpty() } @@ -468,11 +475,13 @@ internal class ImageWireframeHelperTest { currentWireframeIndex = 0, x = 0, y = 0, - width = 0, - height = 0, + width = fakeViewWidth, + height = fakeViewHeight, drawable = mockDrawable, shapeStyle = null, - border = null + border = null, + usePIIPlaceholder = true, + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) // Then @@ -484,7 +493,7 @@ internal class ImageWireframeHelperTest { drawableWidth = captor.capture(), drawableHeight = captor.capture(), imageWireframe = any(), - callback = any() + base64SerializerCallback = any() ) assertThat(captor.allValues).containsExactly(fakeViewWidth, fakeViewHeight) } @@ -497,11 +506,13 @@ internal class ImageWireframeHelperTest { currentWireframeIndex = 0, x = 0, y = 0, - width = 0, - height = 0, + width = fakeDrawableWidth, + height = fakeDrawableHeight, drawable = mockDrawable, shapeStyle = null, - border = null + border = null, + usePIIPlaceholder = true, + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) // Then @@ -513,29 +524,53 @@ internal class ImageWireframeHelperTest { drawableWidth = captor.capture(), drawableHeight = captor.capture(), imageWireframe = any(), - callback = any() + base64SerializerCallback = any() ) - assertThat(captor.allValues).containsExactly(fakeDrawableWidth.toInt(), fakeDrawableHeight.toInt()) + assertThat(captor.allValues).containsExactly(fakeDrawableWidth, fakeDrawableHeight) } @Test - fun `M not try to resolve bitmap W createImageWireframe() { PII image }`() { + fun `M not try to resolve bitmap W createImageWireframe() { PII image }`( + forge: Forge, + @Mock mockResources: Resources, + @Mock mockDisplayMetrics: DisplayMetrics, + @Mock mockContext: Context + ) { // Given - whenever(mockImageTypeResolver.isDrawablePII(any(), any(), any())).thenReturn(true) + whenever(mockImageTypeResolver.isDrawablePII(any(), any())).thenReturn(true) + + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + whenever(mockResources.displayMetrics).thenReturn(mockDisplayMetrics) + mockDisplayMetrics.density = 1f + whenever(mockContext.applicationContext).thenReturn(mockContext) + val mockView: View = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + + whenever(it.resources).thenReturn(mockResources) + whenever(it.context).thenReturn(mockContext) + } // When - testedHelper.createImageWireframe( + val result = testedHelper.createImageWireframe( view = mockView, - currentWireframeIndex = 0, - x = 0, - y = 0, - width = 0, - height = 0, + currentWireframeIndex = forge.aPositiveInt(), + x = forge.aPositiveLong(), + y = forge.aPositiveLong(), + width = forge.aPositiveInt(), + height = forge.aPositiveInt(), drawable = mockDrawable, shapeStyle = null, - border = null - ) + border = null, + usePIIPlaceholder = true, + imageWireframeHelperCallback = mockImageWireframeHelperCallback + ) as MobileSegment.Wireframe.PlaceholderWireframe // Then verify(mockBase64Serializer, never()).handleBitmap( @@ -545,14 +580,17 @@ internal class ImageWireframeHelperTest { drawableWidth = any(), drawableHeight = any(), imageWireframe = any(), - callback = any() + base64SerializerCallback = any() ) + + assertThat(isCloseTo(result.x.toInt(), fakeGlobalX)).isTrue + assertThat(isCloseTo(result.y.toInt(), fakeGlobalY)).isTrue } @Test fun `M try to resolve bitmap W createImageWireframe() { non-PII image }`() { // Given - whenever(mockImageTypeResolver.isDrawablePII(any(), any(), any())).thenReturn(false) + whenever(mockImageTypeResolver.isDrawablePII(any(), any())).thenReturn(false) // When testedHelper.createImageWireframe( @@ -564,7 +602,9 @@ internal class ImageWireframeHelperTest { height = 0, drawable = mockDrawable, shapeStyle = null, - border = null + border = null, + usePIIPlaceholder = true, + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) // Then @@ -575,14 +615,14 @@ internal class ImageWireframeHelperTest { drawableWidth = any(), drawableHeight = any(), imageWireframe = any(), - callback = any() + base64SerializerCallback = any() ) } @Test fun `M return content placeholder W createImageWireframe() { PII image }`() { // Given - whenever(mockImageTypeResolver.isDrawablePII(any(), any(), any())).thenReturn(true) + whenever(mockImageTypeResolver.isDrawablePII(any(), any())).thenReturn(true) // When val actualWireframe = testedHelper.createImageWireframe( @@ -594,7 +634,9 @@ internal class ImageWireframeHelperTest { height = 0, drawable = mockDrawable, shapeStyle = null, - border = null + border = null, + usePIIPlaceholder = true, + imageWireframeHelperCallback = mockImageWireframeHelperCallback ) // Then diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompressionTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompressionTest.kt index cc51cafdf0..d0e788c70b 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompressionTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompressionTest.kt @@ -24,6 +24,7 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @Extensions( @@ -85,5 +86,17 @@ internal class WebPImageCompressionTest { assertThat(captor.firstValue).isEqualTo(Bitmap.CompressFormat.WEBP) } + @Test + fun `M return empty bytearray W compressBitmap { bitmap was already recycled }`() { + // Given + whenever(mockBitmap.isRecycled).thenReturn(true) + + // When + val result = testedImageCompression.compressBitmap(mockBitmap) + + // Then + assertThat(result).isEmpty() + } + // endregion } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseEditTextViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseEditTextViewMapperTest.kt deleted file mode 100644 index bfed1faafc..0000000000 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseEditTextViewMapperTest.kt +++ /dev/null @@ -1,167 +0,0 @@ -/* - * 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.recorder.mapper - -import android.content.res.ColorStateList -import android.widget.EditText -import com.datadog.android.sessionreplay.internal.recorder.GlobalBounds -import com.datadog.android.sessionreplay.model.MobileSegment -import com.datadog.android.sessionreplay.utils.StringUtils -import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator -import com.datadog.android.sessionreplay.utils.ViewUtils -import fr.xgouchet.elmyr.Forge -import fr.xgouchet.elmyr.annotation.Forgery -import fr.xgouchet.elmyr.annotation.IntForgery -import fr.xgouchet.elmyr.annotation.LongForgery -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.mockito.Mock -import org.mockito.kotlin.any -import org.mockito.kotlin.eq -import org.mockito.kotlin.whenever - -internal abstract class BaseEditTextViewMapperTest : BaseWireframeMapperTest() { - - private lateinit var testedEditTextViewMapper: EditTextViewMapper - - @Mock - lateinit var mockuniqueIdentifierGenerator: UniqueIdentifierGenerator - - @Mock - lateinit var mockTextWireframeMapper: TextViewMapper - - @Forgery - lateinit var fakeTextWireframes: List - - @Mock - lateinit var mockEditText: EditText - - @Mock - lateinit var mockBackgroundTintList: ColorStateList - - @LongForgery - var fakeGeneratedIdentifier: Long = 0L - - @IntForgery - var fakeBackgroundTintColor: Int = 0 - - @IntForgery - var fakeTextColor: Int = 0 - - @Mock - lateinit var mockViewUtils: ViewUtils - - @Forgery - lateinit var fakeViewGlobalBounds: GlobalBounds - - @Mock - lateinit var mockStringUtils: StringUtils - - @BeforeEach - fun `set up`() { - whenever(mockEditText.currentTextColor).thenReturn(fakeTextColor) - whenever(mockBackgroundTintList.defaultColor).thenReturn(fakeBackgroundTintColor) - whenever( - mockuniqueIdentifierGenerator.resolveChildUniqueIdentifier( - mockEditText, - EditTextViewMapper.UNDERLINE_KEY_NAME - ) - ).thenReturn(fakeGeneratedIdentifier) - whenever(mockEditText.backgroundTintList).thenReturn(mockBackgroundTintList) - whenever(mockTextWireframeMapper.map(eq(mockEditText), eq(fakeMappingContext), any())) - .thenReturn(fakeTextWireframes) - whenever( - mockViewUtils.resolveViewGlobalBounds( - mockEditText, - fakeMappingContext.systemInformation.screenDensity - ) - ) - .thenReturn(fakeViewGlobalBounds) - testedEditTextViewMapper = initTestInstance() - } - - abstract fun initTestInstance(): EditTextViewMapper - - @Test - fun `M resolve the underline as ShapeWireframe W map()`(forge: Forge) { - // Given - val fakeExpectedUnderlineColor = forge.aStringMatching("#[0-9A-Fa-f]{8}") - whenever( - mockStringUtils.formatColorAndAlphaAsHexa( - fakeBackgroundTintColor, - OPAQUE_ALPHA_VALUE - ) - ) - .thenReturn(fakeExpectedUnderlineColor) - val expectedUnderlineShapeWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x, - y = fakeViewGlobalBounds.y + - fakeViewGlobalBounds.height - - EditTextViewMapper.UNDERLINE_HEIGHT_IN_PIXELS, - width = fakeViewGlobalBounds.width, - height = EditTextViewMapper.UNDERLINE_HEIGHT_IN_PIXELS, - shapeStyle = MobileSegment.ShapeStyle( - backgroundColor = fakeExpectedUnderlineColor, - opacity = mockEditText.alpha - ) - ) - - // When - assertThat(testedEditTextViewMapper.map(mockEditText, fakeMappingContext)) - .isEqualTo(fakeTextWireframes + expectedUnderlineShapeWireframe) - } - - @Test - fun `M resolve the underline color from textColor W map{backgroundTint is null}`( - forge: Forge - ) { - // Given - whenever(mockEditText.backgroundTintList).thenReturn(null) - val fakeExpectedUnderlineColor = forge.aStringMatching("#[0-9A-Fa-f]{8}") - whenever( - mockStringUtils.formatColorAndAlphaAsHexa( - fakeTextColor, - OPAQUE_ALPHA_VALUE - ) - ) - .thenReturn(fakeExpectedUnderlineColor) - val expectedUnderlineShapeWireframe = MobileSegment.Wireframe.ShapeWireframe( - id = fakeGeneratedIdentifier, - x = fakeViewGlobalBounds.x, - y = fakeViewGlobalBounds.y + - fakeViewGlobalBounds.height - - EditTextViewMapper.UNDERLINE_HEIGHT_IN_PIXELS, - width = fakeViewGlobalBounds.width, - height = EditTextViewMapper.UNDERLINE_HEIGHT_IN_PIXELS, - shapeStyle = MobileSegment.ShapeStyle( - backgroundColor = fakeExpectedUnderlineColor, - opacity = mockEditText.alpha - ) - ) - - // When - assertThat(testedEditTextViewMapper.map(mockEditText, fakeMappingContext)) - .isEqualTo(fakeTextWireframes + expectedUnderlineShapeWireframe) - } - - @Test - fun `M ignore the underline W map() { unique id could not be generated }`() { - // Given - whenever( - mockuniqueIdentifierGenerator.resolveChildUniqueIdentifier( - mockEditText, - EditTextViewMapper.UNDERLINE_KEY_NAME - ) - ).thenReturn(null) - - // Then - assertThat(testedEditTextViewMapper.map(mockEditText, fakeMappingContext)) - .isEqualTo(fakeTextWireframes) - } -} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseTextViewWireframeMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseTextViewWireframeMapperTest.kt index c7ccdac17d..ea2a761da6 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseTextViewWireframeMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseTextViewWireframeMapperTest.kt @@ -338,17 +338,19 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes .thenReturn(fakeDefaultObfuscatedText) whenever( mockImageWireframeHelper.createImageWireframe( - any(), - any(), - any(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - any() + view = any(), + currentWireframeIndex = any(), + x = any(), + y = any(), + width = any(), + height = any(), + usePIIPlaceholder = any(), + drawable = anyOrNull(), + shapeStyle = anyOrNull(), + border = anyOrNull(), + clipping = anyOrNull(), + prefix = anyOrNull(), + imageWireframeHelperCallback = anyOrNull() ) ).thenReturn(fakeBackgroundWireframe) @@ -377,17 +379,19 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes .isEqualTo((expectedWireframes[1] as MobileSegment.Wireframe.TextWireframe).text) argumentCaptor() { verify(mockImageWireframeHelper).createImageWireframe( - any(), - any(), - any(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - capture() + view = any(), + currentWireframeIndex = any(), + x = any(), + y = any(), + width = any(), + height = any(), + usePIIPlaceholder = any(), + drawable = anyOrNull(), + shapeStyle = anyOrNull(), + border = anyOrNull(), + clipping = anyOrNull(), + imageWireframeHelperCallback = capture(), + prefix = anyOrNull() ) allValues.forEach() { it.onStart() diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EditTextViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EditTextViewMapperTest.kt deleted file mode 100644 index 923a84053d..0000000000 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EditTextViewMapperTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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.recorder.mapper - -import com.datadog.android.sessionreplay.forge.ForgeConfigurator -import com.datadog.tools.unit.extensions.ApiLevelExtension -import fr.xgouchet.elmyr.junit5.ForgeConfiguration -import fr.xgouchet.elmyr.junit5.ForgeExtension -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.api.extension.Extensions -import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.junit.jupiter.MockitoSettings -import org.mockito.quality.Strictness - -@Extensions( - ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class), - ExtendWith(ApiLevelExtension::class) -) -@MockitoSettings(strictness = Strictness.LENIENT) -@ForgeConfiguration(ForgeConfigurator::class) -internal class EditTextViewMapperTest : BaseEditTextViewMapperTest() { - - override fun initTestInstance(): EditTextViewMapper { - return EditTextViewMapper( - mockTextWireframeMapper, - mockuniqueIdentifierGenerator, - mockViewUtils, - mockStringUtils - ) - } -} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageButtonMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapperTest.kt similarity index 63% rename from features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageButtonMapperTest.kt rename to features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapperTest.kt index 1b0df9ef4b..5adefdeab4 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageButtonMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapperTest.kt @@ -8,21 +8,21 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper import android.content.Context import android.content.res.Resources +import android.graphics.Rect import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable.ConstantState import android.util.DisplayMetrics -import android.widget.ImageButton +import android.widget.ImageView import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.AsyncJobStatusCallback import com.datadog.android.sessionreplay.internal.recorder.GlobalBounds import com.datadog.android.sessionreplay.internal.recorder.MappingContext import com.datadog.android.sessionreplay.internal.recorder.SystemInformation -import com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer import com.datadog.android.sessionreplay.internal.recorder.base64.ImageCompression import com.datadog.android.sessionreplay.internal.recorder.base64.ImageWireframeHelper import com.datadog.android.sessionreplay.internal.recorder.base64.ImageWireframeHelperCallback -import com.datadog.android.sessionreplay.internal.utils.DrawableDimensions +import com.datadog.android.sessionreplay.internal.utils.ImageViewUtils import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator import com.datadog.android.sessionreplay.utils.ViewUtils @@ -41,9 +41,6 @@ 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.eq -import org.mockito.kotlin.isA -import org.mockito.kotlin.isNull import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions @@ -56,12 +53,14 @@ import org.mockito.quality.Strictness ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) -internal class ImageButtonMapperTest { +internal class ImageViewMapperTest { + private lateinit var testedMapper: ImageViewMapper - private lateinit var testedMapper: ImageButtonMapper + @Mock + lateinit var mockImageView: ImageView @Mock - lateinit var mockImageButton: ImageButton + lateinit var stubImageViewUtils: ImageViewUtils @Mock lateinit var mockImageWireframeHelper: ImageWireframeHelper @@ -87,9 +86,6 @@ internal class ImageButtonMapperTest { @Mock lateinit var mockDisplayMetrics: DisplayMetrics - @Mock - lateinit var mockBase64Serializer: Base64Serializer - @Mock lateinit var mockCallback: AsyncJobStatusCallback @@ -105,6 +101,15 @@ internal class ImageButtonMapperTest { @Mock lateinit var mockConstantState: ConstantState + @Mock + lateinit var stubClipping: MobileSegment.WireframeClip + + @Mock + lateinit var stubParentRect: Rect + + @Mock + lateinit var stubContentRect: Rect + @Mock lateinit var mockContext: Context @@ -112,19 +117,19 @@ internal class ImageButtonMapperTest { private val fakeMimeType = Forge().aString() - lateinit var expectedWireframe: MobileSegment.Wireframe.ImageWireframe + private lateinit var expectedWireframe: MobileSegment.Wireframe.ImageWireframe @BeforeEach fun setup(forge: Forge) { - whenever(mockImageButton.background).thenReturn(null) + whenever(mockImageView.background).thenReturn(null) whenever(mockUniqueIdentifierGenerator.resolveChildUniqueIdentifier(any(), any())) .thenReturn(fakeId) whenever(mockConstantState.newDrawable(any())).thenReturn(mockDrawable) whenever(mockDrawable.constantState).thenReturn(mockConstantState) - whenever(mockImageButton.drawable).thenReturn(mockDrawable) - whenever(mockImageButton.drawable.current).thenReturn(mockDrawable) + whenever(mockImageView.drawable).thenReturn(mockDrawable) + whenever(mockImageView.drawable.current).thenReturn(mockDrawable) whenever(mockDrawable.intrinsicWidth).thenReturn(forge.aPositiveInt()) whenever(mockDrawable.intrinsicHeight).thenReturn(forge.aPositiveInt()) @@ -135,20 +140,29 @@ internal class ImageButtonMapperTest { whenever(mockMappingContext.systemInformation).thenReturn(mockSystemInformation) whenever(mockResources.displayMetrics).thenReturn(mockDisplayMetrics) - whenever(mockImageButton.resources).thenReturn(mockResources) + whenever(mockImageView.resources).thenReturn(mockResources) + mockDisplayMetrics.density = 1f whenever(mockContext.applicationContext).thenReturn(mockContext) - whenever(mockImageButton.context).thenReturn(mockContext) + whenever(mockImageView.context).thenReturn(mockContext) whenever(mockBackground.current).thenReturn(mockBackground) + whenever(stubImageViewUtils.resolveParentRectAbsPosition(any())).thenReturn(stubParentRect) + whenever(stubImageViewUtils.resolveContentRectWithScaling(any(), any())).thenReturn(stubContentRect) + whenever(stubImageViewUtils.calculateClipping(any(), any(), any())).thenReturn(stubClipping) + stubContentRect.left = forge.aPositiveInt() + stubContentRect.top = forge.aPositiveInt() + whenever(stubContentRect.width()).thenReturn(forge.aPositiveInt()) + whenever(stubContentRect.height()).thenReturn(forge.aPositiveInt()) + whenever(mockViewUtils.resolveViewGlobalBounds(any(), any())).thenReturn(mockGlobalBounds) expectedWireframe = MobileSegment.Wireframe.ImageWireframe( id = fakeId, x = mockGlobalBounds.x, y = mockGlobalBounds.y, - width = mockImageButton.width.toLong(), - height = mockImageButton.height.toLong(), + width = mockImageView.width.toLong(), + height = mockImageView.height.toLong(), shapeStyle = null, border = null, base64 = "", @@ -156,39 +170,21 @@ internal class ImageButtonMapperTest { isEmpty = true ) - whenever(mockBase64Serializer.getDrawableScaledDimensions(any(), any(), any())) - .thenReturn(DrawableDimensions(0, 0)) - - testedMapper = ImageButtonMapper( - base64Serializer = mockBase64Serializer, + testedMapper = ImageViewMapper( imageWireframeHelper = mockImageWireframeHelper, - uniqueIdentifierGenerator = mockUniqueIdentifierGenerator + uniqueIdentifierGenerator = mockUniqueIdentifierGenerator, + imageViewUtils = stubImageViewUtils ) } @Test fun `M return foreground wireframe W map() { no background }`() { // Given - whenever(mockImageButton.background).thenReturn(null) - val fakeViewDrawable = mockDrawable.constantState?.newDrawable(mockResources) - whenever( - mockImageWireframeHelper.createImageWireframe( - eq(mockImageButton), - any(), - any(), - any(), - any(), - any(), - eq(fakeViewDrawable), - isNull(), - isNull(), - eq(ImageWireframeHelper.DRAWABLE_CHILD_NAME), - isA() - ) - ).thenReturn(expectedWireframe) + whenever(mockImageView.background).thenReturn(null) + mockCreateImageWireframe(null, expectedWireframe) // When - val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + val wireframes = testedMapper.map(mockImageView, mockMappingContext) // Then assertThat(wireframes.size).isEqualTo(1) @@ -204,8 +200,8 @@ internal class ImageButtonMapperTest { id = id, x = mockGlobalBounds.x, y = mockGlobalBounds.y, - width = mockImageButton.width.toLong(), - height = mockImageButton.height.toLong(), + width = mockImageView.width.toLong(), + height = mockImageView.height.toLong(), shapeStyle = null, border = null, base64 = "", @@ -213,14 +209,11 @@ internal class ImageButtonMapperTest { isEmpty = true ) - whenever(mockImageButton.background).thenReturn(mockBackground) - mockCreateImageWireframe( - expectedBackgroundWireframe, - expectedWireframe - ) + whenever(mockImageView.background).thenReturn(mockBackground) + mockCreateImageWireframe(expectedBackgroundWireframe, expectedWireframe) // When - val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + val wireframes = testedMapper.map(mockImageView, mockMappingContext) // Then assertThat(wireframes.size).isEqualTo(2) @@ -231,44 +224,49 @@ internal class ImageButtonMapperTest { @Test fun `M call async callback W map() { }`() { // Given - whenever(mockImageButton.background).thenReturn(mockBackground) - - val argumentCaptor = argumentCaptor() whenever( mockImageWireframeHelper.createImageWireframe( - any(), - any(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - any() + view = any(), + currentWireframeIndex = any(), + x = any(), + y = any(), + width = any(), + height = any(), + usePIIPlaceholder = any(), + drawable = anyOrNull(), + shapeStyle = anyOrNull(), + border = anyOrNull(), + clipping = anyOrNull(), + prefix = anyOrNull(), + imageWireframeHelperCallback = anyOrNull() ) ).thenReturn(expectedWireframe) // When - val wireframes = testedMapper.map(mockImageButton, mockMappingContext, mockCallback) + val wireframes = testedMapper.map(mockImageView, mockMappingContext, mockCallback) // Then assertThat(wireframes.size).isEqualTo(2) assertThat(wireframes[0]).isEqualTo(expectedWireframe) - verify(mockImageWireframeHelper, times(2)).createImageWireframe( - any(), - any(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - argumentCaptor.capture() - ) + + val argumentCaptor = argumentCaptor() + verify(mockImageWireframeHelper, times(2)) + .createImageWireframe( + view = any(), + currentWireframeIndex = any(), + x = any(), + y = any(), + width = any(), + height = any(), + usePIIPlaceholder = any(), + drawable = anyOrNull(), + shapeStyle = anyOrNull(), + border = anyOrNull(), + clipping = anyOrNull(), + imageWireframeHelperCallback = argumentCaptor.capture(), + prefix = anyOrNull() + ) + argumentCaptor.allValues.forEach { it.onStart() it.onFinished() @@ -287,15 +285,15 @@ internal class ImageButtonMapperTest { id = id, x = mockGlobalBounds.x, y = mockGlobalBounds.y, - width = mockImageButton.width.toLong(), - height = mockImageButton.height.toLong(), + width = mockImageView.width.toLong(), + height = mockImageView.height.toLong(), shapeStyle = null, border = null, base64 = "", mimeType = fakeMimeType, isEmpty = true ) - whenever(mockImageButton.background).thenReturn(mockBackground) + whenever(mockImageView.background).thenReturn(mockBackground) mockCreateImageWireframe( expectedBackgroundWireframe, @@ -303,22 +301,24 @@ internal class ImageButtonMapperTest { ) // When - testedMapper.map(mockImageButton, mockMappingContext) + testedMapper.map(mockImageView, mockMappingContext) // Then val captor = argumentCaptor() verify(mockImageWireframeHelper, times(2)).createImageWireframe( - any(), - captor.capture(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull() + view = any(), + currentWireframeIndex = captor.capture(), + x = any(), + y = any(), + width = any(), + height = any(), + usePIIPlaceholder = any(), + drawable = anyOrNull(), + shapeStyle = anyOrNull(), + border = anyOrNull(), + clipping = anyOrNull(), + prefix = anyOrNull(), + imageWireframeHelperCallback = anyOrNull() ) val allValues = captor.allValues assertThat(allValues[0]).isEqualTo(0) @@ -328,7 +328,7 @@ internal class ImageButtonMapperTest { @Test fun `M set index to 0 W map() { no background wireframe }`() { // Given - whenever(mockImageButton.background).thenReturn(mockBackground) + whenever(mockImageView.background).thenReturn(mockBackground) mockCreateImageWireframe( null, @@ -336,22 +336,24 @@ internal class ImageButtonMapperTest { ) // When - testedMapper.map(mockImageButton, mockMappingContext) + testedMapper.map(mockImageView, mockMappingContext) // Then val captor = argumentCaptor() verify(mockImageWireframeHelper, times(2)).createImageWireframe( - any(), - captor.capture(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull() + view = any(), + currentWireframeIndex = captor.capture(), + x = any(), + y = any(), + width = any(), + height = any(), + usePIIPlaceholder = any(), + drawable = anyOrNull(), + shapeStyle = anyOrNull(), + border = anyOrNull(), + clipping = anyOrNull(), + prefix = anyOrNull(), + imageWireframeHelperCallback = anyOrNull() ) val allValues = captor.allValues assertThat(allValues[0]).isEqualTo(0) @@ -363,14 +365,14 @@ internal class ImageButtonMapperTest { @LongForgery id: Long ) { // Given - whenever(mockImageButton.background).thenReturn(mockBackground) + whenever(mockImageView.background).thenReturn(mockBackground) val expectedBackgroundWireframe = MobileSegment.Wireframe.ImageWireframe( id = id, x = mockGlobalBounds.x, y = mockGlobalBounds.y, - width = mockImageButton.width.toLong(), - height = mockImageButton.height.toLong(), + width = mockImageView.width.toLong(), + height = mockImageView.height.toLong(), shapeStyle = null, border = null, base64 = "", @@ -384,7 +386,7 @@ internal class ImageButtonMapperTest { ) // When - val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + val wireframes = testedMapper.map(mockImageView, mockMappingContext) // Then assertThat(wireframes[0]::class.java).isEqualTo(MobileSegment.Wireframe.ImageWireframe::class.java) @@ -395,10 +397,10 @@ internal class ImageButtonMapperTest { @Mock mockColorDrawable: ColorDrawable ) { // Given - whenever(mockImageButton.background).thenReturn(mockColorDrawable) + whenever(mockImageView.background).thenReturn(mockColorDrawable) // When - val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + val wireframes = testedMapper.map(mockImageView, mockMappingContext) // Then assertThat(wireframes[0]::class.java).isEqualTo(MobileSegment.Wireframe.ShapeWireframe::class.java) @@ -409,7 +411,7 @@ internal class ImageButtonMapperTest { @Mock mockColorDrawable: ColorDrawable ) { // Given - whenever(mockImageButton.background).thenReturn(mockColorDrawable) + whenever(mockImageView.background).thenReturn(mockColorDrawable) whenever(mockUniqueIdentifierGenerator.resolveChildUniqueIdentifier(any(), any())) .thenReturn(null) @@ -420,7 +422,7 @@ internal class ImageButtonMapperTest { ) // When - val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + val wireframes = testedMapper.map(mockImageView, mockMappingContext) // Then assertThat(wireframes.size).isEqualTo(1) @@ -432,17 +434,19 @@ internal class ImageButtonMapperTest { ) { whenever( mockImageWireframeHelper.createImageWireframe( - any(), - any(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull() + view = any(), + currentWireframeIndex = any(), + x = any(), + y = any(), + width = any(), + height = any(), + usePIIPlaceholder = any(), + drawable = anyOrNull(), + shapeStyle = anyOrNull(), + border = anyOrNull(), + clipping = anyOrNull(), + prefix = anyOrNull(), + imageWireframeHelperCallback = anyOrNull() ) ) .thenReturn(expectedFirstWireframe) 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 a12d723ea5..b99b8ee412 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 @@ -9,14 +9,15 @@ package com.datadog.android.sessionreplay.internal.utils import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.drawable.Drawable +import android.os.Handler import android.util.DisplayMetrics -import android.widget.ImageView +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer +import com.datadog.android.sessionreplay.internal.recorder.base64.Base64SerializerCallback import com.datadog.android.sessionreplay.internal.recorder.base64.BitmapPool -import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.internal.recorder.wrappers.BitmapWrapper import com.datadog.android.sessionreplay.internal.recorder.wrappers.CanvasWrapper -import fr.xgouchet.elmyr.annotation.FloatForgery import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -30,10 +31,14 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness +import java.util.concurrent.ExecutorService +import java.util.concurrent.Future @Extensions( ExtendWith(MockitoExtension::class), @@ -68,26 +73,57 @@ internal class DrawableUtilsTest { @Mock private lateinit var mockConfig: Bitmap.Config + @Mock + private lateinit var mockExecutorService: ExecutorService + + @Mock + private lateinit var mockBase64SerializerCallback: Base64SerializerCallback + + @Mock + private lateinit var mockBitmapCreationCallback: Base64Serializer.BitmapCreationCallback + + @Mock + private lateinit var mockMainThreadHandler: Handler + + @Mock + private lateinit var mockLogger: InternalLogger + @BeforeEach fun setup() { whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), any())) .thenReturn(mockBitmap) - whenever(mockCanvasWrapper.createCanvas(mockBitmap)) + whenever(mockCanvasWrapper.createCanvas(any())) .thenReturn(mockCanvas) whenever(mockBitmap.config).thenReturn(mockConfig) whenever(mockBitmapPool.getBitmapByProperties(any(), any(), any())).thenReturn(null) + doAnswer { invocation -> + val work = invocation.getArgument(0) as Runnable + work.run() + null + }.whenever(mockMainThreadHandler).post( + any() + ) + + whenever(mockExecutorService.execute(any())).then { + (it.arguments[0] as Runnable).run() + mock>() + } + testedDrawableUtils = DrawableUtils( bitmapWrapper = mockBitmapWrapper, canvasWrapper = mockCanvasWrapper, - bitmapPool = mockBitmapPool + bitmapPool = mockBitmapPool, + threadPoolExecutor = mockExecutorService, + mainThreadHandler = mockMainThreadHandler, + logger = mockLogger ) } // region createBitmap @Test - fun `M set width to drawable intrinsic W createBitmapFromDrawableOfApproxSize() { no resizing }`() { + fun `M set width to drawable intrinsic W createBitmapOfApproxSizeFromDrawable() { no resizing }`() { // Given val requestedSize = 1000 val edge = 10 @@ -99,12 +135,14 @@ internal class DrawableUtilsTest { // When testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockDrawable, - mockDrawable.intrinsicWidth, - mockDrawable.intrinsicHeight, - mockDisplayMetrics, - requestedSize, - mockConfig + drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, + displayMetrics = mockDisplayMetrics, + requestedSizeInBytes = requestedSize, + config = mockConfig, + base64SerializerCallback = mockBase64SerializerCallback, + bitmapCreationCallback = mockBitmapCreationCallback ) // Then @@ -122,7 +160,7 @@ internal class DrawableUtilsTest { } @Test - fun `M set height higher W createBitmapFromDrawableOfApproxSize() { when resizing }`( + fun `M set height higher W createBitmapOfApproxSizeFromDrawable() { when resizing }`( @IntForgery(min = 0, max = 500) viewWidth: Int, @IntForgery(min = 501, max = 1000) viewHeight: Int ) { @@ -135,10 +173,12 @@ internal class DrawableUtilsTest { // When testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockDrawable, - mockDrawable.intrinsicWidth, - mockDrawable.intrinsicHeight, - mockDisplayMetrics + drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, + displayMetrics = mockDisplayMetrics, + base64SerializerCallback = mockBase64SerializerCallback, + bitmapCreationCallback = mockBitmapCreationCallback ) // Then @@ -167,12 +207,14 @@ internal class DrawableUtilsTest { val displayMetricsCaptor = argumentCaptor() // When - val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockDrawable, - mockDrawable.intrinsicWidth, - mockDrawable.intrinsicHeight, - mockDisplayMetrics, - config = mockConfig + testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, + displayMetrics = mockDisplayMetrics, + config = mockConfig, + base64SerializerCallback = mockBase64SerializerCallback, + bitmapCreationCallback = mockBitmapCreationCallback ) // Then @@ -188,57 +230,12 @@ internal class DrawableUtilsTest { assertThat(width).isGreaterThanOrEqualTo(height) assertThat(displayMetricsCaptor.firstValue).isEqualTo(mockDisplayMetrics) - - assertThat(result).isEqualTo(mockBitmap) - } - - @Test - fun `M return null W createBitmapFromDrawableOfApproxSize() { failed to create bmp }`() { - // Given - val edge = 200 - whenever(mockDrawable.intrinsicWidth).thenReturn(edge) - whenever(mockDrawable.intrinsicHeight).thenReturn(edge) - whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), any())) - .thenReturn(null) - - // When - val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockDrawable, - mockDrawable.intrinsicWidth, - mockDrawable.intrinsicHeight, - mockDisplayMetrics, - config = mockConfig - ) - - // Then - assertThat(result).isNull() - } - - @Test - fun `M return null W createBitmapFromDrawableOfApproxSize() { failed to create canvas }`() { - // Given - val edge = 200 - whenever(mockDrawable.intrinsicWidth).thenReturn(edge) - whenever(mockDrawable.intrinsicHeight).thenReturn(edge) - whenever(mockCanvasWrapper.createCanvas(any())) - .thenReturn(null) - - // When - val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockDrawable, - mockDrawable.intrinsicWidth, - mockDrawable.intrinsicHeight, - mockDisplayMetrics, - config = mockConfig - ) - - // Then - assertThat(result).isNull() } // endregion - fun `M use bitmap from pool W createBitmapFromDrawable() { exists in pool }`( + @Test + fun `M use bitmap from pool W createBitmapOfApproxSizeFromDrawable() { exists in pool }`( @IntForgery(min = 1, max = 1000) viewWidth: Int, @IntForgery(min = 1, max = 1000) viewHeight: Int ) { @@ -250,215 +247,69 @@ internal class DrawableUtilsTest { .thenReturn(mockBitmapFromPool) // When - val actualBitmap = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockDrawable, - mockDrawable.intrinsicWidth, - mockDrawable.intrinsicHeight, - mockDisplayMetrics, - config = mockConfig - ) - - // Then - assertThat(actualBitmap).isEqualTo(mockBitmapFromPool) - } - - @Test - fun `M return drawable width and height W getDrawableScaledDimensions() { no scaleType }`( - @Mock mockImageView: ImageView, - @Mock mockDrawable: Drawable, - @IntForgery(min = 1, max = 1000) viewWidth: Int, - @IntForgery(min = 1, max = 1000) viewHeight: Int, - @IntForgery(min = 1, max = 1000) drawableWidth: Int, - @IntForgery(min = 1, max = 1000) drawableHeight: Int, - @FloatForgery(0.1f, 3f) fakeDensity: Float - ) { - // Given - whenever(mockImageView.scaleType).thenReturn(null) - whenever(mockImageView.width).thenReturn(viewWidth) - whenever(mockImageView.height).thenReturn(viewHeight) - whenever(mockDrawable.intrinsicWidth).thenReturn(drawableWidth) - whenever(mockDrawable.intrinsicHeight).thenReturn(drawableHeight) - - val expectedWidth = drawableWidth.densityNormalized(fakeDensity).toLong() - val expectedHeight = drawableHeight.densityNormalized(fakeDensity).toLong() - - // When - val result = testedDrawableUtils.getDrawableScaledDimensions( - mockImageView, - mockDrawable, - fakeDensity - ) - - // Then - assertThat(result.width).isEqualTo(expectedWidth) - assertThat(result.height).isEqualTo(expectedHeight) - } - - @Test - fun `M return drawable width and height W getDrawableScaledDimensions() { unsupported scaleType }`( - @Mock mockImageView: ImageView, - @Mock mockDrawable: Drawable, - @IntForgery(min = 1, max = 1000) viewWidth: Int, - @IntForgery(min = 1, max = 1000) viewHeight: Int, - @IntForgery(min = 1, max = 1000) drawableWidth: Int, - @IntForgery(min = 1, max = 1000) drawableHeight: Int, - @FloatForgery(0.1f, 3f) fakeDensity: Float - ) { - // Given - whenever(mockImageView.scaleType).thenReturn(ImageView.ScaleType.FIT_START) - whenever(mockImageView.width).thenReturn(viewWidth) - whenever(mockImageView.height).thenReturn(viewHeight) - whenever(mockDrawable.intrinsicWidth).thenReturn(drawableWidth) - whenever(mockDrawable.intrinsicHeight).thenReturn(drawableHeight) - - val expectedWidth = drawableWidth.densityNormalized(fakeDensity).toLong() - val expectedHeight = drawableHeight.densityNormalized(fakeDensity).toLong() - - // When - val result = testedDrawableUtils.getDrawableScaledDimensions( - mockImageView, - mockDrawable, - fakeDensity - ) - - // Then - assertThat(result.width).isEqualTo(expectedWidth) - assertThat(result.height).isEqualTo(expectedHeight) - } - - @Test - fun `M return view width and height W getDrawableScaledDimensions() { FitXY }`( - @Mock mockImageView: ImageView, - @Mock mockDrawable: Drawable, - @IntForgery(min = 1, max = 1000) viewWidth: Int, - @IntForgery(min = 1, max = 1000) viewHeight: Int, - @IntForgery(min = 1, max = 1000) drawableWidth: Int, - @IntForgery(min = 1, max = 1000) drawableHeight: Int, - @FloatForgery(0.1f, 3f) density: Float - ) { - // Given - whenever(mockImageView.scaleType).thenReturn(ImageView.ScaleType.FIT_XY) - whenever(mockImageView.width).thenReturn(viewWidth) - whenever(mockImageView.height).thenReturn(viewHeight) - whenever(mockDrawable.intrinsicWidth).thenReturn(drawableWidth) - whenever(mockDrawable.intrinsicHeight).thenReturn(drawableHeight) - - val expectedWidth = viewWidth.densityNormalized(density).toLong() - val expectedHeight = viewHeight.densityNormalized(density).toLong() - - // When - val result = testedDrawableUtils.getDrawableScaledDimensions( - mockImageView, - mockDrawable, - density - ) - - // Then - assertThat(result.width).isEqualTo(expectedWidth) - assertThat(result.height).isEqualTo(expectedHeight) - } - - @Test - fun `M return correct dimensions W getDrawableScaledDimensions() { CenterCrop, width gt height }`( - @Mock mockImageView: ImageView, - @Mock mockDrawable: Drawable, - @IntForgery(min = 501, max = 1000) viewWidth: Int, - @IntForgery(min = 1, max = 500) viewHeight: Int, - @IntForgery(min = 1, max = 500) drawableWidth: Int, - @IntForgery(min = 501, max = 1000) drawableHeight: Int, - @FloatForgery(0.1f, 3f) fakeDensity: Float - ) { - // Given - whenever(mockImageView.scaleType).thenReturn(ImageView.ScaleType.CENTER_CROP) - whenever(mockImageView.width).thenReturn(viewWidth) - whenever(mockImageView.height).thenReturn(viewHeight) - whenever(mockDrawable.intrinsicWidth).thenReturn(drawableWidth) - whenever(mockDrawable.intrinsicHeight).thenReturn(drawableHeight) - - val viewHeightNormalized = viewHeight.densityNormalized(fakeDensity).toLong() - val drawableWidthNormalized = drawableWidth.densityNormalized(fakeDensity).toLong() - val drawableHeightNormalized = drawableHeight.densityNormalized(fakeDensity).toLong() - - val expectedWidth = (viewHeightNormalized * drawableWidthNormalized) / drawableHeightNormalized - val expectedHeight = viewHeightNormalized - - // When - val result = testedDrawableUtils.getDrawableScaledDimensions( - mockImageView, - mockDrawable, - fakeDensity + testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, + displayMetrics = mockDisplayMetrics, + config = mockConfig, + base64SerializerCallback = mockBase64SerializerCallback, + bitmapCreationCallback = mockBitmapCreationCallback ) // Then - assertThat(result.width).isEqualTo(expectedWidth) - assertThat(result.height).isEqualTo(expectedHeight) + verify(mockBitmapCreationCallback).onReady(mockBitmapFromPool) + verifyNoInteractions(mockBase64SerializerCallback) } @Test - fun `M return correct dimensions W getDrawableScaledDimensions() { CenterCrop, width lt height }`( - @Mock mockImageView: ImageView, - @Mock mockDrawable: Drawable, - @IntForgery(min = 1, max = 500) viewWidth: Int, - @IntForgery(min = 501, max = 1000) viewHeight: Int, - @IntForgery(min = 501, max = 1000) drawableWidth: Int, - @IntForgery(min = 1, max = 500) drawableHeight: Int, - @FloatForgery(0.1f, 3f) fakeDensity: Float - ) { + fun `M call onReady W createBitmapOfApproxSizeFromDrawable { failed to create bitmap }`() { // Given - whenever(mockImageView.scaleType).thenReturn(ImageView.ScaleType.CENTER_CROP) - whenever(mockImageView.width).thenReturn(viewWidth) - whenever(mockImageView.height).thenReturn(viewHeight) - whenever(mockDrawable.intrinsicWidth).thenReturn(drawableWidth) - whenever(mockDrawable.intrinsicHeight).thenReturn(drawableHeight) - - val viewWidthNormalized = viewWidth.densityNormalized(fakeDensity).toLong() - val drawableWidthNormalized = drawableWidth.densityNormalized(fakeDensity).toLong() - val drawableHeightNormalized = drawableHeight.densityNormalized(fakeDensity).toLong() - - val expectedHeight = (viewWidthNormalized * drawableHeightNormalized) / drawableWidthNormalized - val expectedWidth = viewWidthNormalized + whenever(mockDrawable.intrinsicWidth).thenReturn(1) + whenever(mockDrawable.intrinsicHeight).thenReturn(1) + whenever(mockBitmapPool.getBitmapByProperties(any(), any(), any())) + .thenReturn(null) + whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), any())) + .thenReturn(null) // When - val result = testedDrawableUtils.getDrawableScaledDimensions( - mockImageView, - mockDrawable, - fakeDensity + testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, + displayMetrics = mockDisplayMetrics, + config = mockConfig, + base64SerializerCallback = mockBase64SerializerCallback, + bitmapCreationCallback = mockBitmapCreationCallback ) // Then - assertThat(result.width).isEqualTo(expectedWidth) - assertThat(result.height).isEqualTo(expectedHeight) + verifyNoInteractions(mockBitmapCreationCallback) + verify(mockBase64SerializerCallback).onReady() } @Test - fun `M return correct dimensions W getDrawableScaledDimensions() { CenterCrop, width eq height }`( - @Mock mockImageView: ImageView, - @Mock mockDrawable: Drawable, - @IntForgery(min = 1, max = 1000) fakeDimension: Int, - @FloatForgery(0.1f, 3f) fakeDensity: Float - ) { + fun `M call onReady W createBitmapOfApproxSizeFromDrawable { failed to create canvas }`() { // Given - whenever(mockImageView.scaleType).thenReturn(ImageView.ScaleType.CENTER_CROP) - whenever(mockImageView.width).thenReturn(fakeDimension) - whenever(mockImageView.height).thenReturn(fakeDimension) - whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDimension) - whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDimension) - - val fakeDimensionNormalized = fakeDimension.densityNormalized(fakeDensity).toLong() - - val expectedWidth = fakeDimensionNormalized - val expectedHeight = fakeDimensionNormalized + whenever(mockDrawable.intrinsicWidth).thenReturn(1) + whenever(mockDrawable.intrinsicHeight).thenReturn(1) + whenever(mockCanvasWrapper.createCanvas(any())) + .thenReturn(null) // When - val result = testedDrawableUtils.getDrawableScaledDimensions( - mockImageView, - mockDrawable, - fakeDensity + testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, + displayMetrics = mockDisplayMetrics, + config = mockConfig, + base64SerializerCallback = mockBase64SerializerCallback, + bitmapCreationCallback = mockBitmapCreationCallback ) // Then - assertThat(result.width).isEqualTo(expectedWidth) - assertThat(result.height).isEqualTo(expectedHeight) + verifyNoInteractions(mockBitmapCreationCallback) + verify(mockBase64SerializerCallback).onReady() } @Test diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/ImageViewUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/ImageViewUtilsTest.kt new file mode 100644 index 0000000000..70350a616d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/ImageViewUtilsTest.kt @@ -0,0 +1,720 @@ +/* + * 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.Rect +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.utils.isCloseToOrGreaterThan +import com.datadog.android.utils.isCloseToOrLessThan +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +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.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class ImageViewUtilsTest { + private val testedImageViewUtils = ImageViewUtils + + // region calculateClipping + + @Test + fun `M return empty clip W calculateClipping() { no clipping required }`( + @Mock mockParentRect: Rect, + @Mock mockChildRect: Rect, + forge: Forge + ) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + + mockParentRect.left = fakeGlobalX + mockParentRect.top = fakeGlobalY + mockParentRect.right = fakeGlobalX + fakeWidth + mockParentRect.bottom = fakeGlobalY + fakeHeight + + mockChildRect.left = mockParentRect.left + mockChildRect.top = mockParentRect.top + mockChildRect.right = mockParentRect.right + mockChildRect.bottom = mockParentRect.bottom + + val expectedClipping = MobileSegment.WireframeClip(0, 0, 0, 0) + + // When + val result = testedImageViewUtils.calculateClipping( + parentRect = mockParentRect, + childRect = mockChildRect, + density = 1f + ) + + // Then + assertThat(result).isEqualTo(expectedClipping) + } + + @Test + fun `M return clip W calculateClipping() { overlaps left }`( + @Mock mockParentRect: Rect, + @Mock mockChildRect: Rect, + forge: Forge + ) { + // Given + val fakeOverlap = 100 + + mockParentRect.left = forge.aPositiveInt() + mockParentRect.top = forge.aPositiveInt() + mockParentRect.right = forge.aPositiveInt() + mockParentRect.bottom = forge.aPositiveInt() + + mockChildRect.left = mockParentRect.left - fakeOverlap + mockChildRect.top = mockParentRect.top + mockChildRect.right = mockParentRect.right + mockChildRect.bottom = mockParentRect.bottom + + val expectedClipping = MobileSegment.WireframeClip(0, 0, fakeOverlap.toLong(), 0) + + // When + val result = testedImageViewUtils.calculateClipping( + parentRect = mockParentRect, + childRect = mockChildRect, + density = 1f + ) + + // Then + assertThat(result).isEqualTo(expectedClipping) + } + + @Test + fun `M return clip W calculateClipping() { overlaps right }`( + @Mock mockParentRect: Rect, + @Mock mockChildRect: Rect, + forge: Forge + ) { + // Given + val fakeOverlap = 100 + + mockParentRect.left = forge.aPositiveInt() + mockParentRect.top = forge.aPositiveInt() + mockParentRect.right = forge.aPositiveInt() + mockParentRect.bottom = forge.aPositiveInt() + + mockChildRect.left = mockParentRect.left + mockChildRect.top = mockParentRect.top + mockChildRect.right = mockParentRect.right + fakeOverlap + mockChildRect.bottom = mockParentRect.bottom + + val expectedClipping = MobileSegment.WireframeClip(0, 0, 0, fakeOverlap.toLong()) + + // When + val result = testedImageViewUtils.calculateClipping( + parentRect = mockParentRect, + childRect = mockChildRect, + density = 1f + ) + + // Then + assertThat(result).isEqualTo(expectedClipping) + } + + @Test + fun `M return clip W calculateClipping() { overlaps top }`( + @Mock mockParentRect: Rect, + @Mock mockChildRect: Rect, + forge: Forge + ) { + // Given + val fakeOverlap = 100 + + mockParentRect.left = forge.aPositiveInt() + mockParentRect.top = forge.aPositiveInt() + mockParentRect.right = forge.aPositiveInt() + mockParentRect.bottom = forge.aPositiveInt() + + mockChildRect.left = mockParentRect.left + mockChildRect.top = mockParentRect.top - fakeOverlap + mockChildRect.right = mockParentRect.right + mockChildRect.bottom = mockParentRect.bottom + + val expectedClipping = MobileSegment.WireframeClip(fakeOverlap.toLong(), 0, 0, 0) + + // When + val result = testedImageViewUtils.calculateClipping( + parentRect = mockParentRect, + childRect = mockChildRect, + density = 1f + ) + + // Then + assertThat(result).isEqualTo(expectedClipping) + } + + @Test + fun `M return clip W calculateClipping() { overlaps bottom }`( + @Mock mockParentRect: Rect, + @Mock mockChildRect: Rect, + forge: Forge + ) { + // Given + val fakeOverlap = 100 + + mockParentRect.left = forge.aPositiveInt() + mockParentRect.top = forge.aPositiveInt() + mockParentRect.right = forge.aPositiveInt() + mockParentRect.bottom = forge.aPositiveInt() + + mockChildRect.left = mockParentRect.left + mockChildRect.top = mockParentRect.top + mockChildRect.right = mockParentRect.right + mockChildRect.bottom = mockParentRect.bottom + fakeOverlap + + val expectedClipping = MobileSegment.WireframeClip(0, fakeOverlap.toLong(), 0, 0) + + // When + val result = testedImageViewUtils.calculateClipping( + parentRect = mockParentRect, + childRect = mockChildRect, + density = 1f + ) + + // Then + assertThat(result).isEqualTo(expectedClipping) + } + + // endregion + + @Test + fun `M return abs position on screen W resolveParentRectAbsPosition()`(forge: Forge) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val mockView: View = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + whenever(it.width).thenReturn(fakeWidth) + whenever(it.height).thenReturn(fakeHeight) + } + + // When + val result = testedImageViewUtils.resolveParentRectAbsPosition(mockView) + + // Then + assertThat(result.left).isEqualTo(fakeGlobalX) + assertThat(result.top).isEqualTo(fakeGlobalY) + assertThat(result.right).isEqualTo(fakeGlobalX + fakeWidth) + assertThat(result.bottom).isEqualTo(fakeGlobalY + fakeHeight) + } + + @Test + fun `M return content rect W resolveContentRectWithScaling() { FIT_START }`( + @Mock mockDrawable: Drawable, + forge: Forge + ) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeDrawableWidth = forge.aPositiveInt() + val fakeDrawableHeight = forge.aPositiveInt() + val fakeScaleType = ImageView.ScaleType.FIT_START + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + + val mockImageView: ImageView = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + whenever(it.width).thenReturn(fakeWidth) + whenever(it.height).thenReturn(fakeHeight) + whenever(it.scaleType).thenReturn(fakeScaleType) + } + + val parentRect = Rect( + fakeGlobalX, + fakeGlobalY, + fakeGlobalX + fakeWidth, + fakeGlobalY + fakeHeight + ) + + // When + val result = testedImageViewUtils.resolveContentRectWithScaling( + view = mockImageView, + drawable = mockDrawable + ) + + // Then + assertThat(result.left).isEqualTo(parentRect.left) + assertThat(result.top).isEqualTo(parentRect.top) + assertThat(result.width().isCloseToOrLessThan(fakeWidth)).isTrue + assertThat(result.height().isCloseToOrLessThan(fakeHeight)).isTrue + } + + @Test + fun `M return content rect W resolveContentRectWithScaling() { FIT_END }`( + @Mock mockDrawable: Drawable, + forge: Forge + ) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeDrawableWidth = forge.aPositiveInt() + val fakeDrawableHeight = forge.aPositiveInt() + val fakeScaleType = ImageView.ScaleType.FIT_END + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + + val mockImageView: ImageView = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + whenever(it.width).thenReturn(fakeWidth) + whenever(it.height).thenReturn(fakeHeight) + whenever(it.scaleType).thenReturn(fakeScaleType) + } + + val parentRect = Rect( + fakeGlobalX, + fakeGlobalY, + fakeGlobalX + fakeWidth, + fakeGlobalY + fakeHeight + ) + + // When + val result = testedImageViewUtils.resolveContentRectWithScaling( + view = mockImageView, + drawable = mockDrawable + ) + + // Then + assertThat(result.right).isEqualTo(parentRect.right) + assertThat(result.bottom).isEqualTo(parentRect.bottom) + assertThat(result.width().isCloseToOrLessThan(fakeWidth)).isTrue + assertThat(result.height().isCloseToOrLessThan(fakeHeight)).isTrue + } + + @Test + fun `M return content rect W resolveContentRectWithScaling() { FIT_CENTER gt parent }`( + @Mock mockDrawable: Drawable, + forge: Forge + ) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeDrawableWidth = forge.anInt(min = fakeWidth + 1) + val fakeDrawableHeight = forge.anInt(min = fakeHeight + 1) + val fakeScaleType = ImageView.ScaleType.FIT_CENTER + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + + val mockImageView: ImageView = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + whenever(it.width).thenReturn(fakeWidth) + whenever(it.height).thenReturn(fakeHeight) + whenever(it.scaleType).thenReturn(fakeScaleType) + } + + val parentRect = Rect( + fakeGlobalX, + fakeGlobalY, + fakeGlobalX + fakeWidth, + fakeGlobalY + fakeHeight + ) + + // When + val result = testedImageViewUtils.resolveContentRectWithScaling( + view = mockImageView, + drawable = mockDrawable + ) + + // Then + assertThat(result.left).isEqualTo(parentRect.centerX() - (result.width() / 2)) + assertThat(result.top).isEqualTo(parentRect.centerY() - (result.height() / 2)) + assertThat(result.width().isCloseToOrLessThan(fakeWidth)).isTrue + assertThat(result.height().isCloseToOrLessThan(fakeHeight)).isTrue + } + + @Test + fun `M return content rect W resolveContentRectWithScaling() { FIT_CENTER lt parent }`( + @Mock mockDrawable: Drawable, + forge: Forge + ) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeDrawableWidth = forge.anInt(min = 1, max = fakeWidth - 1) + val fakeDrawableHeight = forge.anInt(min = 1, max = fakeHeight - 1) + val fakeScaleType = ImageView.ScaleType.FIT_CENTER + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + + val mockImageView: ImageView = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + whenever(it.width).thenReturn(fakeWidth) + whenever(it.height).thenReturn(fakeHeight) + whenever(it.scaleType).thenReturn(fakeScaleType) + } + + val parentRect = Rect( + fakeGlobalX, + fakeGlobalY, + fakeGlobalX + fakeWidth, + fakeGlobalY + fakeHeight + ) + + // When + val result = testedImageViewUtils.resolveContentRectWithScaling( + view = mockImageView, + drawable = mockDrawable + ) + + // Then + assertThat(result.left).isEqualTo(parentRect.centerX() - (result.width() / 2)) + assertThat(result.top).isEqualTo(parentRect.centerY() - (result.height() / 2)) + assertThat(result.width().isCloseToOrLessThan(parentRect.width())).isTrue + assertThat(result.height().isCloseToOrLessThan(parentRect.height())).isTrue + + // must scale up + if (result.width() > result.height()) { + assertThat(result.width()).isGreaterThan(fakeDrawableWidth) + } else { + assertThat(result.height()).isGreaterThan(fakeDrawableHeight) + } + } + + @Test + fun `M return content rect W resolveContentRectWithScaling() { CENTER_INSIDE gt parent }`( + @Mock mockDrawable: Drawable, + forge: Forge + ) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeDrawableWidth = forge.anInt(min = fakeWidth + 1) + val fakeDrawableHeight = forge.anInt(min = fakeHeight + 1) + val fakeScaleType = ImageView.ScaleType.CENTER_INSIDE + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + + val mockImageView: ImageView = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + whenever(it.width).thenReturn(fakeWidth) + whenever(it.height).thenReturn(fakeHeight) + whenever(it.scaleType).thenReturn(fakeScaleType) + } + + val parentRect = Rect( + fakeGlobalX, + fakeGlobalY, + fakeGlobalX + fakeWidth, + fakeGlobalY + fakeHeight + ) + + // When + val result = testedImageViewUtils.resolveContentRectWithScaling( + view = mockImageView, + drawable = mockDrawable + ) + + // Then + assertThat(result.left).isEqualTo(parentRect.centerX() - (result.width() / 2)) + assertThat(result.top).isEqualTo(parentRect.centerY() - (result.height() / 2)) + assertThat(result.width().isCloseToOrLessThan(fakeWidth)).isTrue + assertThat(result.height().isCloseToOrLessThan(fakeHeight)).isTrue + } + + @Test + fun `M return content rect W resolveContentRectWithScaling() { CENTER_INSIDE lt parent }`( + @Mock mockDrawable: Drawable, + forge: Forge + ) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeDrawableWidth = forge.anInt(min = 1, max = fakeWidth - 1) + val fakeDrawableHeight = forge.anInt(min = 1, max = fakeHeight - 1) + val fakeScaleType = ImageView.ScaleType.CENTER_INSIDE + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + + val mockImageView: ImageView = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + whenever(it.width).thenReturn(fakeWidth) + whenever(it.height).thenReturn(fakeHeight) + whenever(it.scaleType).thenReturn(fakeScaleType) + } + + val parentRect = Rect( + fakeGlobalX, + fakeGlobalY, + fakeGlobalX + fakeWidth, + fakeGlobalY + fakeHeight + ) + + // When + val result = testedImageViewUtils.resolveContentRectWithScaling( + view = mockImageView, + drawable = mockDrawable + ) + + // Then + assertThat(result.left).isEqualTo(parentRect.centerX() - (result.width() / 2)) + assertThat(result.top).isEqualTo(parentRect.centerY() - (result.height() / 2)) + assertThat(result.width().isCloseToOrLessThan(fakeWidth)).isTrue + assertThat(result.height().isCloseToOrLessThan(fakeHeight)).isTrue + + // do not scale up + assertThat(result.width()).isEqualTo(fakeDrawableWidth) + assertThat(result.height()).isEqualTo(fakeDrawableHeight) + } + + @Test + fun `M return content rect W resolveContentRectWithScaling() { CENTER }`( + @Mock mockDrawable: Drawable, + forge: Forge + ) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeDrawableWidth = forge.aPositiveInt() + val fakeDrawableHeight = forge.aPositiveInt() + val fakeScaleType = ImageView.ScaleType.CENTER + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + + val mockImageView: ImageView = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + whenever(it.width).thenReturn(fakeWidth) + whenever(it.height).thenReturn(fakeHeight) + whenever(it.scaleType).thenReturn(fakeScaleType) + } + + val parentRect = Rect( + fakeGlobalX, + fakeGlobalY, + fakeGlobalX + fakeWidth, + fakeGlobalY + fakeHeight + ) + + // When + val result = testedImageViewUtils.resolveContentRectWithScaling( + view = mockImageView, + drawable = mockDrawable + ) + + // Then + assertThat(result.left).isEqualTo(parentRect.centerX() - (result.width() / 2)) + assertThat(result.top).isEqualTo(parentRect.centerY() - (result.height() / 2)) + + // do not scale + assertThat(result.width()).isEqualTo(fakeDrawableWidth) + assertThat(result.height()).isEqualTo(fakeDrawableHeight) + } + + @Test + fun `M return content rect W resolveContentRectWithScaling() { CENTER_CROP }`( + @Mock mockDrawable: Drawable, + forge: Forge + ) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeDrawableWidth = forge.aPositiveInt() + val fakeDrawableHeight = forge.aPositiveInt() + val fakeScaleType = ImageView.ScaleType.CENTER_CROP + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + + val mockImageView: ImageView = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + whenever(it.width).thenReturn(fakeWidth) + whenever(it.height).thenReturn(fakeHeight) + whenever(it.scaleType).thenReturn(fakeScaleType) + } + + val parentRect = Rect( + fakeGlobalX, + fakeGlobalY, + fakeGlobalX + fakeWidth, + fakeGlobalY + fakeHeight + ) + + // When + val result = testedImageViewUtils.resolveContentRectWithScaling( + view = mockImageView, + drawable = mockDrawable + ) + + // Then + assertThat(result.width().isCloseToOrGreaterThan(parentRect.width())).isTrue + assertThat(result.height().isCloseToOrGreaterThan(parentRect.height())).isTrue + } + + @Test + fun `M return content rect W resolveContentRectWithScaling() { FIT_XY }`( + @Mock mockDrawable: Drawable, + forge: Forge + ) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeDrawableWidth = forge.aPositiveInt() + val fakeDrawableHeight = forge.aPositiveInt() + val fakeScaleType = ImageView.ScaleType.FIT_XY + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + + val mockImageView: ImageView = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + whenever(it.width).thenReturn(fakeWidth) + whenever(it.height).thenReturn(fakeHeight) + whenever(it.scaleType).thenReturn(fakeScaleType) + } + + val parentRect = Rect( + fakeGlobalX, + fakeGlobalY, + fakeGlobalX + fakeWidth, + fakeGlobalY + fakeHeight + ) + + // When + val result = testedImageViewUtils.resolveContentRectWithScaling( + view = mockImageView, + drawable = mockDrawable + ) + + // Then + assertThat(result).isEqualTo(parentRect) + } + + @Test + fun `M return content rect W resolveContentRectWithScaling() { MATRIX }`( + @Mock mockDrawable: Drawable, + forge: Forge + ) { + // Given + val fakeGlobalX = forge.aPositiveInt() + val fakeGlobalY = forge.aPositiveInt() + val fakeWidth = forge.aPositiveInt() + val fakeHeight = forge.aPositiveInt() + val fakeDrawableWidth = forge.aPositiveInt() + val fakeDrawableHeight = forge.aPositiveInt() + val fakeScaleType = ImageView.ScaleType.MATRIX + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + + val mockImageView: ImageView = mock { + whenever(it.getLocationOnScreen(any())).thenAnswer { + val coords = it.arguments[0] as IntArray + coords[0] = fakeGlobalX + coords[1] = fakeGlobalY + null + } + whenever(it.width).thenReturn(fakeWidth) + whenever(it.height).thenReturn(fakeHeight) + whenever(it.scaleType).thenReturn(fakeScaleType) + } + + val parentRect = Rect( + fakeGlobalX, + fakeGlobalY, + fakeGlobalX + fakeWidth, + fakeGlobalY + fakeHeight + ) + + // When + val result = testedImageViewUtils.resolveContentRectWithScaling( + view = mockImageView, + drawable = mockDrawable + ) + + // Then + assertThat(result).isEqualTo(parentRect) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt index 800b385a1b..5c6749dc57 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt @@ -45,7 +45,11 @@ internal class WireframeExtTest { val fakeTestWireframe = fakeWireframe.testCopy(shapeStyle = fakeShapeStyle) // Then - assertThat(fakeTestWireframe.hasOpaqueBackground()).isTrue + if (fakeTestWireframe is MobileSegment.Wireframe.ImageWireframe) { + assertThat(fakeTestWireframe.hasOpaqueBackground()).isFalse + } else { + assertThat(fakeTestWireframe.hasOpaqueBackground()).isTrue + } } @ParameterizedTest @@ -102,7 +106,7 @@ internal class WireframeExtTest { } @Test - fun `M return true W hasOpaqueBackground { ImageWireframe, base64, noShapeStyle }`( + fun `M return false W hasOpaqueBackground { ImageWireframe, base64, noShapeStyle }`( @Forgery fakeWireframe: MobileSegment.Wireframe.ImageWireframe, @StringForgery fakeBase64: String ) { @@ -110,7 +114,7 @@ internal class WireframeExtTest { val fakeTestWireframe = fakeWireframe.copy(shapeStyle = null, base64 = fakeBase64) // Then - assertThat(fakeTestWireframe.hasOpaqueBackground()).isTrue + assertThat(fakeTestWireframe.hasOpaqueBackground()).isFalse } @Test diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/utils/InternalViewUtils.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/utils/InternalViewUtils.kt new file mode 100644 index 0000000000..db0572f7be --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/utils/InternalViewUtils.kt @@ -0,0 +1,24 @@ +/* + * 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.utils + +internal fun Int.isCloseToOrGreaterThan(compareTo: Int): Boolean { + return this > compareTo || isCloseTo(compareTo, this) +} +internal fun Int.isCloseToOrLessThan(compareTo: Int): Boolean { + return this < compareTo || isCloseTo(compareTo, this) +} + +// add tolerance to mitigate flakiness in tests with Long casting +internal fun isCloseTo(firstItem: Int, secondItem: Int, tolerance: Double = 0.01): Boolean { + require(tolerance in 0.0..1.0) { "Tolerance must be between 0 and 1" } + + val delta = firstItem * tolerance + val min: Int = (firstItem - delta).toInt() + val max: Int = (firstItem + delta).toInt() + return secondItem in (min..max) +} diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_allow_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_allow_payload.json index 1a481f9944..69db4997be 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_allow_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_allow_payload.json @@ -30,8 +30,14 @@ "isEmpty": false }, { - "width": 75, - "height": 80, + "width": 80, + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 + }, "type": "image", "mimeType": "image/webp", "isEmpty": false @@ -44,8 +50,14 @@ "isEmpty": false }, { - "width": 75, - "height": 80, + "width": 80, + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 + }, "type": "image", "mimeType": "image/webp", "isEmpty": false @@ -58,8 +70,14 @@ "isEmpty": false }, { - "width": 75, - "height": 80, + "width": 80, + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 + }, "type": "image", "mimeType": "image/webp", "isEmpty": false diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_mask_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_mask_payload.json index 1a481f9944..69db4997be 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_mask_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_mask_payload.json @@ -30,8 +30,14 @@ "isEmpty": false }, { - "width": 75, - "height": 80, + "width": 80, + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 + }, "type": "image", "mimeType": "image/webp", "isEmpty": false @@ -44,8 +50,14 @@ "isEmpty": false }, { - "width": 75, - "height": 80, + "width": 80, + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 + }, "type": "image", "mimeType": "image/webp", "isEmpty": false @@ -58,8 +70,14 @@ "isEmpty": false }, { - "width": 75, - "height": 80, + "width": 80, + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 + }, "type": "image", "mimeType": "image/webp", "isEmpty": false diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_mask_user_input_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_mask_user_input_payload.json index 1a481f9944..69db4997be 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_mask_user_input_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_image_buttons_mask_user_input_payload.json @@ -30,8 +30,14 @@ "isEmpty": false }, { - "width": 75, - "height": 80, + "width": 80, + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 + }, "type": "image", "mimeType": "image/webp", "isEmpty": false @@ -44,8 +50,14 @@ "isEmpty": false }, { - "width": 75, - "height": 80, + "width": 80, + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 + }, "type": "image", "mimeType": "image/webp", "isEmpty": false @@ -58,8 +70,14 @@ "isEmpty": false }, { - "width": 75, - "height": 80, + "width": 80, + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 + }, "type": "image", "mimeType": "image/webp", "isEmpty": false diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_allow_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_allow_payload.json index 6fa037ebd4..46a42a13b6 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_allow_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_allow_payload.json @@ -24,21 +24,29 @@ }, { "width": 80, - "height": 80, - "border": { - "color": "#000000ff", - "width": 1 + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 }, - "type": "shape" + "type": "image", + "mimeType": "image/webp", + "isEmpty": false }, { "width": 80, - "height": 80, - "border": { - "color": "#000000ff", - "width": 1 + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 }, - "type": "shape" + "type": "image", + "mimeType": "image/webp", + "isEmpty": false }, { "width": 411, diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_mask_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_mask_payload.json index 6fa037ebd4..46a42a13b6 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_mask_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_mask_payload.json @@ -24,21 +24,29 @@ }, { "width": 80, - "height": 80, - "border": { - "color": "#000000ff", - "width": 1 + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 }, - "type": "shape" + "type": "image", + "mimeType": "image/webp", + "isEmpty": false }, { "width": 80, - "height": 80, - "border": { - "color": "#000000ff", - "width": 1 + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 }, - "type": "shape" + "type": "image", + "mimeType": "image/webp", + "isEmpty": false }, { "width": 411, diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_mask_user_input_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_mask_user_input_payload.json index 6fa037ebd4..46a42a13b6 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_mask_user_input_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_images_mask_user_input_payload.json @@ -24,21 +24,29 @@ }, { "width": 80, - "height": 80, - "border": { - "color": "#000000ff", - "width": 1 + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 }, - "type": "shape" + "type": "image", + "mimeType": "image/webp", + "isEmpty": false }, { "width": 80, - "height": 80, - "border": { - "color": "#000000ff", - "width": 1 + "height": 84, + "clip": { + "top": 2, + "bottom": 2, + "left": 0, + "right": 0 }, - "type": "shape" + "type": "image", + "mimeType": "image/webp", + "isEmpty": false }, { "width": 411, diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_allow_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_allow_payload.json index 3984003d76..3fb3561ceb 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_allow_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_allow_payload.json @@ -52,15 +52,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -91,15 +82,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -130,15 +112,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -169,15 +142,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -208,15 +172,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -247,15 +202,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -286,15 +232,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 411, "height": 56, diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_mask_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_mask_payload.json index 3984003d76..3fb3561ceb 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_mask_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_mask_payload.json @@ -52,15 +52,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -91,15 +82,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -130,15 +112,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -169,15 +142,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -208,15 +172,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -247,15 +202,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -286,15 +232,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 411, "height": 56, diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_mask_user_input_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_mask_user_input_payload.json index 3984003d76..3fb3561ceb 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_mask_user_input_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_sensitive_fields_mask_user_input_payload.json @@ -52,15 +52,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -91,15 +82,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -130,15 +112,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -169,15 +142,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -208,15 +172,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -247,15 +202,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -286,15 +232,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffffffff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 411, "height": 56, diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_allow_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_allow_payload.json index 2d5906ecbe..60d42b0b0e 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_allow_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_allow_payload.json @@ -121,15 +121,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -160,15 +151,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffbb33ff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -199,15 +181,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffbb33ff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -238,15 +211,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -277,15 +241,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 32, diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_mask_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_mask_payload.json index 50d7a232c5..7072b9fea7 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_mask_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_mask_payload.json @@ -121,15 +121,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -160,15 +151,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffbb33ff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -199,15 +181,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffbb33ff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -238,15 +211,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -277,15 +241,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 32, diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_mask_user_input_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_mask_user_input_payload.json index fde3a14e05..44fdf5109e 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_mask_user_input_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_mask_user_input_payload.json @@ -121,15 +121,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -160,15 +151,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffbb33ff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -199,15 +181,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffbb33ff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -238,15 +211,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -277,15 +241,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 32, diff --git a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_with_input_mask_user_input_payload.json b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_with_input_mask_user_input_payload.json index e4881d7058..ae1a2969e4 100644 --- a/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_with_input_mask_user_input_payload.json +++ b/instrumented/integration/src/androidTest/assets/session_replay_payloads/sr_text_fields_with_input_mask_user_input_payload.json @@ -121,15 +121,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -160,15 +151,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffbb33ff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -199,15 +181,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#ffbb33ff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -238,15 +211,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 44, @@ -277,15 +241,6 @@ } } }, - { - "width": 395, - "height": 1, - "shapeStyle": { - "backgroundColor": "#a538afff", - "opacity": 1.0 - }, - "type": "shape" - }, { "width": 395, "height": 32, diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/ImageComponentsFragment.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/ImageComponentsFragment.kt index f4b1d87e0d..9b7c269416 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/ImageComponentsFragment.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/ImageComponentsFragment.kt @@ -28,7 +28,6 @@ internal class ImageComponentsFragment : Fragment() { private lateinit var viewModel: ImageComponentsViewModel private lateinit var textViewRemote: TextView private lateinit var buttonRemote: Button - private lateinit var viewRemote: View private lateinit var imageViewRemote: ImageView private lateinit var imageButtonRemote: ImageButton private lateinit var appCompatButtonRemote: AppCompatImageButton @@ -41,7 +40,6 @@ internal class ImageComponentsFragment : Fragment() { val rootView = inflater.inflate(R.layout.fragment_image_components, container, false) textViewRemote = rootView.findViewById(R.id.textViewRemote) buttonRemote = rootView.findViewById(R.id.buttonRemote) - viewRemote = rootView.findViewById(R.id.viewRemote) imageViewRemote = rootView.findViewById(R.id.imageView_remote) imageButtonRemote = rootView.findViewById(R.id.imageButtonRemote) appCompatButtonRemote = rootView.findViewById(R.id.appCompatImageButtonRemote) @@ -57,25 +55,12 @@ internal class ImageComponentsFragment : Fragment() { // internal region private fun loadRemoteViews() { - loadView() loadImageView() loadButton() loadTextView() loadImageButtonBackground() } - private fun loadView() { - viewModel.fetchRemoteImage( - LARGE_IMAGE_URL, - viewRemote, - object : ImageLoadedCallback { - override fun onImageLoaded(resource: Drawable) { - viewRemote.background = resource - } - } - ) - } - private fun loadImageView() { viewModel.fetchRemoteImage( LARGE_IMAGE_URL, diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/ImageScalingFragment.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/ImageScalingFragment.kt new file mode 100644 index 0000000000..fb0376cf65 --- /dev/null +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/ImageScalingFragment.kt @@ -0,0 +1,12 @@ +/* + * 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.sample.sessionreplay + +import androidx.fragment.app.Fragment +import com.datadog.android.sample.R + +internal class ImageScalingFragment : Fragment(R.layout.fragment_image_scaling) diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/SessionReplayFragment.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/SessionReplayFragment.kt index 70aaed5a76..9a91c0063a 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/SessionReplayFragment.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/SessionReplayFragment.kt @@ -61,6 +61,7 @@ internal class SessionReplayFragment : R.id.navigation_password_edit_text_components -> R.id.fragment_password_edit_text_components R.id.navigation_unsupported_views -> R.id.fragment_unsupported_views R.id.navigation_image_components -> R.id.fragment_image_components + R.id.navigation_image_scaling -> R.id.fragment_image_scaling else -> null } diff --git a/sample/kotlin/src/main/res/layout/fragment_image_components.xml b/sample/kotlin/src/main/res/layout/fragment_image_components.xml index aff6bdf42f..93e42aea23 100644 --- a/sample/kotlin/src/main/res/layout/fragment_image_components.xml +++ b/sample/kotlin/src/main/res/layout/fragment_image_components.xml @@ -331,14 +331,15 @@ - - - - - - - - - - diff --git a/sample/kotlin/src/main/res/layout/fragment_image_scaling.xml b/sample/kotlin/src/main/res/layout/fragment_image_scaling.xml new file mode 100644 index 0000000000..f9d90a820b --- /dev/null +++ b/sample/kotlin/src/main/res/layout/fragment_image_scaling.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/kotlin/src/main/res/layout/fragment_session_replay.xml b/sample/kotlin/src/main/res/layout/fragment_session_replay.xml index 01f1c3769e..bd8e14ba03 100644 --- a/sample/kotlin/src/main/res/layout/fragment_session_replay.xml +++ b/sample/kotlin/src/main/res/layout/fragment_session_replay.xml @@ -110,5 +110,15 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/navigation_unsupported_views"/> + + diff --git a/sample/kotlin/src/main/res/navigation/nav_graph.xml b/sample/kotlin/src/main/res/navigation/nav_graph.xml index 935d3c7b5f..461288b2f9 100644 --- a/sample/kotlin/src/main/res/navigation/nav_graph.xml +++ b/sample/kotlin/src/main/res/navigation/nav_graph.xml @@ -113,4 +113,8 @@ android:name="com.datadog.android.sample.sessionreplay.ImageComponentsFragment" android:label="@string/title_image_components"/> + + diff --git a/sample/kotlin/src/main/res/values/strings.xml b/sample/kotlin/src/main/res/values/strings.xml index dde241ad0e..d42768a1c8 100644 --- a/sample/kotlin/src/main/res/values/strings.xml +++ b/sample/kotlin/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ View Pager Unsupported views Image Components + Image Scaling Snackbar Foreground service Picture loading