diff --git a/detekt_custom.yml b/detekt_custom.yml index 69bb84755f..671baf2f26 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -94,7 +94,9 @@ datadog: - "android.database.sqlite.SQLiteDatabase.setTransactionSuccessful():java.lang.IllegalStateException" - "android.graphics.Bitmap.compress(android.graphics.Bitmap.CompressFormat, kotlin.Int, java.io.OutputStream):java.lang.NullPointerException,java.lang.IllegalArgumentException" - "android.graphics.Bitmap.createBitmap(android.util.DisplayMetrics?, kotlin.Int, kotlin.Int, android.graphics.Bitmap.Config):java.lang.IllegalArgumentException" + - "android.graphics.Bitmap.createScaledBitmap(android.graphics.Bitmap, kotlin.Int, kotlin.Int, kotlin.Boolean):java.lang.IllegalArgumentException" - "android.graphics.Canvas.constructor(android.graphics.Bitmap):java.lang.IllegalStateException" + - "android.graphics.drawable.LayerDrawable.getDrawable(kotlin.Int):java.lang.IndexOutOfBoundsException" - "android.net.ConnectivityManager.registerDefaultNetworkCallback(android.net.ConnectivityManager.NetworkCallback):java.lang.IllegalArgumentException,java.lang.SecurityException" - "android.net.ConnectivityManager.unregisterNetworkCallback(android.net.ConnectivityManager.NetworkCallback):java.lang.SecurityException" - "android.util.Base64.encodeToString(kotlin.ByteArray, kotlin.Int):java.lang.AssertionError" @@ -282,6 +284,7 @@ datadog: - "android.app.FragmentManager.unregisterFragmentLifecycleCallbacks(android.app.FragmentManager.FragmentLifecycleCallbacks)" - "android.content.Context.createDeviceProtectedStorageContext()" - "android.content.Context.getSystemService(kotlin.String)" + - "android.content.Context.registerComponentCallbacks(android.content.ComponentCallbacks)" - "android.content.Context.registerReceiver(android.content.BroadcastReceiver?, android.content.IntentFilter)" - "android.content.Context.unregisterReceiver(android.content.BroadcastReceiver)" - "android.content.Intent.getBooleanExtra(kotlin.String, kotlin.Boolean)" @@ -377,12 +380,14 @@ datadog: # endregion # region Android Graphics - "android.graphics.Bitmap.recycle()" + - "android.graphics.Canvas.drawColor(kotlin.Int, android.graphics.PorterDuff.Mode)" - "android.graphics.Color.argb(kotlin.Int, kotlin.Int, kotlin.Int, kotlin.Int)" - "android.graphics.Color.blue(kotlin.Int)" - "android.graphics.Color.green(kotlin.Int)" - "android.graphics.Color.red(kotlin.Int)" - "android.graphics.Color.rgb(kotlin.Int, kotlin.Int, kotlin.Int)" - "android.graphics.drawable.Drawable.draw(android.graphics.Canvas)" + - "android.graphics.drawable.Drawable.ConstantState.newDrawable(android.content.res.Resources?)" - "android.graphics.drawable.Drawable.getDrawable(kotlin.Int)" - "android.graphics.drawable.Drawable.getPadding(android.graphics.Rect)" - "android.graphics.drawable.Drawable.setBounds(kotlin.Int, kotlin.Int, kotlin.Int, kotlin.Int)" @@ -622,6 +627,7 @@ datadog: - "java.security.SecureRandom.constructor()" - "java.security.SecureRandom.nextFloat()" - "java.security.SecureRandom.nextLong()" + - "java.util.HashSet.find(kotlin.Function1)" - "java.util.Properties.constructor()" - "java.util.Properties.setProperty(kotlin.String, kotlin.String)" - "java.util.UUID.constructor(kotlin.Long, kotlin.Long)" @@ -653,6 +659,7 @@ datadog: - "kotlin.Array.first(kotlin.Function1)" - "kotlin.Array.firstOrNull(kotlin.Function1)" - "kotlin.Array.forEach(kotlin.Function1)" + - "kotlin.Array.forEachIndexed(kotlin.Function2)" - "kotlin.Array.joinToString(kotlin.CharSequence, kotlin.CharSequence, kotlin.CharSequence, kotlin.Int, kotlin.CharSequence, kotlin.Function1?)" - "kotlin.Array.none(kotlin.Function1)" - "kotlin.Array.orEmpty()" @@ -856,6 +863,7 @@ datadog: - "kotlin.Int.toFloat()" - "kotlin.Int.toLong()" - "kotlin.Int.and(kotlin.Int)" + - "kotlin.IntArray.joinToString(kotlin.CharSequence, kotlin.CharSequence, kotlin.CharSequence, kotlin.Int, kotlin.CharSequence, kotlin.Function1?)" - "kotlin.IntArray.constructor(kotlin.Int)" - "kotlin.Long.asTime()" - "kotlin.Long.coerceIn(kotlin.Long, kotlin.Long)" diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/LayerDrawableExt.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/LayerDrawableExt.kt new file mode 100644 index 0000000000..17e0709b5f --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/LayerDrawableExt.kt @@ -0,0 +1,25 @@ +/* + * 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 + +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import com.datadog.android.api.InternalLogger + +@Suppress("TooGenericExceptionCaught") +internal fun LayerDrawable.safeGetDrawable(index: Int, logger: InternalLogger = InternalLogger.UNBOUND): Drawable? { + return if (index < 0 || index >= this.numberOfLayers) { + logger.log( + level = InternalLogger.Level.ERROR, + target = InternalLogger.Target.MAINTAINER, + { "Failed to get drawable from layer - invalid index passed: $index" } + ) + null + } else { + this.getDrawable(index) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCache.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCache.kt index 40df0aac30..6b73a4ebc8 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCache.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCache.kt @@ -14,6 +14,7 @@ import android.graphics.drawable.DrawableContainer import android.graphics.drawable.LayerDrawable import androidx.annotation.VisibleForTesting import androidx.collection.LruCache +import com.datadog.android.sessionreplay.internal.recorder.safeGetDrawable import com.datadog.android.sessionreplay.internal.utils.CacheUtils import com.datadog.android.sessionreplay.internal.utils.InvocationUtils @@ -99,18 +100,14 @@ internal class Base64LRUCache( } private fun getPrefixForLayerDrawable(drawable: LayerDrawable): String { - return if (drawable.numberOfLayers > 1) { - val sb = StringBuilder() - for (index in 0 until drawable.numberOfLayers) { - val layer = drawable.getDrawable(index) - val layerHash = System.identityHashCode(layer).toString() - sb.append(layerHash) - sb.append("-") - } - "$sb" - } else { - "" + val sb = StringBuilder() + for (index in 0 until drawable.numberOfLayers) { + val layer = drawable.safeGetDrawable(index) + val layerHash = System.identityHashCode(layer).toString() + sb.append(layerHash) + sb.append("-") } + return "$sb" } internal companion object { 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 c396dde6ce..f88a9413fc 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 @@ -29,7 +29,7 @@ import java.util.concurrent.RejectedExecutionException import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit -@Suppress("UndocumentedPublicClass") +@Suppress("TooManyFunctions") internal class Base64Serializer private constructor( private val threadPoolExecutor: ExecutorService, private val drawableUtils: DrawableUtils, @@ -50,45 +50,18 @@ internal class Base64Serializer private constructor( applicationContext: Context, displayMetrics: DisplayMetrics, drawable: Drawable, + drawableWidth: Int, + drawableHeight: Int, imageWireframe: MobileSegment.Wireframe.ImageWireframe ) { - registerCacheForCallbacks(applicationContext) - registerBitmapPoolForCallbacks(applicationContext) + registerCallbacks(applicationContext) asyncImageProcessingCallback?.startProcessingImage() - var shouldCacheBitmap = false - val cachedBase64 = base64LRUCache?.get(drawable) - if (cachedBase64 != null) { - finalizeRecordedDataItem(cachedBase64, imageWireframe, asyncImageProcessingCallback) - return - } - - val bitmap = if ( - drawable is BitmapDrawable && - drawable.bitmap != null && - !drawable.bitmap.isRecycled - ) { - drawable.bitmap - } else { - drawableUtils.createBitmapOfApproxSizeFromDrawable( - drawable, - displayMetrics - )?.let { - shouldCacheBitmap = true - it - } - } - - if (bitmap == null) { - asyncImageProcessingCallback?.finishProcessingImage() - return - } - - Runnable { - @Suppress("ThreadSafety") // this runs inside an executor - serialiseBitmap(drawable, bitmap, shouldCacheBitmap, imageWireframe, asyncImageProcessingCallback) - }.let { executeRunnable(it) } + tryToGetBase64FromCache(drawable, imageWireframe) + ?: tryToGetBitmapFromBitmapDrawable(drawable, imageWireframe) + ?: tryToDrawNewBitmap(drawable, drawableWidth, drawableHeight, displayMetrics, imageWireframe) + ?: asyncImageProcessingCallback?.finishProcessingImage() } internal fun registerAsyncLoadingCallback( @@ -172,6 +145,85 @@ internal class Base64Serializer private constructor( return base64Result } + @MainThread + private fun tryToDrawNewBitmap( + drawable: Drawable, + drawableWidth: Int, + drawableHeight: Int, + displayMetrics: DisplayMetrics, + imageWireframe: MobileSegment.Wireframe.ImageWireframe + ): Bitmap? { + drawableUtils.createBitmapOfApproxSizeFromDrawable( + drawable, + drawableWidth, + drawableHeight, + displayMetrics + )?.let { resizedBitmap -> + serializeBitmapAsynchronously( + drawable, + bitmap = resizedBitmap, + shouldCacheBitmap = true, + imageWireframe + ) + return resizedBitmap + } + + return null + } + + @MainThread + private fun tryToGetBitmapFromBitmapDrawable( + drawable: Drawable, + imageWireframe: MobileSegment.Wireframe.ImageWireframe + ): 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 + ) + + result = scaledBitmap + } + } + return result + } + + private fun tryToGetBase64FromCache( + drawable: Drawable, + imageWireframe: MobileSegment.Wireframe.ImageWireframe + ): String? { + return base64LRUCache?.get(drawable)?.let { base64String -> + finalizeRecordedDataItem(base64String, imageWireframe, asyncImageProcessingCallback) + base64String + } + } + + private fun serializeBitmapAsynchronously( + drawable: Drawable, + bitmap: Bitmap, + shouldCacheBitmap: Boolean, + imageWireframe: MobileSegment.Wireframe.ImageWireframe + ) { + Runnable { + @Suppress("ThreadSafety") // this runs inside an executor + serialiseBitmap( + drawable, + bitmap, + shouldCacheBitmap, + imageWireframe, + asyncImageProcessingCallback + ) + }.let { executeRunnable(it) } + } + private fun finalizeRecordedDataItem( base64String: String, wireframe: MobileSegment.Wireframe.ImageWireframe, @@ -197,6 +249,20 @@ internal class Base64Serializer private constructor( } } + private fun shouldUseDrawableBitmap(drawable: Drawable): Boolean { + return drawable is BitmapDrawable && + drawable.bitmap != null && + !drawable.bitmap.isRecycled && + drawable.bitmap.width > 0 && + drawable.bitmap.height > 0 + } + + @MainThread + private fun registerCallbacks(applicationContext: Context) { + registerCacheForCallbacks(applicationContext) + registerBitmapPoolForCallbacks(applicationContext) + } + // endregion // region builder diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPool.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPool.kt index a13e36531c..73e2ea088b 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPool.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPool.kt @@ -126,7 +126,10 @@ internal class BitmapPool( val cacheIndex = bitmapIndex.incrementAndGet() val cacheKey = "$key-$cacheIndex" - cache.put(cacheKey, bitmap) + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + bitmapPoolHelper.safeCall { + cache.put(cacheKey, bitmap) + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { bitmapPoolHelper.safeCall { 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 5e3a691e96..a9c6e3446d 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 @@ -7,6 +7,9 @@ package com.datadog.android.sessionreplay.internal.recorder.base64 import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.LayerDrawable import android.view.View import android.widget.TextView import androidx.annotation.MainThread @@ -14,6 +17,7 @@ import androidx.annotation.VisibleForTesting import com.datadog.android.sessionreplay.internal.recorder.MappingContext import com.datadog.android.sessionreplay.internal.recorder.ViewUtilsInternal import com.datadog.android.sessionreplay.internal.recorder.densityNormalized +import com.datadog.android.sessionreplay.internal.recorder.safeGetDrawable import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator @@ -37,16 +41,9 @@ internal class ImageWireframeHelper( prefix: String = DRAWABLE_CHILD_NAME ): MobileSegment.Wireframe.ImageWireframe? { val id = uniqueIdentifierGenerator.resolveChildUniqueIdentifier(view, prefix + currentWireframeIndex) + val drawableProperties = resolveDrawableProperties(view, drawable) - @Suppress("ComplexCondition") - if ( - drawable == null || - id == null || - drawable.intrinsicWidth <= 0 || - drawable.intrinsicHeight <= 0 - ) { - return null - } + if (id == null || !drawableProperties.isValid()) return null val displayMetrics = view.resources.displayMetrics val applicationContext = view.context.applicationContext @@ -66,10 +63,13 @@ internal class ImageWireframeHelper( isEmpty = true ) + @Suppress("UnsafeCallOnNullableType") // drawable already checked for null in isValid base64Serializer.handleBitmap( applicationContext = applicationContext, displayMetrics = displayMetrics, - drawable = drawable, + drawable = drawableProperties.drawable!!, + drawableWidth = drawableProperties.drawableWidth, + drawableHeight = drawableProperties.drawableHeight, imageWireframe = imageWireframe ) @@ -129,6 +129,23 @@ internal class ImageWireframeHelper( return result } + private fun resolveDrawableProperties(view: View, drawable: Drawable?): DrawableProperties { + if (drawable == null) return DrawableProperties(null, 0, 0) + + return when (drawable) { + is LayerDrawable -> { + if (drawable.numberOfLayers > 0) { + resolveDrawableProperties(view, drawable.safeGetDrawable(0)) + } else { + DrawableProperties(drawable, drawable.intrinsicWidth, drawable.intrinsicHeight) + } + } + is InsetDrawable -> resolveDrawableProperties(view, drawable.drawable) + is GradientDrawable -> DrawableProperties(drawable, view.width, view.height) + else -> DrawableProperties(drawable, drawable.intrinsicWidth, drawable.intrinsicHeight) + } + } + @Suppress("MagicNumber") private fun convertIndexToCompoundDrawablePosition(compoundDrawableIndex: Int): CompoundDrawablePositions? { return when (compoundDrawableIndex) { @@ -147,6 +164,16 @@ internal class ImageWireframeHelper( BOTTOM } + private data class DrawableProperties( + val drawable: Drawable?, + val drawableWidth: Int, + val drawableHeight: Int + ) { + fun isValid(): Boolean { + return drawable != null && drawableWidth > 0 && drawableHeight > 0 + } + } + internal companion object { @VisibleForTesting internal const val DRAWABLE_CHILD_NAME = "drawable" } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseWireframeMapper.kt index 0f7b94da37..05712e3a25 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseWireframeMapper.kt @@ -18,6 +18,7 @@ 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.densityNormalized +import com.datadog.android.sessionreplay.internal.recorder.safeGetDrawable import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.StringUtils import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator @@ -95,7 +96,7 @@ abstract class BaseWireframeMapper( val color = colorAndAlphaAsStringHexa(color, alpha) MobileSegment.ShapeStyle(color, viewAlpha) to null } else if (this is RippleDrawable && numberOfLayers >= 1) { - getDrawable(0).resolveShapeStyleAndBorder(viewAlpha) + this.safeGetDrawable(0)?.resolveShapeStyleAndBorder(viewAlpha) } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && this is InsetDrawable) { drawable?.resolveShapeStyleAndBorder(viewAlpha) } else { @@ -172,6 +173,8 @@ abstract class BaseWireframeMapper( width: Long, height: Long ): MobileSegment.Wireframe? { + val resources = view.resources + @Suppress("ThreadSafety") // TODO REPLAY-1861 caller thread of .map is unknown? return imageWireframeHelper?.createImageWireframe( view = view, @@ -180,7 +183,7 @@ abstract class BaseWireframeMapper( y = bounds.y, width, height, - view.background, + view.background?.constantState?.newDrawable(resources), shapeStyle = null, border = null, prefix = PREFIX_BACKGROUND_DRAWABLE diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/Base64Wrapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/Base64Wrapper.kt index 08a2ae5d07..244be028f9 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/Base64Wrapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/Base64Wrapper.kt @@ -7,9 +7,12 @@ package com.datadog.android.sessionreplay.internal.recorder.wrappers import android.util.Base64 +import com.datadog.android.api.InternalLogger import java.lang.AssertionError -internal class Base64Wrapper { +internal class Base64Wrapper( + private val logger: InternalLogger = InternalLogger.UNBOUND +) { internal fun encodeToString(byteArray: ByteArray, flags: Int): String { @Suppress("SwallowedException", "TooGenericExceptionCaught") return try { @@ -17,7 +20,17 @@ internal class Base64Wrapper { } catch (e: AssertionError) { // This should never happen since we are using the default encoding // TODO: REPLAY-1364 Add logs here once the sdkLogger is added + logger.log( + level = InternalLogger.Level.ERROR, + target = InternalLogger.Target.MAINTAINER, + { FAILED_TO_ENCODE_TO_STRING }, + e + ) "" } } + + private companion object { + private const val FAILED_TO_ENCODE_TO_STRING = "Failed to encode to string" + } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/BitmapWrapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/BitmapWrapper.kt index 61951a125f..4711e61f57 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/BitmapWrapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/BitmapWrapper.kt @@ -9,22 +9,57 @@ package com.datadog.android.sessionreplay.internal.recorder.wrappers import android.graphics.Bitmap import android.graphics.Bitmap.Config import android.util.DisplayMetrics +import com.datadog.android.api.InternalLogger -internal class BitmapWrapper { +internal class BitmapWrapper( + private val logger: InternalLogger = InternalLogger.UNBOUND +) { internal fun createBitmap( displayMetrics: DisplayMetrics, bitmapWidth: Int, bitmapHeight: Int, config: Config ): Bitmap? { - @Suppress("SwallowedException") return try { Bitmap.createBitmap(displayMetrics, bitmapWidth, bitmapHeight, config) } catch (e: IllegalArgumentException) { // should never happen since config is given as valid type and width/height are // normalized to be at least 1 // TODO: REPLAY-1364 Add logs here once the sdkLogger is added + logger.log( + level = InternalLogger.Level.ERROR, + target = InternalLogger.Target.MAINTAINER, + { FAILED_TO_CREATE_BITMAP }, + e + ) null } } + + internal fun createScaledBitmap( + src: Bitmap, + dstWidth: Int, + dstHeight: Int, + filter: Boolean + ): Bitmap? { + return try { + Bitmap.createScaledBitmap(src, dstWidth, dstHeight, filter) + } catch (e: IllegalArgumentException) { + // should never happen since config is given as valid type and width/height are + // normalized to be at least 1 + // TODO: REPLAY-1364 Add logs here once the sdkLogger is added + logger.log( + level = InternalLogger.Level.ERROR, + target = InternalLogger.Target.MAINTAINER, + { FAILED_TO_CREATE_SCALED_BITMAP }, + e + ) + null + } + } + + private companion object { + private const val FAILED_TO_CREATE_BITMAP = "Failed to create bitmap" + private const val FAILED_TO_CREATE_SCALED_BITMAP = "Failed to create scaled bitmap" + } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/CanvasWrapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/CanvasWrapper.kt index 5bbf3a9025..74192b183d 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/CanvasWrapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/wrappers/CanvasWrapper.kt @@ -8,8 +8,11 @@ package com.datadog.android.sessionreplay.internal.recorder.wrappers import android.graphics.Bitmap import android.graphics.Canvas +import com.datadog.android.api.InternalLogger -internal class CanvasWrapper { +internal class CanvasWrapper( + private val logger: InternalLogger = InternalLogger.UNBOUND +) { internal fun createCanvas(bitmap: Bitmap): Canvas? { @Suppress("SwallowedException", "TooGenericExceptionCaught") return try { @@ -17,10 +20,26 @@ internal class CanvasWrapper { } catch (e: IllegalStateException) { // should never happen since we are passing an immutable bitmap // TODO: REPLAY-1364 Add logs here once the sdkLogger is added + logger.log( + level = InternalLogger.Level.ERROR, + target = InternalLogger.Target.MAINTAINER, + { FAILED_TO_CREATE_CANVAS }, + e + ) null } catch (e: RuntimeException) { // TODO: REPLAY-1364 Add logs here once the sdkLogger is added + logger.log( + level = InternalLogger.Level.ERROR, + target = InternalLogger.Target.MAINTAINER, + { FAILED_TO_CREATE_CANVAS }, + e + ) null } } + + private companion object { + private const val FAILED_TO_CREATE_CANVAS = "Failed to create canvas" + } } 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 64aefb51be..76200b470d 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 @@ -36,11 +36,13 @@ internal class DrawableUtils( @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(drawable, requestedSizeInBytes) + val (width, height) = getScaledWidthAndHeight(drawableWidth, drawableHeight, requestedSizeInBytes) val bitmap = getBitmapBySize(displayMetrics, width, height, config) ?: return null val canvas = canvasWrapper.createCanvas(bitmap) ?: return null @@ -54,6 +56,15 @@ internal class DrawableUtils( return bitmap } + @MainThread + internal fun createScaledBitmap( + bitmap: Bitmap, + requestedSizeInBytes: Int = MAX_BITMAP_SIZE_IN_BYTES + ): Bitmap? { + val (width, height) = getScaledWidthAndHeight(bitmap.width, bitmap.height, requestedSizeInBytes) + return bitmapWrapper.createScaledBitmap(bitmap, width, height, false) + } + internal fun getDrawableScaledDimensions( view: ImageView, drawable: Drawable, @@ -102,11 +113,12 @@ internal class DrawableUtils( } private fun getScaledWidthAndHeight( - drawable: Drawable, + drawableWidth: Int, + drawableHeight: Int, requestedSizeInBytes: Int ): Pair { - var width = drawable.intrinsicWidth - var height = drawable.intrinsicHeight + var width = drawableWidth + var height = drawableHeight val sizeAfterCreation = width * height * ARGB_8888_PIXEL_SIZE_BYTES if (sizeAfterCreation > requestedSizeInBytes) { diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/LayerDrawableExtTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/LayerDrawableExtTest.kt new file mode 100644 index 0000000000..62ef154388 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/LayerDrawableExtTest.kt @@ -0,0 +1,120 @@ +/* + * 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 + +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.LayerDrawable +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +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.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class LayerDrawableExtTest { + + @Mock + lateinit var mockLayerDrawable: LayerDrawable + + @Mock + lateinit var mockBitmapDrawable: BitmapDrawable + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @Test + fun `M return drawable for index W safeGetDrawable()`() { + // Given + whenever(mockLayerDrawable.numberOfLayers) + .thenReturn(1) + whenever(mockLayerDrawable.getDrawable(0)) + .thenReturn(mockBitmapDrawable) + + // When + val drawable = mockLayerDrawable.safeGetDrawable( + index = 0 + ) + + // Then + assertThat(drawable).isEqualTo(mockBitmapDrawable) + } + + @Test + fun `M return null W safeGetDrawable() { index below 0 }`() { + // Given + whenever(mockLayerDrawable.numberOfLayers) + .thenReturn(0) + whenever(mockLayerDrawable.getDrawable(any())) + .thenThrow(IndexOutOfBoundsException()) + + // When + val drawable = mockLayerDrawable.safeGetDrawable( + index = -1, + mockInternalLogger + ) + + // Then + val captor = argumentCaptor<() -> String>() + verify(mockInternalLogger).log( + level = any(), + target = any(), + captor.capture(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + assertThat(captor.firstValue.invoke()) + .startsWith("Failed to get drawable from layer - invalid index passed") + assertThat(drawable).isNull() + } + + @Test + fun `M return null W safeGetDrawable() { index above number of layers }`() { + // Given + whenever(mockLayerDrawable.numberOfLayers) + .thenReturn(0) + whenever(mockLayerDrawable.getDrawable(any())) + .thenThrow(IndexOutOfBoundsException()) + + // When + val drawable = mockLayerDrawable.safeGetDrawable( + index = 1, + mockInternalLogger + ) + + // Then + val captor = argumentCaptor<() -> String>() + verify(mockInternalLogger).log( + level = any(), + target = any(), + captor.capture(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + assertThat(captor.firstValue.invoke()) + .startsWith("Failed to get drawable from layer - invalid index passed") + assertThat(drawable).isNull() + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCacheTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCacheTest.kt index f61ebe2d9d..80006888e1 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCacheTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCacheTest.kt @@ -13,6 +13,7 @@ import android.graphics.drawable.StateListDrawable import androidx.collection.LruCache import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.recorder.base64.Base64LRUCache.Companion.MAX_CACHE_MEMORY_SIZE_BYTES +import com.datadog.android.sessionreplay.internal.recorder.safeGetDrawable import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -116,34 +117,44 @@ internal class Base64LRUCacheTest { val mockBgLayer: Drawable = mock() val mockFgLayer: Drawable = mock() whenever(mockRippleDrawable.numberOfLayers).thenReturn(2) - whenever(mockRippleDrawable.getDrawable(0)).thenReturn(mockBgLayer) - whenever(mockRippleDrawable.getDrawable(1)).thenReturn(mockFgLayer) + whenever(mockRippleDrawable.safeGetDrawable(0)) + .thenReturn(mockBgLayer) + whenever(mockRippleDrawable.safeGetDrawable(1)) + .thenReturn(mockFgLayer) + whenever(mockRippleDrawable.numberOfLayers).thenReturn(2) val fakeBase64 = forge.aString() + testedCache.put(mockRippleDrawable, fakeBase64) + + val expectedPrefix = System.identityHashCode(mockBgLayer).toString() + "-" + + System.identityHashCode(mockFgLayer).toString() + "-" + val expectedHash = System.identityHashCode(mockRippleDrawable).toString() // When - testedCache.put(mockRippleDrawable, fakeBase64) + val key = testedCache.generateKey(mockRippleDrawable) // Then - val key = testedCache.generateKey(mockRippleDrawable) - assertThat(key).contains(System.identityHashCode(mockBgLayer).toString()) + assertThat(key).isEqualTo(expectedPrefix + expectedHash) } @Test - fun `M not generate key prefix W put() { layerDrawable with only one layer }`(forge: Forge) { + fun `M not generate key prefix W put() { layerDrawable with only one layer }`( + @Mock mockRippleDrawable: RippleDrawable, + @Mock mockBgLayer: Drawable, + @StringForgery fakeBase64: String + ) { // Given - val mockRippleDrawable: RippleDrawable = mock() - val mockBgLayer: Drawable = mock() whenever(mockRippleDrawable.numberOfLayers).thenReturn(1) - whenever(mockRippleDrawable.getDrawable(0)).thenReturn(mockBgLayer) + whenever(mockRippleDrawable.safeGetDrawable(0)).thenReturn(mockBgLayer) + testedCache.put(mockRippleDrawable, fakeBase64) - val fakeBase64 = forge.aString() + val expectedPrefix = System.identityHashCode(mockBgLayer).toString() + "-" + val drawableHash = System.identityHashCode(mockRippleDrawable).toString() // When - testedCache.put(mockRippleDrawable, fakeBase64) + val key = testedCache.generateKey(mockRippleDrawable) // Then - val key = testedCache.generateKey(mockRippleDrawable) - assertThat(key).isEqualTo(System.identityHashCode(mockRippleDrawable).toString()) + assertThat(key).isEqualTo(expectedPrefix + drawableHash) } } 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 21534a23ea..723ce1b32e 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 @@ -17,12 +17,14 @@ import android.widget.ImageView import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.AsyncImageProcessingCallback +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.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 import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -35,7 +37,9 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -99,6 +103,15 @@ internal class Base64SerializerTest { @Mock lateinit var mockBitmapPool: BitmapPool + @Mock + lateinit var mockBitmapDrawable: BitmapDrawable + + @IntForgery(min = 1) + var fakeBitmapWidth: Int = 0 + + @IntForgery(min = 1) + var fakeBitmapHeight: Int = 0 + @FloatForgery(min = 0f, max = 1f) var mockDensity: Float = 0f @@ -120,6 +133,8 @@ internal class Base64SerializerTest { whenever( mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( drawable = any(), + drawableWidth = any(), + drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), config = anyOrNull() @@ -131,8 +146,12 @@ internal class Base64SerializerTest { mock>() } - testedBase64Serializer = createBase64Serializer() + whenever(mockBitmap.isRecycled).thenReturn(false) + whenever(mockBitmap.width).thenReturn(fakeBitmapWidth) + whenever(mockBitmap.height).thenReturn(fakeBitmapHeight) + whenever(mockBitmapDrawable.bitmap).thenReturn(mockBitmap) + testedBase64Serializer = createBase64Serializer() testedBase64Serializer.registerAsyncLoadingCallback(mockCallback) } @@ -143,6 +162,8 @@ internal class Base64SerializerTest { applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) @@ -156,6 +177,8 @@ internal class Base64SerializerTest { whenever( mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( drawable = any(), + drawableWidth = any(), + drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), config = anyOrNull() @@ -167,6 +190,8 @@ internal class Base64SerializerTest { applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) @@ -180,6 +205,8 @@ internal class Base64SerializerTest { whenever( mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( drawable = any(), + drawableWidth = any(), + drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), config = anyOrNull() @@ -191,6 +218,8 @@ internal class Base64SerializerTest { applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) @@ -209,6 +238,8 @@ internal class Base64SerializerTest { whenever( mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( drawable = any(), + drawableWidth = any(), + drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), config = anyOrNull() @@ -223,6 +254,8 @@ internal class Base64SerializerTest { applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) @@ -238,6 +271,8 @@ internal class Base64SerializerTest { applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) } @@ -246,6 +281,45 @@ internal class Base64SerializerTest { verify(mockApplicationContext, times(1)).registerComponentCallbacks(mockBase64LRUCache) } + @Test + fun `M log error W handleBitmap() { base64Lru does not subclass ComponentCallbacks2 }`() { + // Given + val fakeBase64CacheInstance = FakeBase64LruCache() + testedBase64Serializer = Base64Serializer.Builder( + logger = mockLogger, + threadPoolExecutor = mockExecutorService, + bitmapPool = mockBitmapPool, + base64LRUCache = fakeBase64CacheInstance, + drawableUtils = mockDrawableUtils, + base64Utils = mockBase64Utils, + webPImageCompression = mockWebPImageCompression + ).build() + + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, + imageWireframe = fakeImageWireframe + ) + + // Then + val captor = argumentCaptor<() -> String>() + verify(mockLogger).log( + level = any(), + target = any(), + captor.capture(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + assertThat(captor.firstValue.invoke()).isEqualTo( + DOES_NOT_IMPLEMENT_COMPONENTCALLBACKS + ) + } + @Test fun `M register BitmapPool only once for callbacks W handleBitmap() { multiple calls }`() { // When @@ -254,6 +328,8 @@ internal class Base64SerializerTest { applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) } @@ -272,12 +348,16 @@ internal class Base64SerializerTest { applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) // Then verify(mockDrawableUtils).createBitmapOfApproxSizeFromDrawable( drawable = any(), + drawableWidth = any(), + drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), config = anyOrNull() @@ -303,6 +383,8 @@ internal class Base64SerializerTest { applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockStateListDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) @@ -311,7 +393,7 @@ internal class Base64SerializerTest { } @Test - fun `M not try to cache base64 W handleBitmap() { and did not get base64 string }`() { + fun `M not try to cache base64 W handleBitmap() { and did not get base64 }`() { // Given whenever(mockBase64Utils.serializeToBase64String(any())).thenReturn("") @@ -320,6 +402,8 @@ internal class Base64SerializerTest { applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockStateListDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) @@ -330,19 +414,23 @@ internal class Base64SerializerTest { @Test fun `M not use bitmap from bitmapDrawable W handleBitmap() { no bitmap }`() { // Given - val mockBitmapDrawable = mock() + whenever(mockBitmapDrawable.bitmap).thenReturn(null) // When testedBase64Serializer.handleBitmap( applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockBitmapDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) // Then verify(mockDrawableUtils, times(1)).createBitmapOfApproxSizeFromDrawable( drawable = any(), + drawableWidth = any(), + drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), config = anyOrNull() @@ -352,21 +440,23 @@ internal class Base64SerializerTest { @Test fun `M not use bitmap from bitmapDrawable W handleBitmap() { bitmap was recycled }`() { // Given - val mockBitmapDrawable = mock() whenever(mockBitmap.isRecycled).thenReturn(true) - whenever(mockBitmapDrawable.bitmap).thenReturn(mockBitmap) // When testedBase64Serializer.handleBitmap( applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockBitmapDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) // Then verify(mockDrawableUtils, times(1)).createBitmapOfApproxSizeFromDrawable( drawable = any(), + drawableWidth = any(), + drawableHeight = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), config = anyOrNull() @@ -374,48 +464,129 @@ internal class Base64SerializerTest { } @Test - fun `M use bitmap from bitmapDrawable W handleBitmap() { has bitmap }`() { + fun `M use scaled bitmap from bitmapDrawable W handleBitmap() { has bitmap }`() { + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockBitmapDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, + imageWireframe = fakeImageWireframe + ) + + // Then + verify(mockDrawableUtils).createScaledBitmap( + any(), + anyOrNull() + ) + } + + @Test + fun `M draw bitmap W handleBitmap() { bitmapDrawable where bitmap has no width }`() { // Given - val mockBitmapDrawable = mock() - whenever(mockBitmap.isRecycled).thenReturn(false) - whenever(mockBitmapDrawable.bitmap).thenReturn(mockBitmap) + whenever(mockBitmap.width).thenReturn(0) // When testedBase64Serializer.handleBitmap( applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockBitmapDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) // Then - verifyNoInteractions(mockDrawableUtils) + verify(mockDrawableUtils, never()).createScaledBitmap( + any(), + anyOrNull() + ) + verify(mockDrawableUtils, times(1)).createBitmapOfApproxSizeFromDrawable( + drawable = any(), + drawableWidth = any(), + drawableHeight = any(), + displayMetrics = any(), + requestedSizeInBytes = anyOrNull(), + config = anyOrNull() + ) } @Test - fun `M not cache image when caching false W handleBitmap() { from BitmapDrawable with bitmap }`() { + fun `M draw bitmap W handleBitmap() { bitmapDrawable where bitmap has no height }`() { // Given - val mockBitmapDrawable = mock() - whenever(mockBitmap.isRecycled).thenReturn(false) - whenever(mockBitmapDrawable.bitmap).thenReturn(mockBitmap) + whenever(mockBitmap.height).thenReturn(0) + + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockBitmapDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, + imageWireframe = fakeImageWireframe + ) + + // Then + verify(mockDrawableUtils, never()).createScaledBitmap( + any(), + anyOrNull() + ) + verify(mockDrawableUtils, times(1)).createBitmapOfApproxSizeFromDrawable( + drawable = any(), + drawableWidth = any(), + drawableHeight = any(), + displayMetrics = any(), + requestedSizeInBytes = anyOrNull(), + config = anyOrNull() + ) + } + + @Test + fun `M not cache bitmap W handleBitmap() { BitmapDrawable with bitmap not resized }`() { + // Given + whenever(mockDrawableUtils.createScaledBitmap(any(), anyOrNull())) + .thenReturn(mockBitmap) // When testedBase64Serializer.handleBitmap( applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockBitmapDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) // Then - verify(mockBitmapPool, times(0)).put(any()) + verify(mockBitmapPool, never()).put(any()) + } + + @Test + fun `M cache bitmap W handleBitmap() { BitmapDrawable with bitmap was resized }`( + @Mock mockResizedBitmap: Bitmap + ) { + // Given + whenever(mockDrawableUtils.createScaledBitmap(any(), anyOrNull())) + .thenReturn(mockResizedBitmap) + + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockBitmapDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, + imageWireframe = fakeImageWireframe + ) + + // Then + verify(mockBitmapPool).put(any()) } @Test fun `M cache bitmap W handleBitmap() { from BitmapDrawable with null bitmap }`() { // Given - val mockBitmapDrawable = mock() - whenever(mockBitmap.isRecycled).thenReturn(false) whenever(mockBitmapDrawable.bitmap).thenReturn(null) // When @@ -423,6 +594,8 @@ internal class Base64SerializerTest { applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockBitmapDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) @@ -433,13 +606,15 @@ internal class Base64SerializerTest { @Test fun `M cache bitmap W handleBitmap() { not a BitmapDrawable }`() { // Given - val mockBitmapDrawable = mock() + val mockLayerDrawable = mock() // When testedBase64Serializer.handleBitmap( applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, - drawable = mockBitmapDrawable, + drawable = mockLayerDrawable, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, imageWireframe = fakeImageWireframe ) @@ -476,4 +651,26 @@ internal class Base64SerializerTest { ) return builder.build() } + + // this is in order to test having a class that implements + // Cache, but does NOT implement ComponentCallbacks2 + private class FakeBase64LruCache : Cache { + override fun put(value: String) { + super.put(value) + } + + override fun put(element: Drawable, value: String) { + super.put(element, value) + } + + override fun get(element: Drawable): String? { + return super.get(element) + } + + override fun size(): Int { + return 0 + } + + override fun clear() {} + } } 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 4651b17aab..621d017770 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 @@ -9,6 +9,9 @@ package com.datadog.android.sessionreplay.internal.recorder.base64 import android.content.Context import android.content.res.Resources import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.RippleDrawable import android.util.DisplayMetrics import android.view.View import android.widget.TextView @@ -35,6 +38,8 @@ 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.argumentCaptor +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -371,5 +376,74 @@ internal class ImageWireframeHelperTest { assertThat(wireframes).isEmpty() } + @Test + fun `M resolve view width and height W createImageWireframe() { RippleDrawable }`( + @Mock mockDrawable: RippleDrawable, + @Mock mockInsetDrawable: InsetDrawable, + @Mock mockGradientDrawable: GradientDrawable, + @IntForgery(min = 1) fakeViewWidth: Int, + @IntForgery(min = 1) fakeViewHeight: Int + ) { + // Given + whenever(mockView.width).thenReturn(fakeViewWidth) + whenever(mockView.height).thenReturn(fakeViewHeight) + whenever(mockDrawable.numberOfLayers).thenReturn(1) + whenever(mockDrawable.getDrawable(0)).thenReturn(mockInsetDrawable) + whenever(mockInsetDrawable.drawable).thenReturn(mockGradientDrawable) + + // When + testedHelper.createImageWireframe( + view = mockView, + currentWireframeIndex = 0, + x = 0, + y = 0, + width = 0, + height = 0, + drawable = mockDrawable, + shapeStyle = null, + border = null + ) + + // Then + val captor = argumentCaptor() + verify(mockBase64Serializer).handleBitmap( + applicationContext = any(), + displayMetrics = any(), + drawable = any(), + drawableWidth = captor.capture(), + drawableHeight = captor.capture(), + imageWireframe = any() + ) + assertThat(captor.allValues).containsExactly(fakeViewWidth, fakeViewHeight) + } + + @Test + fun `M resolve drawable width and height W createImageWireframe() { TextView }`() { + // When + testedHelper.createImageWireframe( + view = mockView, + currentWireframeIndex = 0, + x = 0, + y = 0, + width = 0, + height = 0, + drawable = mockDrawable, + shapeStyle = null, + border = null + ) + + // Then + val captor = argumentCaptor() + verify(mockBase64Serializer).handleBitmap( + applicationContext = any(), + displayMetrics = any(), + drawable = any(), + drawableWidth = captor.capture(), + drawableHeight = captor.capture(), + imageWireframe = any() + ) + assertThat(captor.allValues).containsExactly(fakeDrawableWidth.toInt(), fakeDrawableHeight.toInt()) + } + // endregion } 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 0b54aaabb3..a12d723ea5 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 @@ -100,6 +100,8 @@ internal class DrawableUtilsTest { // When testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( mockDrawable, + mockDrawable.intrinsicWidth, + mockDrawable.intrinsicHeight, mockDisplayMetrics, requestedSize, mockConfig @@ -134,6 +136,8 @@ internal class DrawableUtilsTest { // When testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( mockDrawable, + mockDrawable.intrinsicWidth, + mockDrawable.intrinsicHeight, mockDisplayMetrics ) @@ -165,6 +169,8 @@ internal class DrawableUtilsTest { // When val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( mockDrawable, + mockDrawable.intrinsicWidth, + mockDrawable.intrinsicHeight, mockDisplayMetrics, config = mockConfig ) @@ -198,6 +204,8 @@ internal class DrawableUtilsTest { // When val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( mockDrawable, + mockDrawable.intrinsicWidth, + mockDrawable.intrinsicHeight, mockDisplayMetrics, config = mockConfig ) @@ -218,6 +226,8 @@ internal class DrawableUtilsTest { // When val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( mockDrawable, + mockDrawable.intrinsicWidth, + mockDrawable.intrinsicHeight, mockDisplayMetrics, config = mockConfig ) @@ -242,6 +252,8 @@ internal class DrawableUtilsTest { // When val actualBitmap = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( mockDrawable, + mockDrawable.intrinsicWidth, + mockDrawable.intrinsicHeight, mockDisplayMetrics, config = mockConfig ) @@ -448,4 +460,25 @@ internal class DrawableUtilsTest { assertThat(result.width).isEqualTo(expectedWidth) assertThat(result.height).isEqualTo(expectedHeight) } + + @Test + fun `M return scaled bitmap W createScaledBitmap()`( + @Mock mockScaledBitmap: Bitmap + ) { + // Given + whenever( + mockBitmapWrapper.createScaledBitmap( + any(), + any(), + any(), + any() + ) + ).thenReturn(mockScaledBitmap) + + // When + val actualBitmap = testedDrawableUtils.createScaledBitmap(mockBitmap) + + // Then + assertThat(actualBitmap).isEqualTo(mockScaledBitmap) + } }