From b25e9a49958e6a3f072cf86251302b9f6ab67b61 Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 9 Jul 2023 00:05:48 +0300 Subject: [PATCH 1/8] Revert "Temporarily remove imagebutton mapper" This reverts commit ef005eadc311b380fa411cca5220b8d31f116ed7. --- .../com/datadog/android/sessionreplay/SessionReplayPrivacy.kt | 4 ++++ .../datadog/android/sessionreplay/SessionReplayPrivacyTest.kt | 4 ++++ 2 files changed, 8 insertions(+) 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 33a84b3014..312c353bec 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 @@ -12,6 +12,7 @@ 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 @@ -24,6 +25,7 @@ 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.MapperTypeWrapper import com.datadog.android.sessionreplay.internal.recorder.mapper.MaskCheckBoxMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.MaskCheckedTextViewMapper @@ -77,6 +79,7 @@ enum class SessionReplayPrivacy { internal fun mappers(): List { val viewWireframeMapper = ViewWireframeMapper() val unsupportedViewMapper = UnsupportedViewMapper() + val imageButtonMapper = ImageButtonMapper() val imageMapper: ViewScreenshotWireframeMapper val textMapper: TextViewMapper val buttonMapper: ButtonMapper @@ -131,6 +134,7 @@ 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()), 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 22ef205b75..6571daae18 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 @@ -12,6 +12,7 @@ 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,6 +24,7 @@ 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.MapperTypeWrapper import com.datadog.android.sessionreplay.internal.recorder.mapper.MaskCheckBoxMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.MaskCheckedTextViewMapper @@ -111,11 +113,13 @@ internal class SessionReplayPrivacyTest { private val mockEditTextViewMapper: EditTextViewMapper = mock() private val mockImageMapper: ViewScreenshotWireframeMapper = mock() private val mockUnsupportedViewMapper: UnsupportedViewMapper = mock() + private val mockImageButtonViewMapper: ImageButtonMapper = 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(AppCompatToolbar::class.java, mockUnsupportedViewMapper.toGenericMapper()) ) From 257722badac4dab7367dfaf7d0143df752cd97b7 Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 10 Jul 2023 11:07:58 +0300 Subject: [PATCH 2/8] Implement caching mechanism for base64 --- detekt_custom.yml | 1 + .../api/apiSurface | 2 +- .../api/dd-sdk-android-session-replay.api | 4 +- .../async/RecordedDataQueueHandler.kt | 17 +- .../recorder/base64/Base64LRUCache.kt | 134 ++++++++++++ .../recorder/base64/Base64Serializer.kt | 73 +++++-- .../internal/recorder/base64/Cache.kt | 14 ++ .../recorder/mapper/BaseWireframeMapper.kt | 3 + .../recorder/mapper/ImageButtonMapper.kt | 5 +- .../async/RecordedDataQueueHandlerTest.kt | 46 +++++ .../recorder/base64/Base64LRUCacheTest.kt | 190 ++++++++++++++++++ .../recorder/base64/Base64SerializerTest.kt | 179 ++++++++++++++--- .../recorder/mapper/ImageButtonMapperTest.kt | 13 ++ 13 files changed, 632 insertions(+), 49 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCache.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Cache.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCacheTest.kt diff --git a/detekt_custom.yml b/detekt_custom.yml index 35fb32b701..104c12990d 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -577,6 +577,7 @@ datadog: - "java.lang.Object.constructor()" - "java.lang.Runtime.availableProcessors()" - "java.lang.Runtime.getRuntime()" + - "java.lang.Runtime.maxMemory()" - "java.lang.System.currentTimeMillis()" - "java.lang.System.getProperty(kotlin.String)" - "java.lang.System.identityHashCode(kotlin.Any)" diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index 546715a373..c2a2673809 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -38,7 +38,7 @@ abstract class com.datadog.android.sessionreplay.internal.recorder.mapper.BaseWi protected fun android.graphics.drawable.Drawable.resolveShapeStyleAndBorder(Float): Pair? protected fun resolveChildDrawableUniqueIdentifier(android.view.View): Long? protected fun getWebPMimeType(): String? - protected fun handleBitmap(android.util.DisplayMetrics, android.graphics.drawable.Drawable, com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.ImageWireframe) + protected fun handleBitmap(android.content.Context, android.util.DisplayMetrics, android.graphics.drawable.Drawable, com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.ImageWireframe) override fun startProcessingImage() override fun finishProcessingImage() companion object diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index 1f23997286..c3e2458f23 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -88,7 +88,7 @@ public final class com/datadog/android/sessionreplay/internal/recorder/SystemInf } public final class com/datadog/android/sessionreplay/internal/recorder/base64/Base64Serializer { - public synthetic fun (Ljava/util/concurrent/ExecutorService;Lcom/datadog/android/sessionreplay/internal/utils/DrawableUtils;Lcom/datadog/android/sessionreplay/internal/utils/Base64Utils;Lcom/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/util/concurrent/ExecutorService;Lcom/datadog/android/sessionreplay/internal/utils/DrawableUtils;Lcom/datadog/android/sessionreplay/internal/utils/Base64Utils;Lcom/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression;Lcom/datadog/android/sessionreplay/internal/recorder/base64/Cache;Lcom/datadog/android/api/InternalLogger;Lkotlin/jvm/internal/DefaultConstructorMarker;)V } public abstract interface class com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression { @@ -113,7 +113,7 @@ public abstract class com/datadog/android/sessionreplay/internal/recorder/mapper protected final fun colorAndAlphaAsStringHexa (II)Ljava/lang/String; public fun finishProcessingImage ()V protected final fun getWebPMimeType ()Ljava/lang/String; - protected final fun handleBitmap (Landroid/util/DisplayMetrics;Landroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe;)V + protected final fun handleBitmap (Landroid/content/Context;Landroid/util/DisplayMetrics;Landroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe;)V protected final fun resolveChildDrawableUniqueIdentifier (Landroid/view/View;)Ljava/lang/Long; protected final fun resolveShapeStyleAndBorder (Landroid/graphics/drawable/Drawable;F)Lkotlin/Pair; protected final fun resolveViewGlobalBounds (Landroid/view/View;F)Lcom/datadog/android/sessionreplay/internal/recorder/GlobalBounds; diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandler.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandler.kt index e3954d91e8..868b33c8ce 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandler.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandler.kt @@ -6,12 +6,15 @@ package com.datadog.android.sessionreplay.internal.async +import android.graphics.drawable.Drawable import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import com.datadog.android.sessionreplay.internal.processor.RecordedDataProcessor import com.datadog.android.sessionreplay.internal.processor.RumContextDataHandler import com.datadog.android.sessionreplay.internal.recorder.SystemInformation +import com.datadog.android.sessionreplay.internal.recorder.base64.Base64LRUCache +import com.datadog.android.sessionreplay.internal.recorder.base64.Cache import com.datadog.android.sessionreplay.internal.utils.TimeProvider import com.datadog.android.sessionreplay.model.MobileSegment import java.lang.ClassCastException @@ -34,15 +37,18 @@ internal class RecordedDataQueueHandler { private var processor: RecordedDataProcessor private var rumContextDataHandler: RumContextDataHandler private var timeProvider: TimeProvider + private var cache: Cache internal constructor( processor: RecordedDataProcessor, rumContextDataHandler: RumContextDataHandler, - timeProvider: TimeProvider + timeProvider: TimeProvider, + cache: Cache = Base64LRUCache ) : this( processor = processor, rumContextDataHandler = rumContextDataHandler, timeProvider = timeProvider, + cache = cache, /** * TODO: RUMM-0000 consider change to LoggingThreadPoolExecutor once V2 is merged. @@ -64,12 +70,14 @@ internal class RecordedDataQueueHandler { processor: RecordedDataProcessor, rumContextDataHandler: RumContextDataHandler, timeProvider: TimeProvider, - executorService: ExecutorService + executorService: ExecutorService, + cache: Cache ) { this.processor = processor this.rumContextDataHandler = rumContextDataHandler this.executorService = executorService this.timeProvider = timeProvider + this.cache = cache } // region internal @@ -99,6 +107,11 @@ internal class RecordedDataQueueHandler { val rumContextData = rumContextDataHandler.createRumContextData() ?: return null + // if the view changed then clear the drawable cache + if (rumContextData.prevRumContext != rumContextData.newRumContext) { + cache.clear() + } + val item = SnapshotRecordedDataQueueItem( rumContextData = rumContextData, systemInformation = systemInformation 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 new file mode 100644 index 0000000000..7c52e85a1d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCache.kt @@ -0,0 +1,134 @@ +/* + * 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.base64 + +import android.content.ComponentCallbacks2 +import android.content.res.Configuration +import android.graphics.drawable.AnimationDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.DrawableContainer +import android.graphics.drawable.LayerDrawable +import android.util.LruCache +import androidx.annotation.VisibleForTesting + +internal object Base64LRUCache : Cache, ComponentCallbacks2 { + @Suppress("MagicNumber") + val MAX_CACHE_MEMORY_SIZE_BYTES = 4 * 1024 * 1024 // 4MB + @Suppress("MagicNumber") + private val ON_LOW_MEMORY_SIZE_BYTES = MAX_CACHE_MEMORY_SIZE_BYTES / 2 // 50% size + @Suppress("MagicNumber") + private val ON_MODERATE_MEMORY_SIZE_BYTES = (MAX_CACHE_MEMORY_SIZE_BYTES / 4) * 3 // 75% size + + private var cache: LruCache = object : + LruCache(MAX_CACHE_MEMORY_SIZE_BYTES) { + override fun sizeOf(key: String?, value: ByteArray): Int { + return value.size + } + } + + override fun onTrimMemory(level: Int) { + when (level) { + ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> { + cache.evictAll() + } + + ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> { + cache.evictAll() + } + + ComponentCallbacks2.TRIM_MEMORY_MODERATE -> { + cache.trimToSize(ON_MODERATE_MEMORY_SIZE_BYTES) + } + + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { + cache.evictAll() + } + + ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> { + cache.trimToSize(ON_LOW_MEMORY_SIZE_BYTES) + } + + ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE -> { + cache.trimToSize(ON_MODERATE_MEMORY_SIZE_BYTES) + } + + ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> { + cache.evictAll() + } + + else -> { + cache.evictAll() + } + } + } + + override fun onConfigurationChanged(newConfig: Configuration) {} + + override fun onLowMemory() { + cache.evictAll() + } + + @VisibleForTesting + internal fun setBackingCache(cache: LruCache) { + this.cache = cache + } + + @Synchronized + override fun put(element: Drawable, value: String) { + val key = generateKey(element) + val byteArray = value.toByteArray(Charsets.UTF_8) + cache.put(key, byteArray) + } + + @Synchronized + override fun get(element: Drawable): String? = + cache.get(generateKey(element))?.let { + String(it) + } + + @Synchronized + override fun size(): Int = cache.size() + + @Synchronized + override fun clear() { + cache.evictAll() + } + + private fun generateKey(drawable: Drawable): String = + generatePrefix(drawable) + System.identityHashCode(drawable) + + private fun generatePrefix(drawable: Drawable): String { + return when (drawable) { + is DrawableContainer -> getPrefixForDrawableContainer(drawable) + is LayerDrawable -> getPrefixForLayerDrawable(drawable) + else -> "" + } + } + + private fun getPrefixForDrawableContainer(drawable: DrawableContainer): String { + if (drawable !is AnimationDrawable) { + return drawable.state.joinToString(separator = "", postfix = "-") + } + + return "" + } + + 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 { + "" + } + } +} 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 890c16b8d8..0a63b2b0af 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 @@ -6,12 +6,15 @@ package com.datadog.android.sessionreplay.internal.recorder.base64 +import android.content.ComponentCallbacks2 +import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.util.DisplayMetrics import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.internal.AsyncImageProcessingCallback import com.datadog.android.sessionreplay.internal.utils.Base64Utils import com.datadog.android.sessionreplay.internal.utils.DrawableUtils @@ -25,23 +28,33 @@ import java.util.concurrent.TimeUnit @Suppress("UndocumentedPublicClass") class Base64Serializer private constructor( private val threadPoolExecutor: ExecutorService, - private val drawableUtils: DrawableUtils = DrawableUtils(), - private val base64Utils: Base64Utils = Base64Utils(), - private val webPImageCompression: ImageCompression = WebPImageCompression() + private val drawableUtils: DrawableUtils, + private val base64Utils: Base64Utils, + private val webPImageCompression: ImageCompression, + private val base64LruCache: Cache, + private val logger: InternalLogger ) { - private var asyncImageProcessingCallback: AsyncImageProcessingCallback? = null // region internal @MainThread internal fun handleBitmap( + applicationContext: Context, displayMetrics: DisplayMetrics, drawable: Drawable, imageWireframe: MobileSegment.Wireframe.ImageWireframe ) { + registerCacheForCallbacks(applicationContext) + asyncImageProcessingCallback?.startProcessingImage() + val cachedBase64 = base64LruCache.get(drawable) + if (cachedBase64 != null) { + finalizeRecordedDataItem(cachedBase64, imageWireframe, asyncImageProcessingCallback) + return + } + val bitmap = drawableUtils.createBitmapFromDrawable(drawable, displayMetrics) if (bitmap == null) { @@ -51,7 +64,7 @@ class Base64Serializer private constructor( Runnable { @Suppress("ThreadSafety") // this runs inside an executor - serialiseBitmap(bitmap, imageWireframe, asyncImageProcessingCallback) + serialiseBitmap(drawable, bitmap, imageWireframe, asyncImageProcessingCallback) }.let { executeRunnable(it) } } @@ -78,30 +91,54 @@ class Base64Serializer private constructor( @WorkerThread private fun serialiseBitmap( + drawable: Drawable, bitmap: Bitmap, imageWireframe: MobileSegment.Wireframe.ImageWireframe, asyncImageProcessingCallback: AsyncImageProcessingCallback? ) { - val base64String = convertBmpToBase64(bitmap) + val base64String = convertBmpToBase64(drawable, bitmap) finalizeRecordedDataItem(base64String, imageWireframe, asyncImageProcessingCallback) } + @MainThread + private fun registerCacheForCallbacks(applicationContext: Context) { + if (isCacheRegisteredForCallbacks) return + + if (base64LruCache is ComponentCallbacks2) { + applicationContext.registerComponentCallbacks(base64LruCache) + isCacheRegisteredForCallbacks = true + } else { + // Temporarily use UNBOUND logger + // TODO: REPLAY-1364 Add logs here once the sdkLogger is added + logger.log( + level = InternalLogger.Level.WARN, + target = InternalLogger.Target.MAINTAINER, + messageBuilder = { DOES_NOT_IMPLEMENT_COMPONENTCALLBACKS } + ) + } + } + @WorkerThread - private fun convertBmpToBase64(bitmap: Bitmap): String { + private fun convertBmpToBase64(drawable: Drawable, bitmap: Bitmap): String { val byteArrayOutputStream = webPImageCompression.compressBitmapToStream(bitmap) if (isOverSizeLimit(byteArrayOutputStream.size())) { return "" } - val base64: String + val base64String: String try { - base64 = base64Utils.serializeToBase64String(byteArrayOutputStream) + base64String = base64Utils.serializeToBase64String(byteArrayOutputStream) + + if (base64String.isNotEmpty()) { + // if we got a base64 string then cache it + base64LruCache.put(drawable, base64String) + } } finally { bitmap.recycle() } - return base64 + return base64String } private fun finalizeRecordedDataItem( @@ -137,13 +174,18 @@ class Base64Serializer private constructor( threadPoolExecutor: ExecutorService = THREADPOOL_EXECUTOR, drawableUtils: DrawableUtils = DrawableUtils(), base64Utils: Base64Utils = Base64Utils(), - webPImageCompression: ImageCompression = WebPImageCompression() + webPImageCompression: ImageCompression = WebPImageCompression(), + base64LruCache: Cache = Base64LRUCache, + // Temporarily use UNBOUND until we handle the loggers + logger: InternalLogger = InternalLogger.UNBOUND ) = Base64Serializer( threadPoolExecutor = threadPoolExecutor, drawableUtils = drawableUtils, base64Utils = base64Utils, - webPImageCompression = webPImageCompression + webPImageCompression = webPImageCompression, + base64LruCache = base64LruCache, + logger = logger ) private companion object { @@ -168,5 +210,12 @@ class Base64Serializer private constructor( internal companion object { @VisibleForTesting internal const val BITMAP_SIZE_LIMIT_BYTES = 15000 // 15 kbs + + internal const val DOES_NOT_IMPLEMENT_COMPONENTCALLBACKS = + "Cache instance does not implement ComponentCallbacks2" + + // The cache is a singleton, so we want to share this flag among + // all instances so that it's registered only once + private var isCacheRegisteredForCallbacks: Boolean = false } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Cache.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Cache.kt new file mode 100644 index 0000000000..43e87328c6 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Cache.kt @@ -0,0 +1,14 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.recorder.base64 + +internal interface Cache { + fun put(element: K, value: V) + fun get(element: K): V? + fun size(): Int + fun clear() +} 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 cc753a30d5..316c29c127 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 @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.content.Context import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.InsetDrawable @@ -103,10 +104,12 @@ abstract class BaseWireframeMapper( */ @MainThread protected fun handleBitmap( + applicationContext: Context, displayMetrics: DisplayMetrics, drawable: Drawable, imageWireframe: MobileSegment.Wireframe.ImageWireframe ) = base64Serializer.handleBitmap( + applicationContext, displayMetrics, drawable, imageWireframe 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/ImageButtonMapper.kt index bc61cfdf9e..095838d294 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/ImageButtonMapper.kt @@ -27,7 +27,8 @@ internal class ImageButtonMapper( view: ImageButton, mappingContext: MappingContext ): List { - val drawable = view.drawable + val resources = view.resources + val drawable = view.drawable?.constantState?.newDrawable(resources) val id = resolveChildDrawableUniqueIdentifier(view) if (drawable == null || id == null) return emptyList() @@ -40,6 +41,7 @@ internal class ImageButtonMapper( val mimeType = getWebPMimeType() val displayMetrics = view.resources.displayMetrics + val applicationContext = view.context.applicationContext val imageWireframe = MobileSegment.Wireframe.ImageWireframe( id = id, @@ -56,6 +58,7 @@ internal class ImageButtonMapper( @Suppress("ThreadSafety") // TODO REPLAY-1861 caller thread of .map is unknown? handleBitmap( + applicationContext = applicationContext, displayMetrics = displayMetrics, drawable = drawable, imageWireframe = imageWireframe diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandlerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandlerTest.kt index 5bd4f0c3c3..227cd072b4 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandlerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandlerTest.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.internal.async +import android.graphics.drawable.Drawable import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler.Companion.MAX_DELAY_MS import com.datadog.android.sessionreplay.internal.processor.RecordedDataProcessor @@ -13,6 +14,7 @@ import com.datadog.android.sessionreplay.internal.processor.RumContextData import com.datadog.android.sessionreplay.internal.processor.RumContextDataHandler import com.datadog.android.sessionreplay.internal.recorder.Node import com.datadog.android.sessionreplay.internal.recorder.SystemInformation +import com.datadog.android.sessionreplay.internal.recorder.base64.Cache import com.datadog.android.sessionreplay.internal.time.SessionReplayTimeProvider import com.datadog.android.sessionreplay.model.MobileSegment import fr.xgouchet.elmyr.Forge @@ -67,6 +69,9 @@ internal class RecordedDataQueueHandlerTest { @Mock lateinit var mockTimeProvider: SessionReplayTimeProvider + @Mock + lateinit var mockBase64LruCache: Cache + @Forgery lateinit var fakeRumContextData: RumContextData @@ -106,6 +111,7 @@ internal class RecordedDataQueueHandlerTest { processor = mockProcessor, rumContextDataHandler = mockRumContextDataHandler, timeProvider = mockTimeProvider, + cache = mockBase64LruCache, executorService = mockExecutorService ) } @@ -441,6 +447,46 @@ internal class RecordedDataQueueHandlerTest { assertThat(testedHandler.recordedDataQueue.size).isEqualTo(2) } + // region addSnapshotItem + + @Test + fun `M clear cache W addSnapshotItem() { changed view }`() { + // Given + val rumContextData = RumContextData( + timestamp = System.currentTimeMillis(), + newRumContext = fakeRumContextData.newRumContext, + prevRumContext = fakeRumContextData.prevRumContext + ) + + whenever(mockRumContextDataHandler.createRumContextData()).thenReturn(rumContextData) + + // When + testedHandler.addSnapshotItem(mockSystemInformation) + + // Then + verify(mockBase64LruCache).clear() + } + + @Test + fun `M not clear cache W addSnapshotItem() { same view }`() { + // Given + val rumContextData = RumContextData( + timestamp = System.currentTimeMillis(), + newRumContext = fakeRumContextData.newRumContext, + prevRumContext = fakeRumContextData.newRumContext + ) + + whenever(mockRumContextDataHandler.createRumContextData()).thenReturn(rumContextData) + + // When + testedHandler.addSnapshotItem(mockSystemInformation) + + // Then + verifyNoMoreInteractions(mockBase64LruCache) + } + + // endregion + private fun createFakeSnapshotItemWithDelayMs(delay: Int): SnapshotRecordedDataQueueItem { val newRumContext = RumContextData( timestamp = System.currentTimeMillis() + delay, 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 new file mode 100644 index 0000000000..110a9092a9 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Base64LRUCacheTest.kt @@ -0,0 +1,190 @@ +/* + * 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.base64 + +import android.graphics.drawable.AnimationDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.StateListDrawable +import android.util.LruCache +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.tools.unit.extensions.ApiLevelExtension +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class), + ExtendWith(ApiLevelExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class Base64LRUCacheTest { + private val testedCache = Base64LRUCache + + @Mock + lateinit var mockLruCache: LruCache + + @Mock + lateinit var mockDrawable: Drawable + + @StringForgery + lateinit var fakeBase64: String + + val argumentCaptor = argumentCaptor() + + @BeforeEach + fun setup() { + testedCache.setBackingCache(mockLruCache) + } + + @Test + fun `M return null W get() { item not in cache }`() { + // When + val cacheItem = testedCache.get(mockDrawable) + + // Then + assertThat(cacheItem).isNull() + } + + @Test + fun `M return item W get() { item in cache }`(forge: Forge) { + // Given + val drawableID = System.identityHashCode(mockDrawable).toString() + val fakeBase64String = forge.aString() + val fakeValue = forge.anAsciiString().toByteArray() + + whenever(mockLruCache.get(drawableID)).thenReturn(fakeValue) + testedCache.put(mockDrawable, fakeBase64String) + + // When + val cacheItem = testedCache.get(mockDrawable) + + // Then + verify(mockLruCache).get(drawableID) + assertThat(cacheItem).isEqualTo(String(fakeValue)) + } + + @Test + fun `M call LruCache put W put()`() { + // Given + val key = System.identityHashCode(mockDrawable).toString() + + // When + testedCache.put(mockDrawable, fakeBase64) + + // Then + verify(mockLruCache).put(key, fakeBase64.toByteArray()) + } + + @Test + fun `M return LruCache size W size()`() { + // Given + whenever(mockLruCache.size()).thenReturn(3) + + // When + val size = testedCache.size() + + // Then + verify(mockLruCache).size() + assertThat(size).isEqualTo(3) + } + + @Test + fun `M clear LRUCache W clear()`() { + // When + testedCache.clear() + + // Then + verify(mockLruCache).evictAll() + } + + @Test + fun `M not generate prefix W put() { animationDrawable }`() { + // Given + val mockAnimationDrawable: AnimationDrawable = mock() + + // When + testedCache.put(mockAnimationDrawable, fakeBase64) + + // Then + verify(mockLruCache).put(argumentCaptor.capture(), any()) + assertThat(argumentCaptor.firstValue).doesNotContain("-") + } + + @Test + fun `M generate key prefix with state W put() { drawableContainer }`(forge: Forge) { + // Given + val mockStatelistDrawable: StateListDrawable = mock() + val fakeStateArray = intArrayOf(forge.aPositiveInt()) + val expectedPrefix = fakeStateArray[0].toString() + "-" + whenever(mockStatelistDrawable.state).thenReturn(fakeStateArray) + + // When + testedCache.put(mockStatelistDrawable, fakeBase64) + + // Then + verify(mockLruCache).put(argumentCaptor.capture(), any()) + assertThat(argumentCaptor.firstValue).startsWith(expectedPrefix) + } + + @Test + fun `M generate key prefix with layer hash W put() { layerDrawable }`(forge: Forge) { + // Given + val mockRippleDrawable: RippleDrawable = mock() + 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) + + val fakeBase64 = forge.aString() + val captor = argumentCaptor() + + // When + testedCache.put(mockRippleDrawable, fakeBase64) + + // Then + verify(mockLruCache).put(captor.capture(), any()) + assertThat(captor.firstValue).contains(System.identityHashCode(mockBgLayer).toString()) + } + + @Test + fun `M not generate key prefix W put() { layerDrawable with only one layer }`(forge: Forge) { + // Given + val mockRippleDrawable: RippleDrawable = mock() + val mockBgLayer: Drawable = mock() + whenever(mockRippleDrawable.numberOfLayers).thenReturn(1) + whenever(mockRippleDrawable.getDrawable(0)).thenReturn(mockBgLayer) + + val fakeBase64 = forge.aString() + val captor = argumentCaptor() + + // When + testedCache.put(mockRippleDrawable, fakeBase64) + + // Then + verify(mockLruCache).put(captor.capture(), any()) + assertThat(captor.firstValue).isEqualTo(System.identityHashCode(mockRippleDrawable).toString()) + } +} 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 d9319c9904..b79ba80ee2 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 @@ -6,7 +6,11 @@ package com.datadog.android.sessionreplay.internal.recorder.base64 +import android.content.Context import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.graphics.drawable.StateListDrawable +import android.util.DisplayMetrics import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.AsyncImageProcessingCallback import com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer.Companion.BITMAP_SIZE_LIMIT_BYTES @@ -27,7 +31,9 @@ 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.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.io.ByteArrayOutputStream @@ -53,42 +59,53 @@ internal class Base64SerializerTest { lateinit var mockBase64Utils: Base64Utils @Mock - lateinit var mockCallback: AsyncImageProcessingCallback + lateinit var mockApplicationContext: Context - @Forgery - lateinit var fakeImageWireframe: MobileSegment.Wireframe.ImageWireframe + @Mock + lateinit var mockCallback: AsyncImageProcessingCallback lateinit var fakeBase64String: String - lateinit var fakeBitmap: Bitmap @Mock lateinit var mockExecutorService: ExecutorService + @Mock + lateinit var mockBase64LruCache: Base64LRUCache + + @Mock + lateinit var mockDisplayMetrics: DisplayMetrics + + @Mock + lateinit var mockDrawable: Drawable + + @Mock + lateinit var mockBitmap: Bitmap + + @Mock + lateinit var mockStateListDrawable: StateListDrawable + + @Forgery + lateinit var fakeImageWireframe: MobileSegment.Wireframe.ImageWireframe + @BeforeEach fun setup(forge: Forge) { fakeBase64String = forge.aString() - fakeBitmap = mock() - - val fakeByteOutputStream: ByteArrayOutputStream = mock() - whenever(mockWebPImageCompression.compressBitmapToStream(any())).thenReturn(fakeByteOutputStream) - whenever(mockBase64Utils.serializeToBase64String(any())).thenReturn(fakeBase64String) fakeImageWireframe.base64 = "" fakeImageWireframe.isEmpty = true + val fakeByteOutputStream: ByteArrayOutputStream = mock() + whenever(mockWebPImageCompression.compressBitmapToStream(any())).thenReturn(fakeByteOutputStream) whenever(mockBase64Utils.serializeToBase64String(any())).thenReturn(fakeBase64String) + whenever(mockDrawableUtils.createBitmapFromDrawable(any(), any())).thenReturn(mockBitmap) + whenever(mockExecutorService.submit(any())).then { (it.arguments[0] as Runnable).run() mock>() } - testedBase64Serializer = Base64Serializer.Builder().build( - threadPoolExecutor = mockExecutorService, - drawableUtils = mockDrawableUtils, - base64Utils = mockBase64Utils, - webPImageCompression = mockWebPImageCompression - ) + testedBase64Serializer = createBase64Serializer() testedBase64Serializer.registerAsyncLoadingCallback(mockCallback) } @@ -97,9 +114,10 @@ internal class Base64SerializerTest { fun `M callback with startProcessingImage W handleBitmap()`() { // When testedBase64Serializer.handleBitmap( - displayMetrics = mock(), - drawable = mock(), - imageWireframe = mock() + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockDrawable, + imageWireframe = fakeImageWireframe ) // Then @@ -113,9 +131,10 @@ internal class Base64SerializerTest { // When testedBase64Serializer.handleBitmap( - displayMetrics = mock(), - drawable = mock(), - imageWireframe = mock() + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockDrawable, + imageWireframe = fakeImageWireframe ) // Then @@ -123,14 +142,12 @@ internal class Base64SerializerTest { } @Test - fun `M callback with finishProcessingImage W handleBitmap { created bmp async }`() { - // Given - whenever(mockDrawableUtils.createBitmapFromDrawable(any(), any())).thenReturn(fakeBitmap) - + fun `M callback with finishProcessingImage W handleBitmap() { created bmp async }`() { // When testedBase64Serializer.handleBitmap( - displayMetrics = mock(), - drawable = mock(), + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockDrawable, imageWireframe = fakeImageWireframe ) @@ -141,18 +158,79 @@ internal class Base64SerializerTest { } @Test - fun `M return empty base64 string W image over size limit`() { + fun `M get base64 from cache W handleBitmap() { cache hit }`(forge: Forge) { + // Given + val fakeBase64String = forge.anAsciiString() + whenever(mockBase64LruCache.get(mockDrawable)).thenReturn(fakeBase64String) + + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockDrawable, + imageWireframe = fakeImageWireframe + ) + + // Then + verifyNoInteractions(mockDrawableUtils) + } + + @Test + fun `M calculate base64 W handleBitmap() { cache miss }`() { // Given + whenever(mockBase64LruCache.get(mockDrawable)).thenReturn(null) - whenever(mockDrawableUtils.createBitmapFromDrawable(any(), any())).thenReturn(fakeBitmap) + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockDrawable, + imageWireframe = fakeImageWireframe + ) + + // Then + verify(mockDrawableUtils).createBitmapFromDrawable(any(), any()) + } + + @Test + fun `M register cache only once for callbacks W handleBitmap() { multiple calls and instances }`() { + // Given + val secondInstance = createBase64Serializer() + + // When + repeat(5) { + secondInstance.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockDrawable, + imageWireframe = fakeImageWireframe + ) + + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockDrawable, + imageWireframe = fakeImageWireframe + ) + } + + // Then + verify(mockApplicationContext, times(1)).registerComponentCallbacks(mockBase64LruCache) + } + + @Test + fun `M return empty base64 string W handleBitmap() { image over size limit }`() { + // Given val mockByteArrayOutputStream: ByteArrayOutputStream = mock() whenever(mockByteArrayOutputStream.size()).thenReturn(BITMAP_SIZE_LIMIT_BYTES + 1) whenever(mockWebPImageCompression.compressBitmapToStream(any())).thenReturn(mockByteArrayOutputStream) + whenever(mockDrawableUtils.createBitmapFromDrawable(any(), any())).thenReturn(mockBitmap) // When testedBase64Serializer.handleBitmap( - displayMetrics = mock(), - drawable = mock(), + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockDrawable, imageWireframe = fakeImageWireframe ) @@ -173,4 +251,43 @@ internal class Base64SerializerTest { instance2.getThreadPoolExecutor() ) } + + @Test + fun `M cache base64 string W handleBitmap() { and got base64 string }`() { + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockStateListDrawable, + imageWireframe = fakeImageWireframe + ) + + // Then + verify(mockBase64LruCache, times(1)).put(mockStateListDrawable, fakeBase64String) + } + + @Test + fun `M not try to cache base64 W handleBitmap() { and did not get base64 string }`() { + // Given + whenever(mockBase64Utils.serializeToBase64String(any())).thenReturn("") + + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockStateListDrawable, + imageWireframe = fakeImageWireframe + ) + + // Then + verify(mockBase64LruCache, times(0)).put(any(), any()) + } + + private fun createBase64Serializer() = Base64Serializer.Builder().build( + threadPoolExecutor = mockExecutorService, + drawableUtils = mockDrawableUtils, + base64Utils = mockBase64Utils, + webPImageCompression = mockWebPImageCompression, + base64LruCache = mockBase64LruCache + ) } 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/ImageButtonMapperTest.kt index 1139c204b7..f1e7764286 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/ImageButtonMapperTest.kt @@ -6,8 +6,10 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.content.Context import android.content.res.Resources import android.graphics.drawable.Drawable +import android.graphics.drawable.Drawable.ConstantState import android.util.DisplayMetrics import android.widget.ImageButton import com.datadog.android.sessionreplay.forge.ForgeConfigurator @@ -80,6 +82,12 @@ internal class ImageButtonMapperTest { @Mock lateinit var mockBackground: Drawable + @Mock + lateinit var mockConstantState: ConstantState + + @Mock + lateinit var mockContext: Context + private val fakeId = Forge().aLong() private val fakeMimeType = Forge().aString() @@ -89,6 +97,8 @@ internal class ImageButtonMapperTest { whenever(mockUniqueIdentifierGenerator.resolveChildUniqueIdentifier(any(), any())) .thenReturn(fakeId) + whenever(mockConstantState.newDrawable(any())).thenReturn(mockDrawable) + whenever(mockDrawable.constantState).thenReturn(mockConstantState) whenever(mockImageButton.drawable).thenReturn(mockDrawable) whenever(mockWebPImageCompression.getMimeType()).thenReturn(fakeMimeType) @@ -101,6 +111,9 @@ internal class ImageButtonMapperTest { whenever(mockImageButton.background).thenReturn(mockBackground) + whenever(mockContext.applicationContext).thenReturn(mockContext) + whenever(mockImageButton.context).thenReturn(mockContext) + whenever(mockViewUtils.resolveViewGlobalBounds(any(), any())).thenReturn(mockGlobalBounds) testedMapper = ImageButtonMapper( From 5e966bf1fef0f52688cc8027c5fbfed4577c8be1 Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 16 Jul 2023 15:05:07 +0300 Subject: [PATCH 3/8] Implement bitmap downscaling --- detekt_custom.yml | 3 + .../recorder/base64/Base64Serializer.kt | 24 +--- .../recorder/mapper/ImageButtonMapper.kt | 63 ++++++--- .../recorder/wrappers/BitmapWrapper.kt | 2 +- .../internal/utils/DrawableUtils.kt | 53 +++++++- .../recorder/base64/Base64SerializerTest.kt | 50 ++++--- .../recorder/mapper/ImageButtonMapperTest.kt | 69 +++++++++- .../internal/utils/DrawableUtilsTest.kt | 125 +++++++++++++----- .../main/res/drawable/ic_dd_shape_oval.xml | 7 + .../main/res/drawable/ic_dd_shape_rect.xml | 7 + .../res/layout/fragment_image_components.xml | 23 ++++ 11 files changed, 325 insertions(+), 101 deletions(-) create mode 100644 sample/kotlin/src/main/res/drawable/ic_dd_shape_oval.xml create mode 100644 sample/kotlin/src/main/res/drawable/ic_dd_shape_rect.xml diff --git a/detekt_custom.yml b/detekt_custom.yml index 793f084130..88ab8898b1 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -727,6 +727,7 @@ datadog: - "kotlin.collections.MutableList.add(com.datadog.android.plugin.DatadogPlugin)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.processor.MutationResolver.Entry)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.model.MobileSegment.Add)" + - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.model.MobileSegment.WireframeUpdateMutation)" - "kotlin.collections.MutableList.add(com.datadog.android.rum.internal.domain.scope.RumScope)" - "kotlin.collections.MutableList.add(com.datadog.android.rum.model.ActionEvent.Type)" @@ -838,6 +839,7 @@ datadog: - "kotlin.Float.toFloat()" - "kotlin.Int.inv()" - "kotlin.Int.toChar()" + - "kotlin.Int.toDouble()" - "kotlin.Int.toFloat()" - "kotlin.Int.toLong()" - "kotlin.Int.and(kotlin.Int)" @@ -857,6 +859,7 @@ datadog: - "kotlin.Pair.constructor(com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext, com.google.gson.JsonArray)" - "kotlin.Pair.constructor(com.datadog.android.sessionreplay.model.MobileSegment, com.google.gson.JsonObject)" - "kotlin.Pair.constructor(com.google.gson.JsonObject, kotlin.Long)" + - "kotlin.Pair.constructor(kotlin.Int, kotlin.Int)" - "kotlin.Triple.constructor(kotlin.String, kotlin.String, kotlin.String)" - "kotlin.Triple.constructor(kotlin.Nothing?, kotlin.Nothing?, kotlin.Nothing?)" # endregion 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 0a63b2b0af..1c200e990a 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 @@ -55,7 +55,7 @@ class Base64Serializer private constructor( return } - val bitmap = drawableUtils.createBitmapFromDrawable(drawable, displayMetrics) + val bitmap = drawableUtils.createBitmapOfApproxSizeFromDrawable(drawable, displayMetrics) if (bitmap == null) { asyncImageProcessingCallback?.finishProcessingImage() @@ -74,10 +74,6 @@ class Base64Serializer private constructor( this.asyncImageProcessingCallback = asyncImageProcessingCallback } - @VisibleForTesting - internal fun isOverSizeLimit(bitmapSize: Int): Boolean = - bitmapSize > BITMAP_SIZE_LIMIT_BYTES - // endregion // region testing @@ -120,25 +116,22 @@ class Base64Serializer private constructor( @WorkerThread private fun convertBmpToBase64(drawable: Drawable, bitmap: Bitmap): String { - val byteArrayOutputStream = webPImageCompression.compressBitmapToStream(bitmap) + val base64Result: String - if (isOverSizeLimit(byteArrayOutputStream.size())) { - return "" - } + val byteArrayOutputStream = webPImageCompression.compressBitmapToStream(bitmap) - val base64String: String try { - base64String = base64Utils.serializeToBase64String(byteArrayOutputStream) + base64Result = base64Utils.serializeToBase64String(byteArrayOutputStream) - if (base64String.isNotEmpty()) { + if (base64Result.isNotEmpty()) { // if we got a base64 string then cache it - base64LruCache.put(drawable, base64String) + base64LruCache.put(drawable, base64Result) } } finally { bitmap.recycle() } - return base64String + return base64Result } private fun finalizeRecordedDataItem( @@ -208,9 +201,6 @@ class Base64Serializer private constructor( // endregion internal companion object { - @VisibleForTesting - internal const val BITMAP_SIZE_LIMIT_BYTES = 15000 // 15 kbs - internal const val DOES_NOT_IMPLEMENT_COMPONENTCALLBACKS = "Cache instance does not implement ComponentCallbacks2" 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/ImageButtonMapper.kt index 095838d294..c580dc6e90 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/ImageButtonMapper.kt @@ -6,7 +6,9 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.graphics.drawable.Drawable import android.widget.ImageButton +import com.datadog.android.sessionreplay.internal.recorder.GlobalBounds 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.ImageCompression @@ -18,7 +20,7 @@ internal class ImageButtonMapper( webPImageCompression: ImageCompression = WebPImageCompression(), base64Serializer: Base64Serializer = Base64Serializer.Builder().build(), uniqueIdentifierGenerator: UniqueIdentifierGenerator = UniqueIdentifierGenerator -) : BaseWireframeMapper( +) : BaseWireframeMapper( webPImageCompression = webPImageCompression, base64Serializer = base64Serializer, uniqueIdentifierGenerator = uniqueIdentifierGenerator @@ -26,7 +28,7 @@ internal class ImageButtonMapper( override fun map( view: ImageButton, mappingContext: MappingContext - ): List { + ): List { val resources = view.resources val drawable = view.drawable?.constantState?.newDrawable(resources) val id = resolveChildDrawableUniqueIdentifier(view) @@ -39,22 +41,51 @@ internal class ImageButtonMapper( val (shapeStyle, border) = view.background?.resolveShapeStyleAndBorder(view.alpha) ?: (null to null) + val wireframes = mutableListOf() + + // if the drawable has no width/height then there's no point trying to get a bitmap + if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) { + val imageWireframe = resolveImageWireframe( + view, + id, + bounds, + shapeStyle, + border, + drawable + ) + wireframes.add(imageWireframe) + } + + return wireframes + } + + // region internal + + private fun resolveImageWireframe( + view: ImageButton, + id: Long, + bounds: GlobalBounds, + shapeStyle: MobileSegment.ShapeStyle?, + border: MobileSegment.ShapeBorder?, + drawable: Drawable + ): MobileSegment.Wireframe.ImageWireframe { val mimeType = getWebPMimeType() val displayMetrics = view.resources.displayMetrics val applicationContext = view.context.applicationContext - val imageWireframe = MobileSegment.Wireframe.ImageWireframe( - id = id, - x = bounds.x, - y = bounds.y, - width = bounds.width, - height = bounds.height, - shapeStyle = shapeStyle, - border = border, - base64 = "", - mimeType = mimeType, - isEmpty = true - ) + val imageWireframe = + MobileSegment.Wireframe.ImageWireframe( + id = id, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + shapeStyle = shapeStyle, + border = border, + base64 = "", + mimeType = mimeType, + isEmpty = true + ) @Suppress("ThreadSafety") // TODO REPLAY-1861 caller thread of .map is unknown? handleBitmap( @@ -64,6 +95,8 @@ internal class ImageButtonMapper( imageWireframe = imageWireframe ) - return listOf(imageWireframe) + return imageWireframe } + + // endregion } 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 f2729721a8..61951a125f 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 @@ -17,7 +17,7 @@ internal class BitmapWrapper { bitmapHeight: Int, config: Config ): Bitmap? { - @Suppress("SwallowedException", "TooGenericExceptionCaught") + @Suppress("SwallowedException") return try { Bitmap.createBitmap(displayMetrics, bitmapWidth, bitmapHeight, config) } catch (e: IllegalArgumentException) { 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 b13f659c5f..eba7da4340 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 @@ -13,17 +13,32 @@ import android.util.DisplayMetrics import androidx.annotation.MainThread import com.datadog.android.sessionreplay.internal.recorder.wrappers.BitmapWrapper import com.datadog.android.sessionreplay.internal.recorder.wrappers.CanvasWrapper +import kotlin.math.sqrt internal class DrawableUtils( private val bitmapWrapper: BitmapWrapper = BitmapWrapper(), private val canvasWrapper: CanvasWrapper = CanvasWrapper() ) { + /** + * 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 createBitmapFromDrawable(drawable: Drawable, displayMetrics: DisplayMetrics): Bitmap? { - val bitmapWidth = if (drawable.intrinsicWidth <= 0) 1 else drawable.intrinsicWidth - val bitmapHeight = if (drawable.intrinsicHeight <= 0) 1 else drawable.intrinsicHeight - val bitmap = bitmapWrapper.createBitmap(displayMetrics, bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888) + internal fun createBitmapOfApproxSizeFromDrawable( + drawable: Drawable, + displayMetrics: DisplayMetrics, + requestedSizeInBytes: Int = MAX_BITMAP_SIZE_IN_BYTES + ): Bitmap? { + val (width, height) = getScaledWidthAndHeight(drawable, requestedSizeInBytes) + + val bitmap = bitmapWrapper.createBitmap( + displayMetrics, + width, + height, + Bitmap.Config.ARGB_8888 + ) ?: return null val canvas = canvasWrapper.createCanvas(bitmap) ?: return null @@ -31,4 +46,34 @@ internal class DrawableUtils( drawable.draw(canvas) return bitmap } + + private fun getScaledWidthAndHeight( + drawable: Drawable, + requestedSizeInBytes: Int + ): Pair { + var width = drawable.intrinsicWidth + var height = drawable.intrinsicHeight + val sizeAfterCreation = width * height * ARGB_8888_PIXEL_SIZE_BYTES + + if (sizeAfterCreation > requestedSizeInBytes) { + val bitmapRatio = width.toDouble() / height.toDouble() + val totalMaxPixels = (requestedSizeInBytes / ARGB_8888_PIXEL_SIZE_BYTES).toDouble() + val maxSize = sqrt(totalMaxPixels).toInt() + width = maxSize + height = maxSize + + if (bitmapRatio > 1) { // width gt height + height = (maxSize / bitmapRatio).toInt() + } else { + width = (maxSize * bitmapRatio).toInt() + } + } + + return Pair(width, height) + } + + internal companion object { + internal const val MAX_BITMAP_SIZE_IN_BYTES = 10240 // 10kb + private const val ARGB_8888_PIXEL_SIZE_BYTES = 4 + } } 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 b79ba80ee2..8b038921ef 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,7 +13,6 @@ import android.graphics.drawable.StateListDrawable import android.util.DisplayMetrics import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.AsyncImageProcessingCallback -import com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer.Companion.BITMAP_SIZE_LIMIT_BYTES import com.datadog.android.sessionreplay.internal.utils.Base64Utils import com.datadog.android.sessionreplay.internal.utils.DrawableUtils import com.datadog.android.sessionreplay.model.MobileSegment @@ -30,6 +29,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -95,10 +95,17 @@ internal class Base64SerializerTest { fakeImageWireframe.isEmpty = true val fakeByteOutputStream: ByteArrayOutputStream = mock() - whenever(mockWebPImageCompression.compressBitmapToStream(any())).thenReturn(fakeByteOutputStream) + whenever(mockWebPImageCompression.compressBitmapToStream(any())) + .thenReturn(fakeByteOutputStream) whenever(mockBase64Utils.serializeToBase64String(any())).thenReturn(fakeBase64String) - whenever(mockDrawableUtils.createBitmapFromDrawable(any(), any())).thenReturn(mockBitmap) + whenever( + mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( + any(), + any(), + anyOrNull() + ) + ).thenReturn(mockBitmap) whenever(mockExecutorService.submit(any())).then { (it.arguments[0] as Runnable).run() @@ -127,7 +134,8 @@ internal class Base64SerializerTest { @Test fun `M callback with finishProcessingImage W handleBitmap() { failed to create bmp }`() { // Given - whenever(mockDrawableUtils.createBitmapFromDrawable(any(), any())).thenReturn(null) + whenever(mockDrawableUtils.createBitmapOfApproxSizeFromDrawable(any(), any(), anyOrNull())) + .thenReturn(null) // When testedBase64Serializer.handleBitmap( @@ -143,6 +151,10 @@ internal class Base64SerializerTest { @Test fun `M callback with finishProcessingImage W handleBitmap() { created bmp async }`() { + // Given + whenever(mockDrawableUtils.createBitmapOfApproxSizeFromDrawable(any(), any(), anyOrNull())) + .thenReturn(mockBitmap) + // When testedBase64Serializer.handleBitmap( applicationContext = mockApplicationContext, @@ -163,6 +175,12 @@ internal class Base64SerializerTest { val fakeBase64String = forge.anAsciiString() whenever(mockBase64LruCache.get(mockDrawable)).thenReturn(fakeBase64String) + whenever(mockDrawableUtils.createBitmapOfApproxSizeFromDrawable(any(), any(), anyOrNull())) + .thenReturn(mockBitmap) + val mockByteArrayOutputStream: ByteArrayOutputStream = mock() + whenever(mockWebPImageCompression.compressBitmapToStream(any())) + .thenReturn(mockByteArrayOutputStream) + // When testedBase64Serializer.handleBitmap( applicationContext = mockApplicationContext, @@ -189,7 +207,7 @@ internal class Base64SerializerTest { ) // Then - verify(mockDrawableUtils).createBitmapFromDrawable(any(), any()) + verify(mockDrawableUtils).createBitmapOfApproxSizeFromDrawable(any(), any(), anyOrNull()) } @Test @@ -218,28 +236,6 @@ internal class Base64SerializerTest { verify(mockApplicationContext, times(1)).registerComponentCallbacks(mockBase64LruCache) } - @Test - fun `M return empty base64 string W handleBitmap() { image over size limit }`() { - // Given - val mockByteArrayOutputStream: ByteArrayOutputStream = mock() - whenever(mockByteArrayOutputStream.size()).thenReturn(BITMAP_SIZE_LIMIT_BYTES + 1) - whenever(mockWebPImageCompression.compressBitmapToStream(any())).thenReturn(mockByteArrayOutputStream) - whenever(mockDrawableUtils.createBitmapFromDrawable(any(), any())).thenReturn(mockBitmap) - - // When - testedBase64Serializer.handleBitmap( - applicationContext = mockApplicationContext, - displayMetrics = mockDisplayMetrics, - drawable = mockDrawable, - imageWireframe = fakeImageWireframe - ) - - // Then - assertThat(fakeImageWireframe.base64).isEmpty() - assertThat(fakeImageWireframe.isEmpty).isTrue() - verify(mockCallback).finishProcessingImage() - } - @Test fun `M use the same ThreadPoolExecutor W build()`() { // When 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/ImageButtonMapperTest.kt index f1e7764286..1c7eafffcc 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/ImageButtonMapperTest.kt @@ -33,6 +33,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.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -101,6 +103,9 @@ internal class ImageButtonMapperTest { whenever(mockDrawable.constantState).thenReturn(mockConstantState) whenever(mockImageButton.drawable).thenReturn(mockDrawable) + whenever(mockDrawable.intrinsicWidth).thenReturn(forge.aPositiveInt()) + whenever(mockDrawable.intrinsicHeight).thenReturn(forge.aPositiveInt()) + whenever(mockWebPImageCompression.getMimeType()).thenReturn(fakeMimeType) whenever(mockSystemInformation.screenDensity).thenReturn(forge.aFloat()) @@ -130,10 +135,10 @@ internal class ImageButtonMapperTest { .thenReturn(null) // When - val result = testedMapper.map(mockImageButton, mockMappingContext) + val wireframes = testedMapper.map(mockImageButton, mockMappingContext) // Then - assertThat(result).isEmpty() + assertThat(wireframes).isEmpty() } @Test @@ -142,10 +147,48 @@ internal class ImageButtonMapperTest { whenever(mockImageButton.drawable).thenReturn(null) // When - val result = testedMapper.map(mockImageButton, mockMappingContext) + val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + + // Then + assertThat(wireframes).isEmpty() + } + + @Test + fun `M return emptylist W map() { drawable has no intrinsicWidth }`() { + // Given + whenever(mockDrawable.intrinsicWidth).thenReturn(-1) + + // When + val wireframes = testedMapper.map(mockImageButton, mockMappingContext) // Then - assertThat(result).isEmpty() + assertThat(wireframes).isEmpty() + } + + @Test + fun `M return emptylist W map() { drawable has no intrinsicHeight }`() { + // Given + whenever(mockDrawable.intrinsicHeight).thenReturn(-1) + + // When + val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + + // Then + assertThat(wireframes).isEmpty() + } + + @Test + fun `M set null shapestyle and border W map() { view without background }`() { + // Given + whenever(mockImageButton.background).thenReturn(null) + + // When + val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + val actualWireframe = wireframes[0] as? MobileSegment.Wireframe.ShapeWireframe + + // Then + assertThat(actualWireframe?.shapeStyle).isNull() + assertThat(actualWireframe?.border).isNull() } @Test @@ -165,10 +208,22 @@ internal class ImageButtonMapperTest { ) // When - val result = testedMapper.map(mockImageButton, mockMappingContext) + val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + val actualWireframe = wireframes[0] + + // Then + assertThat(actualWireframe).isEqualTo(expectedWireframe) + verify(mockBase64Serializer, times(1)) + .handleBitmap(any(), any(), any(), any()) + } + + @Test + fun `M call handleBitmap W map()`() { + // When + testedMapper.map(mockImageButton, mockMappingContext) // Then - val wireframe = result[0] - assertThat(wireframe).isEqualTo(expectedWireframe) + verify(mockBase64Serializer, times(1)) + .handleBitmap(any(), any(), any(), any()) } } 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 9dd48ab81b..5867db5772 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 @@ -7,10 +7,13 @@ package com.datadog.android.sessionreplay.internal.utils import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.drawable.Drawable import android.util.DisplayMetrics import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.recorder.wrappers.BitmapWrapper +import com.datadog.android.sessionreplay.internal.recorder.wrappers.CanvasWrapper +import com.datadog.android.sessionreplay.internal.utils.DrawableUtils.Companion.MAX_BITMAP_SIZE_IN_BYTES import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -46,85 +49,147 @@ internal class DrawableUtilsTest { private lateinit var mockBitmapWrapper: BitmapWrapper @Mock - private lateinit var fakeBitmap: Bitmap + private lateinit var mockCanvasWrapper: CanvasWrapper + + @Mock + private lateinit var mockBitmap: Bitmap + + @Mock + private lateinit var mockCanvas: Canvas @BeforeEach fun setup() { whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), any())) - .thenReturn(fakeBitmap) - testedDrawableUtils = DrawableUtils(mockBitmapWrapper) + .thenReturn(mockBitmap) + whenever(mockBitmap.byteCount).thenReturn(MAX_BITMAP_SIZE_IN_BYTES + 1) + whenever(mockCanvasWrapper.createCanvas(mockBitmap)) + .thenReturn(mockCanvas) + testedDrawableUtils = DrawableUtils( + bitmapWrapper = mockBitmapWrapper, + canvasWrapper = mockCanvasWrapper + ) } + // region createBitmap + @Test - fun `M set width to 1 W createBitmapFromDrawable() { with width 0 }`() { + fun `M set width to drawable intrinsic W createBitmapFromDrawableOfApproxSize() { no resizing }`() { // Given - whenever(mockDrawable.intrinsicWidth).thenReturn(0) - whenever(mockDrawable.intrinsicHeight).thenReturn(0) + val requestedSize = 1000 + val edge = 10 + whenever(mockDrawable.intrinsicWidth).thenReturn(edge) + whenever(mockDrawable.intrinsicHeight).thenReturn(edge) - val boundsCaptor = argumentCaptor() + val argumentCaptor = argumentCaptor() val displayMetricsCaptor = argumentCaptor() // When - val result = testedDrawableUtils.createBitmapFromDrawable(mockDrawable, mockDisplayMetrics) + testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + mockDrawable, + mockDisplayMetrics, + requestedSize + ) // Then verify(mockBitmapWrapper).createBitmap( displayMetrics = displayMetricsCaptor.capture(), - bitmapWidth = boundsCaptor.capture(), - bitmapHeight = boundsCaptor.capture(), + bitmapWidth = argumentCaptor.capture(), + bitmapHeight = argumentCaptor.capture(), config = any() ) - boundsCaptor.allValues.forEach { - assertThat(it).isEqualTo(1) - } + val width = argumentCaptor.firstValue + val height = argumentCaptor.secondValue + assertThat(width).isEqualTo(edge) + assertThat(height).isEqualTo(edge) + } + + @Test + fun `M set height higher W createBitmapFromDrawableOfApproxSize() { when resizing }`() { + // Given + whenever(mockDrawable.intrinsicWidth).thenReturn(900) + whenever(mockDrawable.intrinsicHeight).thenReturn(1000) - assertThat(displayMetricsCaptor.firstValue).isEqualTo(mockDisplayMetrics) + val argumentCaptor = argumentCaptor() + val displayMetricsCaptor = argumentCaptor() + + // When + testedDrawableUtils + .createBitmapOfApproxSizeFromDrawable(mockDrawable, mockDisplayMetrics) + + // Then + verify(mockBitmapWrapper).createBitmap( + displayMetrics = displayMetricsCaptor.capture(), + bitmapWidth = argumentCaptor.capture(), + bitmapHeight = argumentCaptor.capture(), + config = any() + ) - assertThat(result).isEqualTo(fakeBitmap) + val width = argumentCaptor.firstValue + val height = argumentCaptor.secondValue + assertThat(height).isGreaterThanOrEqualTo(width) } @Test - fun `M set width to drawable intrinsic W createBitmapFromDrawable()`() { + fun `M set width higher W createBitmapFromDrawableOfApproxSize() { when resizing }`() { // Given - whenever(mockDrawable.intrinsicWidth).thenReturn(200) - whenever(mockDrawable.intrinsicHeight).thenReturn(200) + whenever(mockDrawable.intrinsicWidth).thenReturn(1000) + whenever(mockDrawable.intrinsicHeight).thenReturn(900) - val boundsCaptor = argumentCaptor() + val argumentCaptor = argumentCaptor() val displayMetricsCaptor = argumentCaptor() // When - val result = testedDrawableUtils.createBitmapFromDrawable(mockDrawable, mockDisplayMetrics) + val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable(mockDrawable, mockDisplayMetrics) // Then verify(mockBitmapWrapper).createBitmap( displayMetrics = displayMetricsCaptor.capture(), - bitmapWidth = boundsCaptor.capture(), - bitmapHeight = boundsCaptor.capture(), + bitmapWidth = argumentCaptor.capture(), + bitmapHeight = argumentCaptor.capture(), config = any() ) - boundsCaptor.allValues.forEach { - assertThat(it).isEqualTo(200) - } + val width = argumentCaptor.firstValue + val height = argumentCaptor.secondValue + assertThat(width).isGreaterThanOrEqualTo(height) assertThat(displayMetricsCaptor.firstValue).isEqualTo(mockDisplayMetrics) - assertThat(result).isEqualTo(fakeBitmap) + assertThat(result).isEqualTo(mockBitmap) } @Test - fun `M return null W createBitmapFromDrawable() { failed to create bmp }`() { + fun `M return null W createBitmapFromDrawableOfApproxSize() { failed to create bmp }`() { // Given - whenever(mockDrawable.intrinsicWidth).thenReturn(200) - whenever(mockDrawable.intrinsicHeight).thenReturn(200) + 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.createBitmapFromDrawable(mockDrawable, mockDisplayMetrics) + val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable(mockDrawable, mockDisplayMetrics) + + // 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, mockDisplayMetrics) // Then assertThat(result).isNull() } + + // endregion } diff --git a/sample/kotlin/src/main/res/drawable/ic_dd_shape_oval.xml b/sample/kotlin/src/main/res/drawable/ic_dd_shape_oval.xml new file mode 100644 index 0000000000..da83159d60 --- /dev/null +++ b/sample/kotlin/src/main/res/drawable/ic_dd_shape_oval.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/sample/kotlin/src/main/res/drawable/ic_dd_shape_rect.xml b/sample/kotlin/src/main/res/drawable/ic_dd_shape_rect.xml new file mode 100644 index 0000000000..97797206e5 --- /dev/null +++ b/sample/kotlin/src/main/res/drawable/ic_dd_shape_rect.xml @@ -0,0 +1,7 @@ + + + + + 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 283797abcd..2ac342eb20 100644 --- a/sample/kotlin/src/main/res/layout/fragment_image_components.xml +++ b/sample/kotlin/src/main/res/layout/fragment_image_components.xml @@ -210,6 +210,29 @@ + + + + + + + + Date: Mon, 24 Jul 2023 16:24:46 +0300 Subject: [PATCH 4/8] Implement pool of reusable bitmaps --- .../api/apiSurface | 4 +- .../api/dd-sdk-android-session-replay.api | 6 +- .../build.gradle.kts | 5 + .../async/RecordedDataQueueHandler.kt | 17 +- .../recorder/base64/Base64LRUCache.kt | 42 +-- .../recorder/base64/Base64Serializer.kt | 34 ++- .../internal/recorder/base64/BitmapPool.kt | 191 +++++++++++++ .../internal/recorder/base64/Cache.kt | 11 +- .../recorder/base64/ImageCompression.kt | 2 +- .../recorder/base64/WebPImageCompression.kt | 10 +- .../internal/utils/Base64Utils.kt | 5 +- .../internal/utils/CacheUtils.kt | 52 ++++ .../internal/utils/DrawableUtils.kt | 54 +++- .../internal/utils/InvocationUtils.kt | 33 +++ .../async/RecordedDataQueueHandlerTest.kt | 52 +--- .../recorder/base64/Base64LRUCacheTest.kt | 4 +- .../recorder/base64/Base64SerializerTest.kt | 107 ++++--- .../recorder/base64/BitmapPoolTest.kt | 261 ++++++++++++++++++ .../base64/WebPImageCompressionTest.kt | 4 +- .../internal/utils/DrawableUtilsTest.kt | 94 ++++++- .../internal/utils/InvocationUtilsTest.kt | 111 ++++++++ 21 files changed, 904 insertions(+), 195 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPool.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtils.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtilsTest.kt diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index c2a2673809..9588785458 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -25,10 +25,10 @@ data class com.datadog.android.sessionreplay.internal.recorder.SystemInformation class com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer interface com.datadog.android.sessionreplay.internal.recorder.base64.ImageCompression fun getMimeType(): String? - fun compressBitmapToStream(android.graphics.Bitmap): java.io.ByteArrayOutputStream + fun compressBitmap(android.graphics.Bitmap): ByteArray class com.datadog.android.sessionreplay.internal.recorder.base64.WebPImageCompression : ImageCompression override fun getMimeType(): String? - override fun compressBitmapToStream(android.graphics.Bitmap): java.io.ByteArrayOutputStream + override fun compressBitmap(android.graphics.Bitmap): ByteArray companion object abstract class com.datadog.android.sessionreplay.internal.recorder.mapper.BaseWireframeMapper : WireframeMapper, com.datadog.android.sessionreplay.internal.AsyncImageProcessingCallback constructor(com.datadog.android.sessionreplay.utils.StringUtils = StringUtils, com.datadog.android.sessionreplay.utils.ViewUtils = ViewUtils, com.datadog.android.sessionreplay.internal.recorder.base64.ImageCompression = WebPImageCompression(), com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator = UniqueIdentifierGenerator, com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer = Base64Serializer.Builder().build()) diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index c3e2458f23..3e071b0568 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -88,17 +88,17 @@ public final class com/datadog/android/sessionreplay/internal/recorder/SystemInf } public final class com/datadog/android/sessionreplay/internal/recorder/base64/Base64Serializer { - public synthetic fun (Ljava/util/concurrent/ExecutorService;Lcom/datadog/android/sessionreplay/internal/utils/DrawableUtils;Lcom/datadog/android/sessionreplay/internal/utils/Base64Utils;Lcom/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression;Lcom/datadog/android/sessionreplay/internal/recorder/base64/Cache;Lcom/datadog/android/api/InternalLogger;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/util/concurrent/ExecutorService;Lcom/datadog/android/sessionreplay/internal/utils/DrawableUtils;Lcom/datadog/android/sessionreplay/internal/utils/Base64Utils;Lcom/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression;Lcom/datadog/android/sessionreplay/internal/recorder/base64/Cache;Lcom/datadog/android/sessionreplay/internal/recorder/base64/Cache;Lcom/datadog/android/api/InternalLogger;Lkotlin/jvm/internal/DefaultConstructorMarker;)V } public abstract interface class com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression { - public abstract fun compressBitmapToStream (Landroid/graphics/Bitmap;)Ljava/io/ByteArrayOutputStream; + public abstract fun compressBitmap (Landroid/graphics/Bitmap;)[B public abstract fun getMimeType ()Ljava/lang/String; } public final class com/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompression : com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression { public static final field Companion Lcom/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompression$Companion; - public fun compressBitmapToStream (Landroid/graphics/Bitmap;)Ljava/io/ByteArrayOutputStream; + public fun compressBitmap (Landroid/graphics/Bitmap;)[B public fun getMimeType ()Ljava/lang/String; } diff --git a/features/dd-sdk-android-session-replay/build.gradle.kts b/features/dd-sdk-android-session-replay/build.gradle.kts index eca47e63ae..b82ee6938a 100644 --- a/features/dd-sdk-android-session-replay/build.gradle.kts +++ b/features/dd-sdk-android-session-replay/build.gradle.kts @@ -27,6 +27,7 @@ plugins { id("com.github.ben-manes.versions") // Tests + id("de.mobilej.unmock") id("org.jetbrains.kotlinx.kover") // Internal Generation @@ -111,6 +112,10 @@ dependencies { apply(from = "clone_session_replay_schema.gradle.kts") apply(from = "generate_session_replay_models.gradle.kts") +unMock { + keep("android.util.LruCache") +} + kotlinConfig(jvmBytecodeTarget = JvmTarget.JVM_11) junitConfig() javadocConfig() diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandler.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandler.kt index 868b33c8ce..e3954d91e8 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandler.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandler.kt @@ -6,15 +6,12 @@ package com.datadog.android.sessionreplay.internal.async -import android.graphics.drawable.Drawable import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import com.datadog.android.sessionreplay.internal.processor.RecordedDataProcessor import com.datadog.android.sessionreplay.internal.processor.RumContextDataHandler import com.datadog.android.sessionreplay.internal.recorder.SystemInformation -import com.datadog.android.sessionreplay.internal.recorder.base64.Base64LRUCache -import com.datadog.android.sessionreplay.internal.recorder.base64.Cache import com.datadog.android.sessionreplay.internal.utils.TimeProvider import com.datadog.android.sessionreplay.model.MobileSegment import java.lang.ClassCastException @@ -37,18 +34,15 @@ internal class RecordedDataQueueHandler { private var processor: RecordedDataProcessor private var rumContextDataHandler: RumContextDataHandler private var timeProvider: TimeProvider - private var cache: Cache internal constructor( processor: RecordedDataProcessor, rumContextDataHandler: RumContextDataHandler, - timeProvider: TimeProvider, - cache: Cache = Base64LRUCache + timeProvider: TimeProvider ) : this( processor = processor, rumContextDataHandler = rumContextDataHandler, timeProvider = timeProvider, - cache = cache, /** * TODO: RUMM-0000 consider change to LoggingThreadPoolExecutor once V2 is merged. @@ -70,14 +64,12 @@ internal class RecordedDataQueueHandler { processor: RecordedDataProcessor, rumContextDataHandler: RumContextDataHandler, timeProvider: TimeProvider, - executorService: ExecutorService, - cache: Cache + executorService: ExecutorService ) { this.processor = processor this.rumContextDataHandler = rumContextDataHandler this.executorService = executorService this.timeProvider = timeProvider - this.cache = cache } // region internal @@ -107,11 +99,6 @@ internal class RecordedDataQueueHandler { val rumContextData = rumContextDataHandler.createRumContextData() ?: return null - // if the view changed then clear the drawable cache - if (rumContextData.prevRumContext != rumContextData.newRumContext) { - cache.clear() - } - val item = SnapshotRecordedDataQueueItem( rumContextData = rumContextData, systemInformation = systemInformation 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 7c52e85a1d..e10129607f 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,14 +14,11 @@ import android.graphics.drawable.DrawableContainer import android.graphics.drawable.LayerDrawable import android.util.LruCache import androidx.annotation.VisibleForTesting +import com.datadog.android.sessionreplay.internal.utils.CacheUtils internal object Base64LRUCache : Cache, ComponentCallbacks2 { @Suppress("MagicNumber") - val MAX_CACHE_MEMORY_SIZE_BYTES = 4 * 1024 * 1024 // 4MB - @Suppress("MagicNumber") - private val ON_LOW_MEMORY_SIZE_BYTES = MAX_CACHE_MEMORY_SIZE_BYTES / 2 // 50% size - @Suppress("MagicNumber") - private val ON_MODERATE_MEMORY_SIZE_BYTES = (MAX_CACHE_MEMORY_SIZE_BYTES / 4) * 3 // 75% size + private val MAX_CACHE_MEMORY_SIZE_BYTES = 4 * 1024 * 1024 // 4MB private var cache: LruCache = object : LruCache(MAX_CACHE_MEMORY_SIZE_BYTES) { @@ -31,39 +28,8 @@ internal object Base64LRUCache : Cache, ComponentCallbacks2 { } override fun onTrimMemory(level: Int) { - when (level) { - ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> { - cache.evictAll() - } - - ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> { - cache.evictAll() - } - - ComponentCallbacks2.TRIM_MEMORY_MODERATE -> { - cache.trimToSize(ON_MODERATE_MEMORY_SIZE_BYTES) - } - - ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { - cache.evictAll() - } - - ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> { - cache.trimToSize(ON_LOW_MEMORY_SIZE_BYTES) - } - - ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE -> { - cache.trimToSize(ON_MODERATE_MEMORY_SIZE_BYTES) - } - - ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> { - cache.evictAll() - } - - else -> { - cache.evictAll() - } - } + val cacheUtils = CacheUtils() + cacheUtils.handleTrimMemory(level, cache) } override fun onConfigurationChanged(newConfig: Configuration) {} 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 1c200e990a..d8b7e9332e 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 @@ -16,6 +16,7 @@ import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import com.datadog.android.api.InternalLogger 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 @@ -32,6 +33,7 @@ class Base64Serializer private constructor( private val base64Utils: Base64Utils, private val webPImageCompression: ImageCompression, private val base64LruCache: Cache, + private val bitmapPool: Cache, private val logger: InternalLogger ) { private var asyncImageProcessingCallback: AsyncImageProcessingCallback? = null @@ -55,7 +57,11 @@ class Base64Serializer private constructor( return } - val bitmap = drawableUtils.createBitmapOfApproxSizeFromDrawable(drawable, displayMetrics) + val bitmap = drawableUtils.createBitmapOfApproxSizeFromDrawable( + applicationContext, + drawable, + displayMetrics + ) if (bitmap == null) { asyncImageProcessingCallback?.finishProcessingImage() @@ -118,19 +124,17 @@ class Base64Serializer private constructor( private fun convertBmpToBase64(drawable: Drawable, bitmap: Bitmap): String { val base64Result: String - val byteArrayOutputStream = webPImageCompression.compressBitmapToStream(bitmap) + val byteArray = webPImageCompression.compressBitmap(bitmap) - try { - base64Result = base64Utils.serializeToBase64String(byteArrayOutputStream) - - if (base64Result.isNotEmpty()) { - // if we got a base64 string then cache it - base64LruCache.put(drawable, base64Result) - } - } finally { - bitmap.recycle() + base64Result = base64Utils.serializeToBase64String(byteArray) + + if (base64Result.isNotEmpty()) { + // if we got a base64 string then cache it + base64LruCache.put(drawable, base64Result) } + bitmapPool.put(bitmap) + return base64Result } @@ -169,6 +173,7 @@ class Base64Serializer private constructor( base64Utils: Base64Utils = Base64Utils(), webPImageCompression: ImageCompression = WebPImageCompression(), base64LruCache: Cache = Base64LRUCache, + bitmapPool: Cache = BitmapPool, // Temporarily use UNBOUND until we handle the loggers logger: InternalLogger = InternalLogger.UNBOUND ) = @@ -178,6 +183,7 @@ class Base64Serializer private constructor( base64Utils = base64Utils, webPImageCompression = webPImageCompression, base64LruCache = base64LruCache, + bitmapPool = bitmapPool, logger = logger ) @@ -201,11 +207,9 @@ class Base64Serializer private constructor( // endregion internal companion object { - internal const val DOES_NOT_IMPLEMENT_COMPONENTCALLBACKS = - "Cache instance does not implement ComponentCallbacks2" - // The cache is a singleton, so we want to share this flag among // all instances so that it's registered only once - private var isCacheRegisteredForCallbacks: Boolean = false + @VisibleForTesting + internal var isCacheRegisteredForCallbacks: Boolean = false } } 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 new file mode 100644 index 0000000000..39fb44bca0 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPool.kt @@ -0,0 +1,191 @@ +/* + * 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.base64 + +import android.content.ComponentCallbacks2 +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Bitmap.Config +import android.os.Build +import android.util.LruCache +import androidx.annotation.VisibleForTesting +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.internal.utils.CacheUtils +import com.datadog.android.sessionreplay.internal.utils.InvocationUtils +import java.util.concurrent.atomic.AtomicInteger + +@Suppress("TooManyFunctions") +internal object BitmapPool : Cache, ComponentCallbacks2 { + private const val BITMAP_OPERATION_FAILED = "operation failed for bitmap pool" + + @VisibleForTesting + @Suppress("MagicNumber") + internal val MAX_CACHE_MEMORY_SIZE_BYTES = 4 * 1024 * 1024 // 4MB + + private var bitmapsBySize = HashMap>() + private var usedBitmaps = HashSet() + private val logger = InternalLogger.UNBOUND + private val invocationUtils = InvocationUtils() + private var bitmapIndex = AtomicInteger(0) + + private var cache: LruCache = object : + LruCache(MAX_CACHE_MEMORY_SIZE_BYTES) { + override fun sizeOf(key: String?, bitmap: Bitmap): Int { + return bitmap.allocationByteCount + } + + override fun entryRemoved( + evicted: Boolean, + key: String?, + oldValue: Bitmap?, + newValue: Bitmap? + ) { + super.entryRemoved(evicted, key, oldValue, newValue) + + if (oldValue != null) { + val dimensionsKey = generateKey(oldValue) + val bitmapGroup = bitmapsBySize[dimensionsKey] ?: HashSet() + + invocationUtils.safeCallWithErrorLogging( + logger = logger, + call = { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + bitmapGroup.remove(oldValue) + }, + failureMessage = BITMAP_OPERATION_FAILED + ) + markBitmapAsFree(oldValue) + oldValue.recycle() + } + } + } + + @VisibleForTesting + internal fun setBitmapsBySize(bitmaps: HashMap>) { + this.bitmapsBySize = bitmaps + } + + @VisibleForTesting + internal fun setBackingCache(cache: LruCache) { + this.cache = cache + } + + @VisibleForTesting + internal fun setUsedBitmaps(usedBitmaps: HashSet) { + this.usedBitmaps = usedBitmaps + } + + @Synchronized + override fun put(value: Bitmap) { + // don't allow immutable or recycled bitmaps in the pool + if (!value.isMutable || value.isRecycled) { + return + } + + val key = generateKey(value) + + val bitmapExistsInPool = invocationUtils.safeCallWithErrorLogging( + logger = logger, + call = { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + bitmapsBySize[key]?.contains(value) ?: false + }, + failureMessage = BITMAP_OPERATION_FAILED + ) ?: false + + if (!bitmapExistsInPool) { + addBitmapToPool(key, value) + } + + markBitmapAsFree(value) + } + + override fun size(): Int = cache.size() + + @Synchronized + override fun clear() = cache.evictAll() + + @Synchronized + override fun get(element: String): Bitmap? { + val bitmapsWithReqDimensions = bitmapsBySize[element] ?: return null + + // find the first unused bitmap, mark it as used and return it + return bitmapsWithReqDimensions.find { + invocationUtils.safeCallWithErrorLogging( + logger = logger, + call = { !usedBitmaps.contains(it) }, + failureMessage = BITMAP_OPERATION_FAILED + ) ?: false + }?.apply { markBitmapAsUsed(this) } + } + + internal fun getBitmapByProperties(width: Int, height: Int, config: Config): Bitmap? { + val key = generateKey(width, height, config) + return get(key) + } + + private fun markBitmapAsFree(bitmap: Bitmap) { + invocationUtils.safeCallWithErrorLogging( + logger = logger, + call = { + usedBitmaps.remove(bitmap) + }, + failureMessage = BITMAP_OPERATION_FAILED + ) + } + + private fun markBitmapAsUsed(bitmap: Bitmap) { + invocationUtils.safeCallWithErrorLogging( + logger = logger, + call = { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + usedBitmaps.add(bitmap) + }, + failureMessage = BITMAP_OPERATION_FAILED + ) + } + + private fun addBitmapToPool(key: String, bitmap: Bitmap) { + val cacheIndex = bitmapIndex.incrementAndGet() + val cacheKey = "$key-$cacheIndex" + cache.put(cacheKey, bitmap) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + invocationUtils.safeCallWithErrorLogging( + logger = logger, + call = { bitmapsBySize.putIfAbsent(key, HashSet()) }, + failureMessage = BITMAP_OPERATION_FAILED + ) + } else { + if (bitmapsBySize[key] == null) bitmapsBySize[key] = HashSet() + } + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + invocationUtils.safeCallWithErrorLogging( + logger = logger, + call = { bitmapsBySize[key]?.add(bitmap) }, + failureMessage = BITMAP_OPERATION_FAILED + ) + } + + private fun generateKey(bitmap: Bitmap) = + generateKey(bitmap.width, bitmap.height, bitmap.config) + + private fun generateKey(width: Int, height: Int, config: Config) = + "$width-$height-$config" + + override fun onConfigurationChanged(newConfig: Configuration) {} + + override fun onLowMemory() { + cache.evictAll() + } + + override fun onTrimMemory(level: Int) { + val cacheUtils = CacheUtils() + cacheUtils.handleTrimMemory(level, cache) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Cache.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Cache.kt index 43e87328c6..f4dd7cc8a7 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Cache.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/Cache.kt @@ -7,8 +7,15 @@ package com.datadog.android.sessionreplay.internal.recorder.base64 internal interface Cache { - fun put(element: K, value: V) - fun get(element: K): V? + + fun put(value: V) {} + fun put(element: K, value: V) {} + fun get(element: K): V? = null fun size(): Int fun clear() + + companion object { + internal const val DOES_NOT_IMPLEMENT_COMPONENTCALLBACKS = + "Cache instance does not implement ComponentCallbacks2" + } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression.kt index a68b5a8887..e3f2e1c32e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression.kt @@ -22,5 +22,5 @@ interface ImageCompression { /** * Compress the bitmap to a [ByteArrayOutputStream]. */ - fun compressBitmapToStream(bitmap: Bitmap): ByteArrayOutputStream + fun compressBitmap(bitmap: Bitmap): ByteArray } 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 b754dbe492..44529019d1 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 @@ -21,13 +21,13 @@ class WebPImageCompression internal constructor() : ImageCompression { MimeTypeMap.getSingleton().getMimeTypeFromExtension(WEBP_EXTENSION) @WorkerThread - override fun compressBitmapToStream(bitmap: Bitmap): ByteArrayOutputStream { - val byteArrayOutputStream = ByteArrayOutputStream() + override fun compressBitmap(bitmap: Bitmap): ByteArray { + // preallocate stream size + val byteArrayOutputStream = ByteArrayOutputStream(bitmap.allocationByteCount) val imageFormat = getImageCompressionFormat() - // stream is not null and image quality is between 0 and 100 - @Suppress("UnsafeThirdPartyFunctionCall") + @Suppress("UnsafeThirdPartyFunctionCall") // stream is not null and image quality is between 0 and 100 bitmap.compress(imageFormat, IMAGE_QUALITY, byteArrayOutputStream) - return byteArrayOutputStream + return byteArrayOutputStream.toByteArray() } private fun getImageCompressionFormat(): Bitmap.CompressFormat = diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/Base64Utils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/Base64Utils.kt index fc41ea395e..1e3ba1d1a2 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/Base64Utils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/Base64Utils.kt @@ -9,12 +9,11 @@ package com.datadog.android.sessionreplay.internal.utils import android.util.Base64 import androidx.annotation.WorkerThread import com.datadog.android.sessionreplay.internal.recorder.wrappers.Base64Wrapper -import java.io.ByteArrayOutputStream internal class Base64Utils( private val base64Wrapper: Base64Wrapper = Base64Wrapper() ) { @WorkerThread - internal fun serializeToBase64String(byteArrayOutputStream: ByteArrayOutputStream): String = - base64Wrapper.encodeToString(byteArrayOutputStream.toByteArray(), Base64.DEFAULT) + internal fun serializeToBase64String(byteArray: ByteArray): String = + base64Wrapper.encodeToString(byteArray, Base64.DEFAULT) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt new file mode 100644 index 0000000000..c979f2524d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt @@ -0,0 +1,52 @@ +/* + * 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.content.ComponentCallbacks2 +import android.util.LruCache + +internal class CacheUtils { + internal fun handleTrimMemory(level: Int, cache: LruCache) { + @Suppress("MagicNumber") + val onLowMemorySizeBytes = cache.maxSize() / 2 // 50% + + @Suppress("MagicNumber") + val onModerateMemorySizeBytes = (cache.maxSize() / 4) * 3 // 75% + + when (level) { + ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> { + cache.evictAll() + } + + ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> { + cache.evictAll() + } + + ComponentCallbacks2.TRIM_MEMORY_MODERATE -> { + cache.trimToSize(onModerateMemorySizeBytes) + } + + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { + cache.evictAll() + } + + ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> { + cache.trimToSize(onLowMemorySizeBytes) + } + + ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE -> { + cache.trimToSize(onModerateMemorySizeBytes) + } + + ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {} + + else -> { + cache.evictAll() + } + } + } +} 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 eba7da4340..2ee9ed6af0 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 @@ -7,17 +7,24 @@ package com.datadog.android.sessionreplay.internal.utils +import android.content.Context import android.graphics.Bitmap +import android.graphics.Bitmap.Config +import android.graphics.Color +import android.graphics.PorterDuff import android.graphics.drawable.Drawable import android.util.DisplayMetrics import androidx.annotation.MainThread +import androidx.annotation.VisibleForTesting +import com.datadog.android.sessionreplay.internal.recorder.base64.BitmapPool import com.datadog.android.sessionreplay.internal.recorder.wrappers.BitmapWrapper import com.datadog.android.sessionreplay.internal.recorder.wrappers.CanvasWrapper import kotlin.math.sqrt internal class DrawableUtils( private val bitmapWrapper: BitmapWrapper = BitmapWrapper(), - private val canvasWrapper: CanvasWrapper = CanvasWrapper() + private val canvasWrapper: CanvasWrapper = CanvasWrapper(), + private val bitmapPool: BitmapPool = BitmapPool ) { /** * This method attempts to create a bitmap from a drawable, such that the bitmap file size will @@ -27,21 +34,23 @@ internal class DrawableUtils( @MainThread @Suppress("ReturnCount") internal fun createBitmapOfApproxSizeFromDrawable( + applicationContext: Context, drawable: Drawable, displayMetrics: DisplayMetrics, - requestedSizeInBytes: Int = MAX_BITMAP_SIZE_IN_BYTES + requestedSizeInBytes: Int = MAX_BITMAP_SIZE_IN_BYTES, + config: Config = Config.ARGB_8888 ): Bitmap? { - val (width, height) = getScaledWidthAndHeight(drawable, requestedSizeInBytes) + registerBitmapPoolForCallbacks(applicationContext) - val bitmap = bitmapWrapper.createBitmap( - displayMetrics, - width, - height, - Bitmap.Config.ARGB_8888 - ) - ?: return null + val (width, height) = getScaledWidthAndHeight(drawable, 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 @@ -72,8 +81,31 @@ internal class DrawableUtils( return Pair(width, height) } + @Suppress("ReturnCount") + private fun getBitmapBySize( + displayMetrics: DisplayMetrics, + width: Int, + height: Int, + config: Config + ): Bitmap? = + bitmapPool.getBitmapByProperties(width, height, config) + ?: bitmapWrapper.createBitmap(displayMetrics, width, height, config) + + @MainThread + private fun registerBitmapPoolForCallbacks(applicationContext: Context) { + if (isBitmapPoolRegisteredForCallbacks) return + + applicationContext.registerComponentCallbacks(bitmapPool) + isBitmapPoolRegisteredForCallbacks = true + } + internal companion object { - internal const val MAX_BITMAP_SIZE_IN_BYTES = 10240 // 10kb + private const val MAX_BITMAP_SIZE_IN_BYTES = 10240 // 10kb private const val ARGB_8888_PIXEL_SIZE_BYTES = 4 + + // The cache is a singleton, so we want to share this flag among + // all instances so that it's registered only once + @VisibleForTesting + internal var isBitmapPoolRegisteredForCallbacks: Boolean = false } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtils.kt new file mode 100644 index 0000000000..324231ecc6 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtils.kt @@ -0,0 +1,33 @@ +/* + * 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 com.datadog.android.api.InternalLogger + +internal class InvocationUtils { + @Suppress("SwallowedException", "TooGenericExceptionCaught") + inline fun safeCallWithErrorLogging( + logger: InternalLogger, + call: () -> R, + failureMessage: String, + level: InternalLogger.Level = InternalLogger.Level.WARN, + target: InternalLogger.Target = InternalLogger.Target.MAINTAINER + ): R? { + try { + return call() + } catch (e: Exception) { + // TODO: REPLAY-1364 Add logs here once the sdkLogger is added + logger.log( + level, + target, + { failureMessage } + ) + } + + return null + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandlerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandlerTest.kt index 227cd072b4..a2e5a6dc6f 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandlerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/async/RecordedDataQueueHandlerTest.kt @@ -6,7 +6,6 @@ package com.datadog.android.sessionreplay.internal.async -import android.graphics.drawable.Drawable import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler.Companion.MAX_DELAY_MS import com.datadog.android.sessionreplay.internal.processor.RecordedDataProcessor @@ -14,7 +13,6 @@ import com.datadog.android.sessionreplay.internal.processor.RumContextData import com.datadog.android.sessionreplay.internal.processor.RumContextDataHandler import com.datadog.android.sessionreplay.internal.recorder.Node import com.datadog.android.sessionreplay.internal.recorder.SystemInformation -import com.datadog.android.sessionreplay.internal.recorder.base64.Cache import com.datadog.android.sessionreplay.internal.time.SessionReplayTimeProvider import com.datadog.android.sessionreplay.model.MobileSegment import fr.xgouchet.elmyr.Forge @@ -53,7 +51,7 @@ import java.util.concurrent.TimeUnit @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) internal class RecordedDataQueueHandlerTest { - lateinit var testedHandler: RecordedDataQueueHandler + private lateinit var testedHandler: RecordedDataQueueHandler @Mock lateinit var mockProcessor: RecordedDataProcessor @@ -61,7 +59,7 @@ internal class RecordedDataQueueHandlerTest { @Mock lateinit var mockRumContextDataHandler: RumContextDataHandler - lateinit var mockExecutorService: ExecutorService + private lateinit var mockExecutorService: ExecutorService @Mock lateinit var mockSystemInformation: SystemInformation @@ -69,9 +67,6 @@ internal class RecordedDataQueueHandlerTest { @Mock lateinit var mockTimeProvider: SessionReplayTimeProvider - @Mock - lateinit var mockBase64LruCache: Cache - @Forgery lateinit var fakeRumContextData: RumContextData @@ -111,7 +106,6 @@ internal class RecordedDataQueueHandlerTest { processor = mockProcessor, rumContextDataHandler = mockRumContextDataHandler, timeProvider = mockTimeProvider, - cache = mockBase64LruCache, executorService = mockExecutorService ) } @@ -240,7 +234,7 @@ internal class RecordedDataQueueHandlerTest { mockExecutorService.awaitTermination(1, TimeUnit.SECONDS) // Then - assertThat(testedHandler.recordedDataQueue.isEmpty()).isTrue() + assertThat(testedHandler.recordedDataQueue.isEmpty()).isTrue verifyNoMoreInteractions(mockProcessor) } @@ -447,46 +441,6 @@ internal class RecordedDataQueueHandlerTest { assertThat(testedHandler.recordedDataQueue.size).isEqualTo(2) } - // region addSnapshotItem - - @Test - fun `M clear cache W addSnapshotItem() { changed view }`() { - // Given - val rumContextData = RumContextData( - timestamp = System.currentTimeMillis(), - newRumContext = fakeRumContextData.newRumContext, - prevRumContext = fakeRumContextData.prevRumContext - ) - - whenever(mockRumContextDataHandler.createRumContextData()).thenReturn(rumContextData) - - // When - testedHandler.addSnapshotItem(mockSystemInformation) - - // Then - verify(mockBase64LruCache).clear() - } - - @Test - fun `M not clear cache W addSnapshotItem() { same view }`() { - // Given - val rumContextData = RumContextData( - timestamp = System.currentTimeMillis(), - newRumContext = fakeRumContextData.newRumContext, - prevRumContext = fakeRumContextData.newRumContext - ) - - whenever(mockRumContextDataHandler.createRumContextData()).thenReturn(rumContextData) - - // When - testedHandler.addSnapshotItem(mockSystemInformation) - - // Then - verifyNoMoreInteractions(mockBase64LruCache) - } - - // endregion - private fun createFakeSnapshotItemWithDelayMs(delay: Int): SnapshotRecordedDataQueueItem { val newRumContext = RumContextData( timestamp = System.currentTimeMillis() + delay, 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 110a9092a9..896929f77a 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 @@ -12,7 +12,6 @@ import android.graphics.drawable.RippleDrawable import android.graphics.drawable.StateListDrawable import android.util.LruCache import com.datadog.android.sessionreplay.forge.ForgeConfigurator -import com.datadog.tools.unit.extensions.ApiLevelExtension import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -34,8 +33,7 @@ import org.mockito.quality.Strictness @Extensions( ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class), - ExtendWith(ApiLevelExtension::class) + ExtendWith(ForgeExtension::class) ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) 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 8b038921ef..eb8e61ca2a 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 @@ -36,7 +36,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness -import java.io.ByteArrayOutputStream import java.util.concurrent.ExecutorService import java.util.concurrent.Future @@ -47,7 +46,7 @@ import java.util.concurrent.Future @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) internal class Base64SerializerTest { - lateinit var testedBase64Serializer: Base64Serializer + private lateinit var testedBase64Serializer: Base64Serializer @Mock lateinit var mockDrawableUtils: DrawableUtils @@ -64,7 +63,9 @@ internal class Base64SerializerTest { @Mock lateinit var mockCallback: AsyncImageProcessingCallback - lateinit var fakeBase64String: String + private lateinit var fakeBase64String: String + + private lateinit var fakeByteArray: ByteArray @Mock lateinit var mockExecutorService: ExecutorService @@ -84,26 +85,31 @@ internal class Base64SerializerTest { @Mock lateinit var mockStateListDrawable: StateListDrawable + @Mock + lateinit var mockBitmapPool: BitmapPool + @Forgery lateinit var fakeImageWireframe: MobileSegment.Wireframe.ImageWireframe @BeforeEach fun setup(forge: Forge) { fakeBase64String = forge.aString() + fakeByteArray = forge.aString().toByteArray() fakeImageWireframe.base64 = "" fakeImageWireframe.isEmpty = true - val fakeByteOutputStream: ByteArrayOutputStream = mock() - whenever(mockWebPImageCompression.compressBitmapToStream(any())) - .thenReturn(fakeByteOutputStream) + whenever(mockWebPImageCompression.compressBitmap(any())) + .thenReturn(fakeByteArray) whenever(mockBase64Utils.serializeToBase64String(any())).thenReturn(fakeBase64String) whenever( mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( - any(), - any(), - anyOrNull() + applicationContext = any(), + drawable = any(), + displayMetrics = any(), + requestedSizeInBytes = anyOrNull(), + config = anyOrNull() ) ).thenReturn(mockBitmap) @@ -134,8 +140,15 @@ internal class Base64SerializerTest { @Test fun `M callback with finishProcessingImage W handleBitmap() { failed to create bmp }`() { // Given - whenever(mockDrawableUtils.createBitmapOfApproxSizeFromDrawable(any(), any(), anyOrNull())) - .thenReturn(null) + whenever( + mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( + applicationContext = any(), + drawable = any(), + displayMetrics = any(), + requestedSizeInBytes = anyOrNull(), + config = anyOrNull() + ) + ).thenReturn(null) // When testedBase64Serializer.handleBitmap( @@ -152,8 +165,15 @@ internal class Base64SerializerTest { @Test fun `M callback with finishProcessingImage W handleBitmap() { created bmp async }`() { // Given - whenever(mockDrawableUtils.createBitmapOfApproxSizeFromDrawable(any(), any(), anyOrNull())) - .thenReturn(mockBitmap) + whenever( + mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( + applicationContext = any(), + drawable = any(), + displayMetrics = any(), + requestedSizeInBytes = anyOrNull(), + config = anyOrNull() + ) + ).thenReturn(mockBitmap) // When testedBase64Serializer.handleBitmap( @@ -165,7 +185,7 @@ internal class Base64SerializerTest { // Then assertThat(fakeImageWireframe.base64).isEqualTo(fakeBase64String) - assertThat(fakeImageWireframe.isEmpty).isFalse() + assertThat(fakeImageWireframe.isEmpty).isFalse verify(mockCallback).finishProcessingImage() } @@ -175,11 +195,18 @@ internal class Base64SerializerTest { val fakeBase64String = forge.anAsciiString() whenever(mockBase64LruCache.get(mockDrawable)).thenReturn(fakeBase64String) - whenever(mockDrawableUtils.createBitmapOfApproxSizeFromDrawable(any(), any(), anyOrNull())) + whenever( + mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( + applicationContext = any(), + drawable = any(), + displayMetrics = any(), + requestedSizeInBytes = anyOrNull(), + config = anyOrNull() + ) + ) .thenReturn(mockBitmap) - val mockByteArrayOutputStream: ByteArrayOutputStream = mock() - whenever(mockWebPImageCompression.compressBitmapToStream(any())) - .thenReturn(mockByteArrayOutputStream) + whenever(mockWebPImageCompression.compressBitmap(any())) + .thenReturn(fakeByteArray) // When testedBase64Serializer.handleBitmap( @@ -193,26 +220,10 @@ internal class Base64SerializerTest { verifyNoInteractions(mockDrawableUtils) } - @Test - fun `M calculate base64 W handleBitmap() { cache miss }`() { - // Given - whenever(mockBase64LruCache.get(mockDrawable)).thenReturn(null) - - // When - testedBase64Serializer.handleBitmap( - applicationContext = mockApplicationContext, - displayMetrics = mockDisplayMetrics, - drawable = mockDrawable, - imageWireframe = fakeImageWireframe - ) - - // Then - verify(mockDrawableUtils).createBitmapOfApproxSizeFromDrawable(any(), any(), anyOrNull()) - } - @Test fun `M register cache only once for callbacks W handleBitmap() { multiple calls and instances }`() { // Given + Base64Serializer.isCacheRegisteredForCallbacks = false val secondInstance = createBase64Serializer() // When @@ -236,6 +247,29 @@ internal class Base64SerializerTest { verify(mockApplicationContext, times(1)).registerComponentCallbacks(mockBase64LruCache) } + @Test + fun `M calculate base64 W handleBitmap() { cache miss }`() { + // Given + whenever(mockBase64LruCache.get(mockDrawable)).thenReturn(null) + + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockDrawable, + imageWireframe = fakeImageWireframe + ) + + // Then + verify(mockDrawableUtils).createBitmapOfApproxSizeFromDrawable( + applicationContext = any(), + drawable = any(), + displayMetrics = any(), + requestedSizeInBytes = anyOrNull(), + config = anyOrNull() + ) + } + @Test fun `M use the same ThreadPoolExecutor W build()`() { // When @@ -284,6 +318,7 @@ internal class Base64SerializerTest { drawableUtils = mockDrawableUtils, base64Utils = mockBase64Utils, webPImageCompression = mockWebPImageCompression, - base64LruCache = mockBase64LruCache + base64LruCache = mockBase64LruCache, + bitmapPool = mockBitmapPool ) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolTest.kt new file mode 100644 index 0000000000..f20b942aed --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolTest.kt @@ -0,0 +1,261 @@ +/* + * 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.base64 + +import android.graphics.Bitmap +import android.util.LruCache +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.recorder.base64.BitmapPool.MAX_CACHE_MEMORY_SIZE_BYTES +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.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class BitmapPoolTest { + private lateinit var mockBitmap: Bitmap + + @Mock + lateinit var mockConfig: Bitmap.Config + + private val testedCache = BitmapPool + + private var width: Int = 0 + private var height: Int = 0 + private lateinit var fakeKey: String + + private lateinit var internalCache: LruCache + + @BeforeEach + fun setup(forge: Forge) { + internalCache = LruCache(MAX_CACHE_MEMORY_SIZE_BYTES) + testedCache.setBackingCache(internalCache) + + width = forge.anInt(1, 200) + height = forge.anInt(1, 200) + fakeKey = "$width-$height-$mockConfig" + + mockBitmap = createMockBitmap(forge) + + testedCache.setUsedBitmaps(HashSet()) + testedCache.setBitmapsBySize(HashMap()) + } + + @Test + fun `M return null W get() { pool does not have bitmaps of the right size }`() { + // Given + val bitmapOtherDimensions: Bitmap = mock() + whenever(bitmapOtherDimensions.width).thenReturn(width + 10) + testedCache.put(bitmapOtherDimensions) + + // When + val bitmap = testedCache.getBitmapByProperties(width, height, mockConfig) + + // Then + assertThat(bitmap).isNull() + } + + @Test + fun `M return a bitmap W get() { pool has free bitmap of the right size }`() { + // Given + testedCache.put(mockBitmap) + + // When + val cacheItem = testedCache.getBitmapByProperties(width, height, mockConfig) + + // Then + assertThat(cacheItem).isEqualTo(mockBitmap) + } + + @Test + fun `M return null W get() { bitmap in pool but it's already in use }`() { + // Given + testedCache.put(mockBitmap) + testedCache.getBitmapByProperties(width, height, mockConfig) + + // When + val cacheItem = testedCache.getBitmapByProperties(width, height, mockConfig) + + // Then + assertThat(cacheItem).isNull() + } + + // region put + + @Test + fun `M return bitmaps W get() { according to what is in the pool }`(forge: Forge) { + // Given + testedCache.put(mockBitmap) + + val bitmapsBySize = HashMap>() + bitmapsBySize[fakeKey] = hashSetOf(mockBitmap) + testedCache.setBitmapsBySize(bitmapsBySize) + + val secondBitmap = createMockBitmap(forge) + testedCache.put(secondBitmap) + + val expectedBitmaps = listOf(mockBitmap, secondBitmap) + + // When + val firstResultFromPool = testedCache.get(fakeKey) + val secondResultFromPool = testedCache.get(fakeKey) + val thirdResultFromPool = testedCache.get(fakeKey) + + // Then + assertThat(firstResultFromPool).isIn(expectedBitmaps) + assertThat(secondResultFromPool).isIn(expectedBitmaps) + assertThat(firstResultFromPool).isNotEqualTo(secondResultFromPool) + assertThat(thirdResultFromPool).isNull() + } + + @Test + fun `M mark bitmap as free W put() { if bitmap already in the pool }`() { + // Given + val usedCache = hashSetOf(mockBitmap) + testedCache.setUsedBitmaps(usedCache) + + val bitmapsBySize = HashMap>() + bitmapsBySize[fakeKey] = hashSetOf(mockBitmap) + testedCache.setBitmapsBySize(bitmapsBySize) + + // When + testedCache.put(mockBitmap) + + // Then + assertThat(usedCache).isEmpty() + } + + @Test + fun `M add to pool W put() { and bitmap not in pool }`() { + // Given + testedCache.setUsedBitmaps(HashSet()) + + // When + testedCache.put(mockBitmap) + val actual = testedCache.getBitmapByProperties(width, height, mockConfig) + + // Then + assertThat(actual).isEqualTo(mockBitmap) + } + + @Test + fun `M not allow immutable bitmaps W put()`() { + // Given + whenever(mockBitmap.isMutable).thenReturn(false) + + // When + testedCache.put(mockBitmap) + + // Then + assertThat(internalCache.size()).isEqualTo(0) + } + + @Test + fun `M not allow recycled bitmaps W put()`() { + // Given + whenever(mockBitmap.isRecycled).thenReturn(true) + + // When + testedCache.put(mockBitmap) + + // Then + assertThat(internalCache.size()).isEqualTo(0) + } + + // endregion + + // concurrency region + + @Test + fun `M get free bitmap only once W get() { multiple threads }`() { + // Given + val countDownLatch = CountDownLatch(3) + testedCache.put(mockBitmap) + var numBmpsRetrievedFromPool = 0 + + // When + repeat(3) { + Thread { + val result = testedCache.get(fakeKey) + if (result != null) numBmpsRetrievedFromPool++ + countDownLatch.countDown() + }.start() + } + + // Then + countDownLatch.await(5, TimeUnit.SECONDS) + assertThat(numBmpsRetrievedFromPool).isEqualTo(1) + } + + @Test + fun `M insert same bitmap only once W put() { multiple threads }`() { + // Given + val countDownLatch = CountDownLatch(3) + + // When + repeat(3) { + Thread { + testedCache.put(mockBitmap) + countDownLatch.countDown() + }.start() + } + + // Then + countDownLatch.await(5, TimeUnit.SECONDS) + assertThat(internalCache.size()).isEqualTo(1) + assertThat(internalCache.snapshot().values).contains(mockBitmap) + } + + @Test + fun `M insert multiple bitmaps W put() { multiple threads }`(forge: Forge) { + // Given + val countDownLatch = CountDownLatch(3) + + // When + repeat(3) { + Thread { + testedCache.put(createMockBitmap(forge)) + countDownLatch.countDown() + }.start() + } + + // Then + countDownLatch.await(5, TimeUnit.SECONDS) + assertThat(internalCache.size()).isEqualTo(3) + } + + // endregion + + private fun createMockBitmap(forge: Forge): Bitmap { + val bitmap: Bitmap = mock() + val allocSize = forge.anInt(100, 10000) + whenever(bitmap.config).thenReturn(mockConfig) + whenever(bitmap.width).thenReturn(width) + whenever(bitmap.height).thenReturn(height) + whenever(bitmap.allocationByteCount).thenReturn(allocSize) + whenever(bitmap.isMutable).thenReturn(true) + whenever(bitmap.isRecycled).thenReturn(false) + return bitmap + } +} 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 77f2083d6a..cc51cafdf0 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 @@ -53,7 +53,7 @@ internal class WebPImageCompressionTest { val captor = argumentCaptor() // When - testedImageCompression.compressBitmapToStream(mockBitmap) + testedImageCompression.compressBitmap(mockBitmap) verify(mockBitmap).compress( captor.capture(), @@ -72,7 +72,7 @@ internal class WebPImageCompressionTest { val captor = argumentCaptor() // When - testedImageCompression.compressBitmapToStream(mockBitmap) + testedImageCompression.compressBitmap(mockBitmap) verify(mockBitmap).compress( captor.capture(), 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 5867db5772..df86579b1d 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 @@ -6,14 +6,15 @@ package com.datadog.android.sessionreplay.internal.utils +import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.drawable.Drawable import android.util.DisplayMetrics import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.recorder.base64.BitmapPool import com.datadog.android.sessionreplay.internal.recorder.wrappers.BitmapWrapper import com.datadog.android.sessionreplay.internal.recorder.wrappers.CanvasWrapper -import com.datadog.android.sessionreplay.internal.utils.DrawableUtils.Companion.MAX_BITMAP_SIZE_IN_BYTES import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -26,6 +27,8 @@ 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.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -42,31 +45,43 @@ internal class DrawableUtilsTest { @Mock private lateinit var mockDisplayMetrics: DisplayMetrics + @Mock + private lateinit var mockBitmapPool: BitmapPool + @Mock private lateinit var mockDrawable: Drawable + @Mock + private lateinit var mockApplicationContext: Context + @Mock private lateinit var mockBitmapWrapper: BitmapWrapper @Mock private lateinit var mockCanvasWrapper: CanvasWrapper + @Mock + private lateinit var mockCanvas: Canvas + @Mock private lateinit var mockBitmap: Bitmap @Mock - private lateinit var mockCanvas: Canvas + private lateinit var mockConfig: Bitmap.Config @BeforeEach fun setup() { whenever(mockBitmapWrapper.createBitmap(any(), any(), any(), any())) .thenReturn(mockBitmap) - whenever(mockBitmap.byteCount).thenReturn(MAX_BITMAP_SIZE_IN_BYTES + 1) whenever(mockCanvasWrapper.createCanvas(mockBitmap)) .thenReturn(mockCanvas) + whenever(mockBitmap.config).thenReturn(mockConfig) + whenever(mockBitmapPool.getBitmapByProperties(any(), any(), any())).thenReturn(null) + testedDrawableUtils = DrawableUtils( bitmapWrapper = mockBitmapWrapper, - canvasWrapper = mockCanvasWrapper + canvasWrapper = mockCanvasWrapper, + bitmapPool = mockBitmapPool ) } @@ -85,9 +100,11 @@ internal class DrawableUtilsTest { // When testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + mockApplicationContext, mockDrawable, mockDisplayMetrics, - requestedSize + requestedSize, + mockConfig ) // Then @@ -114,8 +131,11 @@ internal class DrawableUtilsTest { val displayMetricsCaptor = argumentCaptor() // When - testedDrawableUtils - .createBitmapOfApproxSizeFromDrawable(mockDrawable, mockDisplayMetrics) + testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + mockApplicationContext, + mockDrawable, + mockDisplayMetrics + ) // Then verify(mockBitmapWrapper).createBitmap( @@ -140,7 +160,12 @@ internal class DrawableUtilsTest { val displayMetricsCaptor = argumentCaptor() // When - val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable(mockDrawable, mockDisplayMetrics) + val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + mockApplicationContext, + mockDrawable, + mockDisplayMetrics, + config = mockConfig + ) // Then verify(mockBitmapWrapper).createBitmap( @@ -169,7 +194,12 @@ internal class DrawableUtilsTest { .thenReturn(null) // When - val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable(mockDrawable, mockDisplayMetrics) + val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + mockApplicationContext, + mockDrawable, + mockDisplayMetrics, + config = mockConfig + ) // Then assertThat(result).isNull() @@ -185,11 +215,55 @@ internal class DrawableUtilsTest { .thenReturn(null) // When - val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable(mockDrawable, mockDisplayMetrics) + val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + mockApplicationContext, + mockDrawable, + mockDisplayMetrics, + config = mockConfig + ) // Then assertThat(result).isNull() } // endregion + + fun `M use bitmap from pool W createBitmapFromDrawable() { exists in pool }`() { + // Given + val mockBitmapFromPool: Bitmap = mock() + whenever(mockDrawable.intrinsicHeight).thenReturn(200) + whenever(mockDrawable.intrinsicWidth).thenReturn(200) + whenever(mockBitmapPool.getBitmapByProperties(any(), any(), any())) + .thenReturn(mockBitmapFromPool) + + // When + val actualBitmap = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + mockApplicationContext, + mockDrawable, + mockDisplayMetrics, + config = mockConfig + ) + + // Then + assertThat(actualBitmap).isEqualTo(mockBitmapFromPool) + } + + @Test + fun `M register BitmapPool for callbacks only once W createBitmapOfApproxSizeFromDrawable()`() { + // Given + DrawableUtils.isBitmapPoolRegisteredForCallbacks = false + + // When + repeat(5) { + testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( + mockApplicationContext, + mockDrawable, + mockDisplayMetrics, + config = mockConfig + ) + } + + // Then + verify(mockApplicationContext, times(1)).registerComponentCallbacks(any()) + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtilsTest.kt new file mode 100644 index 0000000000..8b7da19560 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtilsTest.kt @@ -0,0 +1,111 @@ +/* + * 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 com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.lang.Exception + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class InvocationUtilsTest { + private lateinit var testedInvocationUtils: InvocationUtils + + @Mock + lateinit var mockLogger: InternalLogger + + @BeforeEach + fun setup() { + testedInvocationUtils = InvocationUtils() + } + + @Test + fun `M call function W safeCallWithErrorLogging()`() { + // Given + val mockFunc = mock<() -> Unit>() + + // When + testedInvocationUtils.safeCallWithErrorLogging( + logger = mockLogger, + call = { mockFunc() }, + failureMessage = "someMessage" + ) + + // Then + verify(mockFunc, times(1))() + } + + @Test + fun `M return function result W safeCallWithErrorLogging()`() { + // Given + val mockFunc = mock<() -> Boolean>() + whenever(mockFunc()).thenReturn(true) + + // When + val result = testedInvocationUtils.safeCallWithErrorLogging( + logger = mockLogger, + call = { mockFunc() }, + failureMessage = "someMessage" + ) + + // Then + assertThat(result).isTrue + } + + @Test + fun `M log failureMessage W safeCallWithErrorLogging() { failure }`( + @StringForgery fakeMessage: String + ) { + // Given + val mockFunc = mock<() -> Boolean>() + val mockException: Exception = mock() + doThrow(mockException).`when`(mockFunc) + + val captor = argumentCaptor<() -> String>() + + // When + testedInvocationUtils.safeCallWithErrorLogging( + logger = mockLogger, + call = { mockFunc() }, + failureMessage = fakeMessage + ) + + // Then + verify(mockLogger).log( + level = any(), + target = any(), + captor.capture(), + anyOrNull(), + anyOrNull() + ) + assertThat(captor.firstValue()).isEqualTo(fakeMessage) + } +} From 5be1cc0645da78c211d11262a4eeaf783e168c6e Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 31 Jul 2023 14:58:52 +0300 Subject: [PATCH 5/8] Refactor singleton caches to class instances --- detekt_custom.yml | 2 + .../api/apiSurface | 2 +- .../api/dd-sdk-android-session-replay.api | 8 +- .../build.gradle.kts | 5 - .../sessionreplay/SessionReplayPrivacy.kt | 18 +- .../recorder/base64/Base64LRUCache.kt | 74 +++++--- .../recorder/base64/Base64Serializer.kt | 48 ++--- .../internal/recorder/base64/BitmapPool.kt | 173 ++++++++---------- .../recorder/base64/BitmapPoolHelper.kt | 30 +++ .../recorder/mapper/BaseWireframeMapper.kt | 31 +++- .../internal/utils/CacheUtils.kt | 41 ++++- .../internal/utils/DrawableUtils.kt | 4 +- .../internal/utils/InvocationUtils.kt | 6 +- .../recorder/base64/Base64LRUCacheTest.kt | 73 ++------ .../recorder/base64/Base64SerializerTest.kt | 36 ++-- .../recorder/base64/BitmapPoolTest.kt | 171 ++++++++++++++--- .../recorder/mapper/ImageButtonMapperTest.kt | 2 +- 17 files changed, 444 insertions(+), 280 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolHelper.kt diff --git a/detekt_custom.yml b/detekt_custom.yml index 88ab8898b1..0fa21a4fd7 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -339,6 +339,8 @@ datadog: - "android.view.Window.Callback.onMenuItemSelected(kotlin.Int, android.view.MenuItem)" - "android.view.View.getChildAt(kotlin.Int)" - "android.view.View.hashCode()" + - "androidx.collection.LruCache.size()" + - "androidx.collection.LruCache.maxSize()" # endregion # region Android Webview APIs - "android.webkit.ConsoleMessage.MessageLevel.toLogLevel()" diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index 9588785458..6227984509 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -31,7 +31,7 @@ class com.datadog.android.sessionreplay.internal.recorder.base64.WebPImageCompre override fun compressBitmap(android.graphics.Bitmap): ByteArray companion object abstract class com.datadog.android.sessionreplay.internal.recorder.mapper.BaseWireframeMapper : WireframeMapper, com.datadog.android.sessionreplay.internal.AsyncImageProcessingCallback - constructor(com.datadog.android.sessionreplay.utils.StringUtils = StringUtils, com.datadog.android.sessionreplay.utils.ViewUtils = ViewUtils, com.datadog.android.sessionreplay.internal.recorder.base64.ImageCompression = WebPImageCompression(), com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator = UniqueIdentifierGenerator, com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer = Base64Serializer.Builder().build()) + constructor(com.datadog.android.sessionreplay.utils.StringUtils = StringUtils, com.datadog.android.sessionreplay.utils.ViewUtils = ViewUtils) protected fun resolveViewId(android.view.View): Long protected fun colorAndAlphaAsStringHexa(Int, Int): String protected fun resolveViewGlobalBounds(android.view.View, Float): com.datadog.android.sessionreplay.internal.recorder.GlobalBounds diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index 3e071b0568..14b255b68a 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -88,7 +88,7 @@ public final class com/datadog/android/sessionreplay/internal/recorder/SystemInf } public final class com/datadog/android/sessionreplay/internal/recorder/base64/Base64Serializer { - public synthetic fun (Ljava/util/concurrent/ExecutorService;Lcom/datadog/android/sessionreplay/internal/utils/DrawableUtils;Lcom/datadog/android/sessionreplay/internal/utils/Base64Utils;Lcom/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression;Lcom/datadog/android/sessionreplay/internal/recorder/base64/Cache;Lcom/datadog/android/sessionreplay/internal/recorder/base64/Cache;Lcom/datadog/android/api/InternalLogger;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/util/concurrent/ExecutorService;Lcom/datadog/android/sessionreplay/internal/utils/DrawableUtils;Lcom/datadog/android/sessionreplay/internal/utils/Base64Utils;Lcom/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression;Lcom/datadog/android/sessionreplay/internal/recorder/base64/Cache;Lcom/datadog/android/sessionreplay/internal/recorder/base64/BitmapPool;Lcom/datadog/android/api/InternalLogger;Lkotlin/jvm/internal/DefaultConstructorMarker;)V } public abstract interface class com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression { @@ -108,12 +108,12 @@ public final class com/datadog/android/sessionreplay/internal/recorder/base64/We public abstract class com/datadog/android/sessionreplay/internal/recorder/mapper/BaseWireframeMapper : com/datadog/android/sessionreplay/internal/AsyncImageProcessingCallback, com/datadog/android/sessionreplay/internal/recorder/mapper/WireframeMapper { public static final field Companion Lcom/datadog/android/sessionreplay/internal/recorder/mapper/BaseWireframeMapper$Companion; public fun ()V - public fun (Lcom/datadog/android/sessionreplay/utils/StringUtils;Lcom/datadog/android/sessionreplay/utils/ViewUtils;Lcom/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression;Lcom/datadog/android/sessionreplay/utils/UniqueIdentifierGenerator;Lcom/datadog/android/sessionreplay/internal/recorder/base64/Base64Serializer;)V - public synthetic fun (Lcom/datadog/android/sessionreplay/utils/StringUtils;Lcom/datadog/android/sessionreplay/utils/ViewUtils;Lcom/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression;Lcom/datadog/android/sessionreplay/utils/UniqueIdentifierGenerator;Lcom/datadog/android/sessionreplay/internal/recorder/base64/Base64Serializer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lcom/datadog/android/sessionreplay/utils/StringUtils;Lcom/datadog/android/sessionreplay/utils/ViewUtils;)V + public synthetic fun (Lcom/datadog/android/sessionreplay/utils/StringUtils;Lcom/datadog/android/sessionreplay/utils/ViewUtils;ILkotlin/jvm/internal/DefaultConstructorMarker;)V protected final fun colorAndAlphaAsStringHexa (II)Ljava/lang/String; public fun finishProcessingImage ()V protected final fun getWebPMimeType ()Ljava/lang/String; - protected final fun handleBitmap (Landroid/content/Context;Landroid/util/DisplayMetrics;Landroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe;)V + protected final fun handleBitmap (Landroid/content/Context;Landroid/util/DisplayMetrics;Landroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe;)Lkotlin/Unit; protected final fun resolveChildDrawableUniqueIdentifier (Landroid/view/View;)Ljava/lang/Long; protected final fun resolveShapeStyleAndBorder (Landroid/graphics/drawable/Drawable;F)Lkotlin/Pair; protected final fun resolveViewGlobalBounds (Landroid/view/View;F)Lcom/datadog/android/sessionreplay/internal/recorder/GlobalBounds; diff --git a/features/dd-sdk-android-session-replay/build.gradle.kts b/features/dd-sdk-android-session-replay/build.gradle.kts index b82ee6938a..eca47e63ae 100644 --- a/features/dd-sdk-android-session-replay/build.gradle.kts +++ b/features/dd-sdk-android-session-replay/build.gradle.kts @@ -27,7 +27,6 @@ plugins { id("com.github.ben-manes.versions") // Tests - id("de.mobilej.unmock") id("org.jetbrains.kotlinx.kover") // Internal Generation @@ -112,10 +111,6 @@ dependencies { apply(from = "clone_session_replay_schema.gradle.kts") apply(from = "generate_session_replay_models.gradle.kts") -unMock { - keep("android.util.LruCache") -} - kotlinConfig(jvmBytecodeTarget = JvmTarget.JVM_11) junitConfig() javadocConfig() 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 312c353bec..ea78e5e2ab 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 @@ -20,6 +20,9 @@ import android.widget.SeekBar import android.widget.TextView import android.widget.Toolbar import androidx.appcompat.widget.SwitchCompat +import com.datadog.android.sessionreplay.internal.recorder.base64.Base64LRUCache +import com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer +import com.datadog.android.sessionreplay.internal.recorder.base64.BitmapPool import com.datadog.android.sessionreplay.internal.recorder.mapper.BasePickerMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.ButtonMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckBoxMapper @@ -77,9 +80,11 @@ enum class SessionReplayPrivacy { @Suppress("LongMethod") internal fun mappers(): List { + val base64Serializer = buildBase64Serializer() + val viewWireframeMapper = ViewWireframeMapper() val unsupportedViewMapper = UnsupportedViewMapper() - val imageButtonMapper = ImageButtonMapper() + val imageButtonMapper = ImageButtonMapper(base64Serializer = base64Serializer) val imageMapper: ViewScreenshotWireframeMapper val textMapper: TextViewMapper val buttonMapper: ButtonMapper @@ -161,6 +166,17 @@ enum class SessionReplayPrivacy { return mappersList } + private fun buildBase64Serializer(): Base64Serializer { + val bitmapPool = BitmapPool() + val base64LRUCache = Base64LRUCache() + + val builder = Base64Serializer.Builder( + bitmapPool = bitmapPool, + base64LRUCache = base64LRUCache + ) + return builder.build() + } + private fun getMaskSeekBarMapper(): MaskSeekBarWireframeMapper? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { MaskSeekBarWireframeMapper() 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 e10129607f..40df0aac30 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 @@ -12,59 +12,74 @@ import android.graphics.drawable.AnimationDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.DrawableContainer import android.graphics.drawable.LayerDrawable -import android.util.LruCache import androidx.annotation.VisibleForTesting +import androidx.collection.LruCache import com.datadog.android.sessionreplay.internal.utils.CacheUtils - -internal object Base64LRUCache : Cache, ComponentCallbacks2 { - @Suppress("MagicNumber") - private val MAX_CACHE_MEMORY_SIZE_BYTES = 4 * 1024 * 1024 // 4MB - - private var cache: LruCache = object : - LruCache(MAX_CACHE_MEMORY_SIZE_BYTES) { - override fun sizeOf(key: String?, value: ByteArray): Int { - return value.size +import com.datadog.android.sessionreplay.internal.utils.InvocationUtils + +internal class Base64LRUCache( + private val cacheUtils: CacheUtils = CacheUtils(), + private val invocationUtils: InvocationUtils = InvocationUtils(), + private var cache: LruCache = + object : + LruCache(MAX_CACHE_MEMORY_SIZE_BYTES) { + override fun sizeOf(key: String, value: ByteArray): Int { + return value.size + } } - } +) : Cache, ComponentCallbacks2 { override fun onTrimMemory(level: Int) { - val cacheUtils = CacheUtils() cacheUtils.handleTrimMemory(level, cache) } override fun onConfigurationChanged(newConfig: Configuration) {} override fun onLowMemory() { - cache.evictAll() - } - - @VisibleForTesting - internal fun setBackingCache(cache: LruCache) { - this.cache = cache + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + invocationUtils.safeCallWithErrorLogging( + call = { cache.evictAll() }, + failureMessage = FAILURE_MSG_EVICT_CACHE_CONTENTS + ) } @Synchronized override fun put(element: Drawable, value: String) { val key = generateKey(element) val byteArray = value.toByteArray(Charsets.UTF_8) - cache.put(key, byteArray) + + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + invocationUtils.safeCallWithErrorLogging( + call = { cache.put(key, byteArray) }, + failureMessage = FAILURE_MSG_PUT_CACHE + ) } @Synchronized override fun get(element: Drawable): String? = - cache.get(generateKey(element))?.let { - String(it) - } + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + invocationUtils.safeCallWithErrorLogging( + call = { + cache.get(generateKey(element))?.let { + String(it) + } + }, + failureMessage = FAILURE_MSG_GET_CACHE + ) - @Synchronized override fun size(): Int = cache.size() @Synchronized override fun clear() { - cache.evictAll() + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + invocationUtils.safeCallWithErrorLogging( + call = { cache.evictAll() }, + failureMessage = FAILURE_MSG_EVICT_CACHE_CONTENTS + ) } - private fun generateKey(drawable: Drawable): String = + @VisibleForTesting + internal fun generateKey(drawable: Drawable): String = generatePrefix(drawable) + System.identityHashCode(drawable) private fun generatePrefix(drawable: Drawable): String { @@ -97,4 +112,13 @@ internal object Base64LRUCache : Cache, ComponentCallbacks2 { "" } } + + internal companion object { + @Suppress("MagicNumber") + internal val MAX_CACHE_MEMORY_SIZE_BYTES = 4 * 1024 * 1024 // 4MB + + private const val FAILURE_MSG_EVICT_CACHE_CONTENTS = "Failed to evict cache entries" + private const val FAILURE_MSG_PUT_CACHE = "Failed to put item in cache" + private const val FAILURE_MSG_GET_CACHE = "Failed to get item from cache" + } } 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 d8b7e9332e..789b277057 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 @@ -32,8 +32,8 @@ class Base64Serializer private constructor( private val drawableUtils: DrawableUtils, private val base64Utils: Base64Utils, private val webPImageCompression: ImageCompression, - private val base64LruCache: Cache, - private val bitmapPool: Cache, + private val base64LRUCache: Cache?, + private val bitmapPool: BitmapPool?, private val logger: InternalLogger ) { private var asyncImageProcessingCallback: AsyncImageProcessingCallback? = null @@ -51,7 +51,7 @@ class Base64Serializer private constructor( asyncImageProcessingCallback?.startProcessingImage() - val cachedBase64 = base64LruCache.get(drawable) + val cachedBase64 = base64LRUCache?.get(drawable) if (cachedBase64 != null) { finalizeRecordedDataItem(cachedBase64, imageWireframe, asyncImageProcessingCallback) return @@ -106,8 +106,8 @@ class Base64Serializer private constructor( private fun registerCacheForCallbacks(applicationContext: Context) { if (isCacheRegisteredForCallbacks) return - if (base64LruCache is ComponentCallbacks2) { - applicationContext.registerComponentCallbacks(base64LruCache) + if (base64LRUCache is ComponentCallbacks2) { + applicationContext.registerComponentCallbacks(base64LRUCache) isCacheRegisteredForCallbacks = true } else { // Temporarily use UNBOUND logger @@ -130,10 +130,10 @@ class Base64Serializer private constructor( if (base64Result.isNotEmpty()) { // if we got a base64 string then cache it - base64LruCache.put(drawable, base64Result) + base64LRUCache?.put(drawable, base64Result) } - bitmapPool.put(bitmap) + bitmapPool?.put(bitmap) return base64Result } @@ -166,25 +166,26 @@ class Base64Serializer private constructor( // endregion // region builder - internal class Builder { - internal fun build( - threadPoolExecutor: ExecutorService = THREADPOOL_EXECUTOR, - drawableUtils: DrawableUtils = DrawableUtils(), - base64Utils: Base64Utils = Base64Utils(), - webPImageCompression: ImageCompression = WebPImageCompression(), - base64LruCache: Cache = Base64LRUCache, - bitmapPool: Cache = BitmapPool, - // Temporarily use UNBOUND until we handle the loggers - logger: InternalLogger = InternalLogger.UNBOUND - ) = + internal class Builder( + // Temporarily use UNBOUND until we handle the loggers + private var logger: InternalLogger = InternalLogger.UNBOUND, + 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 base64Utils: Base64Utils = Base64Utils(), + private var webPImageCompression: ImageCompression = WebPImageCompression() + ) { + + internal fun build() = Base64Serializer( + logger = logger, threadPoolExecutor = threadPoolExecutor, + bitmapPool = bitmapPool, + base64LRUCache = base64LRUCache, drawableUtils = drawableUtils, base64Utils = base64Utils, - webPImageCompression = webPImageCompression, - base64LruCache = base64LruCache, - bitmapPool = bitmapPool, - logger = logger + webPImageCompression = webPImageCompression ) private companion object { @@ -192,8 +193,7 @@ class Base64Serializer private constructor( private const val CORE_DEFAULT_POOL_SIZE = 1 private const val MAX_THREAD_COUNT = 10 - // all parameters are non-negative and queue is not null - @Suppress("UnsafeThirdPartyFunctionCall") + @Suppress("UnsafeThirdPartyFunctionCall") // all parameters are non-negative and queue is not null private val THREADPOOL_EXECUTOR = ThreadPoolExecutor( CORE_DEFAULT_POOL_SIZE, MAX_THREAD_COUNT, 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 39fb44bca0..a13e36531c 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 @@ -11,73 +11,53 @@ import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Bitmap.Config import android.os.Build -import android.util.LruCache import androidx.annotation.VisibleForTesting -import com.datadog.android.api.InternalLogger +import androidx.collection.LruCache import com.datadog.android.sessionreplay.internal.utils.CacheUtils -import com.datadog.android.sessionreplay.internal.utils.InvocationUtils import java.util.concurrent.atomic.AtomicInteger @Suppress("TooManyFunctions") -internal object BitmapPool : Cache, ComponentCallbacks2 { - private const val BITMAP_OPERATION_FAILED = "operation failed for bitmap pool" - - @VisibleForTesting - @Suppress("MagicNumber") - internal val MAX_CACHE_MEMORY_SIZE_BYTES = 4 * 1024 * 1024 // 4MB - - private var bitmapsBySize = HashMap>() - private var usedBitmaps = HashSet() - private val logger = InternalLogger.UNBOUND - private val invocationUtils = InvocationUtils() - private var bitmapIndex = AtomicInteger(0) - +internal class BitmapPool( + private val bitmapPoolHelper: BitmapPoolHelper = BitmapPoolHelper(), + private val cacheUtils: CacheUtils = CacheUtils(), + @get:VisibleForTesting internal val bitmapsBySize: HashMap> = HashMap(), + @get:VisibleForTesting internal val usedBitmaps: HashSet = HashSet(), private var cache: LruCache = object : LruCache(MAX_CACHE_MEMORY_SIZE_BYTES) { - override fun sizeOf(key: String?, bitmap: Bitmap): Int { - return bitmap.allocationByteCount + override fun sizeOf(key: String, value: Bitmap): Int { + return value.allocationByteCount } + @Synchronized override fun entryRemoved( evicted: Boolean, - key: String?, - oldValue: Bitmap?, + key: String, + oldValue: Bitmap, newValue: Bitmap? ) { - super.entryRemoved(evicted, key, oldValue, newValue) - - if (oldValue != null) { - val dimensionsKey = generateKey(oldValue) - val bitmapGroup = bitmapsBySize[dimensionsKey] ?: HashSet() - - invocationUtils.safeCallWithErrorLogging( - logger = logger, - call = { - @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block - bitmapGroup.remove(oldValue) - }, - failureMessage = BITMAP_OPERATION_FAILED - ) - markBitmapAsFree(oldValue) - oldValue.recycle() + bitmapPoolHelper.safeCall { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + super.entryRemoved(evicted, key, oldValue, newValue) } - } - } - @VisibleForTesting - internal fun setBitmapsBySize(bitmaps: HashMap>) { - this.bitmapsBySize = bitmaps - } + val dimensionsKey = bitmapPoolHelper.generateKey(oldValue) + val bitmapGroup = bitmapsBySize[dimensionsKey] ?: HashSet() - @VisibleForTesting - internal fun setBackingCache(cache: LruCache) { - this.cache = cache - } + bitmapPoolHelper.safeCall { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + bitmapGroup.remove(oldValue) + } - @VisibleForTesting - internal fun setUsedBitmaps(usedBitmaps: HashSet) { - this.usedBitmaps = usedBitmaps + bitmapPoolHelper.safeCall { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + usedBitmaps.remove(oldValue) + } + + oldValue.recycle() + } } +) : Cache, ComponentCallbacks2 { + private var bitmapIndex = AtomicInteger(0) @Synchronized override fun put(value: Bitmap) { @@ -86,16 +66,12 @@ internal object BitmapPool : Cache, ComponentCallbacks2 { return } - val key = generateKey(value) + val key = bitmapPoolHelper.generateKey(value) - val bitmapExistsInPool = invocationUtils.safeCallWithErrorLogging( - logger = logger, - call = { - @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block - bitmapsBySize[key]?.contains(value) ?: false - }, - failureMessage = BITMAP_OPERATION_FAILED - ) ?: false + val bitmapExistsInPool = bitmapPoolHelper.safeCall { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + bitmapsBySize[key]?.contains(value) ?: false + } ?: false if (!bitmapExistsInPool) { addBitmapToPool(key, value) @@ -107,7 +83,12 @@ internal object BitmapPool : Cache, ComponentCallbacks2 { override fun size(): Int = cache.size() @Synchronized - override fun clear() = cache.evictAll() + override fun clear() { + bitmapPoolHelper.safeCall { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + cache.evictAll() + } + } @Synchronized override fun get(element: String): Bitmap? { @@ -115,77 +96,69 @@ internal object BitmapPool : Cache, ComponentCallbacks2 { // find the first unused bitmap, mark it as used and return it return bitmapsWithReqDimensions.find { - invocationUtils.safeCallWithErrorLogging( - logger = logger, - call = { !usedBitmaps.contains(it) }, - failureMessage = BITMAP_OPERATION_FAILED - ) ?: false + bitmapPoolHelper.safeCall { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + !usedBitmaps.contains(it) + } ?: false }?.apply { markBitmapAsUsed(this) } } internal fun getBitmapByProperties(width: Int, height: Int, config: Config): Bitmap? { - val key = generateKey(width, height, config) + val key = bitmapPoolHelper.generateKey(width, height, config) return get(key) } private fun markBitmapAsFree(bitmap: Bitmap) { - invocationUtils.safeCallWithErrorLogging( - logger = logger, - call = { - usedBitmaps.remove(bitmap) - }, - failureMessage = BITMAP_OPERATION_FAILED - ) + bitmapPoolHelper.safeCall { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + usedBitmaps.remove(bitmap) + } } private fun markBitmapAsUsed(bitmap: Bitmap) { - invocationUtils.safeCallWithErrorLogging( - logger = logger, - call = { - @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block - usedBitmaps.add(bitmap) - }, - failureMessage = BITMAP_OPERATION_FAILED - ) + bitmapPoolHelper.safeCall { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + usedBitmaps.add(bitmap) + } } private fun addBitmapToPool(key: String, bitmap: Bitmap) { val cacheIndex = bitmapIndex.incrementAndGet() val cacheKey = "$key-$cacheIndex" + cache.put(cacheKey, bitmap) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block - invocationUtils.safeCallWithErrorLogging( - logger = logger, - call = { bitmapsBySize.putIfAbsent(key, HashSet()) }, - failureMessage = BITMAP_OPERATION_FAILED - ) + bitmapPoolHelper.safeCall { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + bitmapsBySize.putIfAbsent(key, HashSet()) + } } else { if (bitmapsBySize[key] == null) bitmapsBySize[key] = HashSet() } - @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block - invocationUtils.safeCallWithErrorLogging( - logger = logger, - call = { bitmapsBySize[key]?.add(bitmap) }, - failureMessage = BITMAP_OPERATION_FAILED - ) - } - - private fun generateKey(bitmap: Bitmap) = - generateKey(bitmap.width, bitmap.height, bitmap.config) - private fun generateKey(width: Int, height: Int, config: Config) = - "$width-$height-$config" + bitmapPoolHelper.safeCall { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + bitmapsBySize[key]?.add(bitmap) + } + } override fun onConfigurationChanged(newConfig: Configuration) {} override fun onLowMemory() { - cache.evictAll() + bitmapPoolHelper.safeCall { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + cache.evictAll() + } } override fun onTrimMemory(level: Int) { - val cacheUtils = CacheUtils() cacheUtils.handleTrimMemory(level, cache) } + + internal companion object { + @VisibleForTesting + @Suppress("MagicNumber") + internal val MAX_CACHE_MEMORY_SIZE_BYTES = 4 * 1024 * 1024 // 4MB + } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolHelper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolHelper.kt new file mode 100644 index 0000000000..b7e7a058fd --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolHelper.kt @@ -0,0 +1,30 @@ +/* + * 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.base64 + +import android.graphics.Bitmap +import com.datadog.android.sessionreplay.internal.utils.InvocationUtils + +internal class BitmapPoolHelper( + private val invocationUtils: InvocationUtils = InvocationUtils() +) { + internal fun generateKey(bitmap: Bitmap) = + generateKey(bitmap.width, bitmap.height, bitmap.config) + + internal fun generateKey(width: Int, height: Int, config: Bitmap.Config) = + "$width-$height-$config" + + internal fun safeCall(call: () -> R): R? = + invocationUtils.safeCallWithErrorLogging( + call = { call() }, + failureMessage = BITMAP_OPERATION_FAILED + ) + + private companion object { + private const val BITMAP_OPERATION_FAILED = "operation failed for bitmap pool" + } +} 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 316c29c127..beca003604 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 @@ -28,12 +28,29 @@ import com.datadog.android.sessionreplay.utils.ViewUtils @Suppress("UndocumentedPublicClass") abstract class BaseWireframeMapper( private val stringUtils: StringUtils = StringUtils, - private val viewUtils: ViewUtils = ViewUtils, - private val webPImageCompression: ImageCompression = WebPImageCompression(), - private val uniqueIdentifierGenerator: UniqueIdentifierGenerator = UniqueIdentifierGenerator, - // TODO: REPLAY-1856 find a way to remove base64 dependency from the constructor - private val base64Serializer: Base64Serializer = Base64Serializer.Builder().build() + private val viewUtils: ViewUtils = ViewUtils ) : WireframeMapper, AsyncImageProcessingCallback { + private var base64Serializer: Base64Serializer? = null + private var webPImageCompression: ImageCompression = WebPImageCompression() + private var uniqueIdentifierGenerator: UniqueIdentifierGenerator = UniqueIdentifierGenerator + + internal constructor( + base64Serializer: Base64Serializer? = null, + webPImageCompression: ImageCompression? = null, + uniqueIdentifierGenerator: UniqueIdentifierGenerator? = null + ) : this() { + base64Serializer?.let { + this.base64Serializer = it + } + + webPImageCompression?.let { + this.webPImageCompression = it + } + + uniqueIdentifierGenerator?.let { + this.uniqueIdentifierGenerator = it + } + } /** * Resolves the [View] unique id to be used in the mapped [MobileSegment.Wireframe]. @@ -108,7 +125,7 @@ abstract class BaseWireframeMapper( displayMetrics: DisplayMetrics, drawable: Drawable, imageWireframe: MobileSegment.Wireframe.ImageWireframe - ) = base64Serializer.handleBitmap( + ) = base64Serializer?.handleBitmap( applicationContext, displayMetrics, drawable, @@ -118,7 +135,7 @@ abstract class BaseWireframeMapper( internal fun registerAsyncImageProcessingCallback( asyncImageProcessingCallback: AsyncImageProcessingCallback ) { - base64Serializer.registerAsyncLoadingCallback(asyncImageProcessingCallback) + base64Serializer?.registerAsyncLoadingCallback(asyncImageProcessingCallback) } override fun startProcessingImage() {} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt index c979f2524d..0fdff54672 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt @@ -7,9 +7,11 @@ package com.datadog.android.sessionreplay.internal.utils import android.content.ComponentCallbacks2 -import android.util.LruCache +import androidx.collection.LruCache -internal class CacheUtils { +internal class CacheUtils( + private val invocationUtils: InvocationUtils = InvocationUtils() +) { internal fun handleTrimMemory(level: Int, cache: LruCache) { @Suppress("MagicNumber") val onLowMemorySizeBytes = cache.maxSize() / 2 // 50% @@ -19,34 +21,55 @@ internal class CacheUtils { when (level) { ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> { - cache.evictAll() + evictAll(cache) } ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> { - cache.evictAll() + evictAll(cache) } ComponentCallbacks2.TRIM_MEMORY_MODERATE -> { - cache.trimToSize(onModerateMemorySizeBytes) + trimToSize(cache, onModerateMemorySizeBytes) } ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { - cache.evictAll() + evictAll(cache) } ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> { - cache.trimToSize(onLowMemorySizeBytes) + trimToSize(cache, onLowMemorySizeBytes) } ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE -> { - cache.trimToSize(onModerateMemorySizeBytes) + trimToSize(cache, onModerateMemorySizeBytes) } ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {} else -> { - cache.evictAll() + evictAll(cache) } } } + + private fun evictAll(cache: LruCache) { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + invocationUtils.safeCallWithErrorLogging( + call = { cache.evictAll() }, + failureMessage = FAILURE_MSG_EVICT_CACHE_CONTENTS + ) + } + + private fun trimToSize(cache: LruCache, targetSize: Int) { + @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block + invocationUtils.safeCallWithErrorLogging( + call = { cache.trimToSize(targetSize) }, + failureMessage = FAILURE_MSG_TRIM_CACHE + ) + } + + private companion object { + private const val FAILURE_MSG_EVICT_CACHE_CONTENTS = "Failed to evict cache entries" + private const val FAILURE_MSG_TRIM_CACHE = "Failed to trim cache to size" + } } 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 2ee9ed6af0..5246194af0 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 @@ -24,7 +24,7 @@ import kotlin.math.sqrt internal class DrawableUtils( private val bitmapWrapper: BitmapWrapper = BitmapWrapper(), private val canvasWrapper: CanvasWrapper = CanvasWrapper(), - private val bitmapPool: BitmapPool = BitmapPool + private val bitmapPool: BitmapPool? = null ) { /** * This method attempts to create a bitmap from a drawable, such that the bitmap file size will @@ -88,7 +88,7 @@ internal class DrawableUtils( height: Int, config: Config ): Bitmap? = - bitmapPool.getBitmapByProperties(width, height, config) + bitmapPool?.getBitmapByProperties(width, height, config) ?: bitmapWrapper.createBitmap(displayMetrics, width, height, config) @MainThread diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtils.kt index 324231ecc6..b3086fc413 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtils.kt @@ -11,7 +11,8 @@ import com.datadog.android.api.InternalLogger internal class InvocationUtils { @Suppress("SwallowedException", "TooGenericExceptionCaught") inline fun safeCallWithErrorLogging( - logger: InternalLogger, + // Temporarily use UNBOUND until we handle the loggers + logger: InternalLogger = InternalLogger.UNBOUND, call: () -> R, failureMessage: String, level: InternalLogger.Level = InternalLogger.Level.WARN, @@ -24,7 +25,8 @@ internal class InvocationUtils { logger.log( level, target, - { failureMessage } + { failureMessage }, + e ) } 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 896929f77a..f61ebe2d9d 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 @@ -10,8 +10,9 @@ import android.graphics.drawable.AnimationDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.RippleDrawable import android.graphics.drawable.StateListDrawable -import android.util.LruCache +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 fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -25,9 +26,7 @@ import org.mockito.Mock import org.mockito.Mockito.mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings -import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -38,10 +37,9 @@ import org.mockito.quality.Strictness @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) internal class Base64LRUCacheTest { - private val testedCache = Base64LRUCache + private lateinit var testedCache: Base64LRUCache - @Mock - lateinit var mockLruCache: LruCache + private lateinit var internalCache: LruCache @Mock lateinit var mockDrawable: Drawable @@ -53,7 +51,10 @@ internal class Base64LRUCacheTest { @BeforeEach fun setup() { - testedCache.setBackingCache(mockLruCache) + internalCache = LruCache(MAX_CACHE_MEMORY_SIZE_BYTES) + testedCache = Base64LRUCache( + cache = internalCache + ) } @Test @@ -68,53 +69,15 @@ internal class Base64LRUCacheTest { @Test fun `M return item W get() { item in cache }`(forge: Forge) { // Given - val drawableID = System.identityHashCode(mockDrawable).toString() val fakeBase64String = forge.aString() - val fakeValue = forge.anAsciiString().toByteArray() - whenever(mockLruCache.get(drawableID)).thenReturn(fakeValue) testedCache.put(mockDrawable, fakeBase64String) // When val cacheItem = testedCache.get(mockDrawable) // Then - verify(mockLruCache).get(drawableID) - assertThat(cacheItem).isEqualTo(String(fakeValue)) - } - - @Test - fun `M call LruCache put W put()`() { - // Given - val key = System.identityHashCode(mockDrawable).toString() - - // When - testedCache.put(mockDrawable, fakeBase64) - - // Then - verify(mockLruCache).put(key, fakeBase64.toByteArray()) - } - - @Test - fun `M return LruCache size W size()`() { - // Given - whenever(mockLruCache.size()).thenReturn(3) - - // When - val size = testedCache.size() - - // Then - verify(mockLruCache).size() - assertThat(size).isEqualTo(3) - } - - @Test - fun `M clear LRUCache W clear()`() { - // When - testedCache.clear() - - // Then - verify(mockLruCache).evictAll() + assertThat(cacheItem).isEqualTo(fakeBase64String) } @Test @@ -126,8 +89,8 @@ internal class Base64LRUCacheTest { testedCache.put(mockAnimationDrawable, fakeBase64) // Then - verify(mockLruCache).put(argumentCaptor.capture(), any()) - assertThat(argumentCaptor.firstValue).doesNotContain("-") + val key = testedCache.generateKey(mockAnimationDrawable) + assertThat(key).doesNotContain("-") } @Test @@ -142,8 +105,8 @@ internal class Base64LRUCacheTest { testedCache.put(mockStatelistDrawable, fakeBase64) // Then - verify(mockLruCache).put(argumentCaptor.capture(), any()) - assertThat(argumentCaptor.firstValue).startsWith(expectedPrefix) + val key = testedCache.generateKey(mockStatelistDrawable) + assertThat(key).startsWith(expectedPrefix) } @Test @@ -157,14 +120,13 @@ internal class Base64LRUCacheTest { whenever(mockRippleDrawable.getDrawable(1)).thenReturn(mockFgLayer) val fakeBase64 = forge.aString() - val captor = argumentCaptor() // When testedCache.put(mockRippleDrawable, fakeBase64) // Then - verify(mockLruCache).put(captor.capture(), any()) - assertThat(captor.firstValue).contains(System.identityHashCode(mockBgLayer).toString()) + val key = testedCache.generateKey(mockRippleDrawable) + assertThat(key).contains(System.identityHashCode(mockBgLayer).toString()) } @Test @@ -176,13 +138,12 @@ internal class Base64LRUCacheTest { whenever(mockRippleDrawable.getDrawable(0)).thenReturn(mockBgLayer) val fakeBase64 = forge.aString() - val captor = argumentCaptor() // When testedCache.put(mockRippleDrawable, fakeBase64) // Then - verify(mockLruCache).put(captor.capture(), any()) - assertThat(captor.firstValue).isEqualTo(System.identityHashCode(mockRippleDrawable).toString()) + val key = testedCache.generateKey(mockRippleDrawable) + assertThat(key).isEqualTo(System.identityHashCode(mockRippleDrawable).toString()) } } 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 eb8e61ca2a..890554f84d 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 @@ -11,6 +11,7 @@ import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.graphics.drawable.StateListDrawable import android.util.DisplayMetrics +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.utils.Base64Utils @@ -57,6 +58,9 @@ internal class Base64SerializerTest { @Mock lateinit var mockBase64Utils: Base64Utils + @Mock + lateinit var mockLogger: InternalLogger + @Mock lateinit var mockApplicationContext: Context @@ -71,7 +75,7 @@ internal class Base64SerializerTest { lateinit var mockExecutorService: ExecutorService @Mock - lateinit var mockBase64LruCache: Base64LRUCache + lateinit var mockBase64LRUCache: Base64LRUCache @Mock lateinit var mockDisplayMetrics: DisplayMetrics @@ -193,7 +197,7 @@ internal class Base64SerializerTest { fun `M get base64 from cache W handleBitmap() { cache hit }`(forge: Forge) { // Given val fakeBase64String = forge.anAsciiString() - whenever(mockBase64LruCache.get(mockDrawable)).thenReturn(fakeBase64String) + whenever(mockBase64LRUCache.get(mockDrawable)).thenReturn(fakeBase64String) whenever( mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( @@ -244,13 +248,13 @@ internal class Base64SerializerTest { } // Then - verify(mockApplicationContext, times(1)).registerComponentCallbacks(mockBase64LruCache) + verify(mockApplicationContext, times(1)).registerComponentCallbacks(mockBase64LRUCache) } @Test fun `M calculate base64 W handleBitmap() { cache miss }`() { // Given - whenever(mockBase64LruCache.get(mockDrawable)).thenReturn(null) + whenever(mockBase64LRUCache.get(mockDrawable)).thenReturn(null) // When testedBase64Serializer.handleBitmap( @@ -293,7 +297,7 @@ internal class Base64SerializerTest { ) // Then - verify(mockBase64LruCache, times(1)).put(mockStateListDrawable, fakeBase64String) + verify(mockBase64LRUCache, times(1)).put(mockStateListDrawable, fakeBase64String) } @Test @@ -310,15 +314,19 @@ internal class Base64SerializerTest { ) // Then - verify(mockBase64LruCache, times(0)).put(any(), any()) + verify(mockBase64LRUCache, times(0)).put(any(), any()) } - private fun createBase64Serializer() = Base64Serializer.Builder().build( - threadPoolExecutor = mockExecutorService, - drawableUtils = mockDrawableUtils, - base64Utils = mockBase64Utils, - webPImageCompression = mockWebPImageCompression, - base64LruCache = mockBase64LruCache, - bitmapPool = mockBitmapPool - ) + private fun createBase64Serializer(): Base64Serializer { + val builder = Base64Serializer.Builder( + logger = mockLogger, + threadPoolExecutor = mockExecutorService, + bitmapPool = mockBitmapPool, + base64LRUCache = mockBase64LRUCache, + drawableUtils = mockDrawableUtils, + base64Utils = mockBase64Utils, + webPImageCompression = mockWebPImageCompression + ) + return builder.build() + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolTest.kt index f20b942aed..c65659788d 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/BitmapPoolTest.kt @@ -7,9 +7,11 @@ package com.datadog.android.sessionreplay.internal.recorder.base64 import android.graphics.Bitmap -import android.util.LruCache +import android.os.Build import com.datadog.android.sessionreplay.forge.ForgeConfigurator -import com.datadog.android.sessionreplay.internal.recorder.base64.BitmapPool.MAX_CACHE_MEMORY_SIZE_BYTES +import com.datadog.android.sessionreplay.internal.utils.CacheUtils +import com.datadog.tools.unit.annotations.TestTargetApi +import com.datadog.tools.unit.extensions.ApiLevelExtension import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -19,9 +21,14 @@ 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.Spy 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.whenever import org.mockito.quality.Strictness import java.util.concurrent.CountDownLatch @@ -29,7 +36,8 @@ import java.util.concurrent.TimeUnit @Extensions( ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class) + ExtendWith(ForgeExtension::class), + ExtendWith(ApiLevelExtension::class) ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) @@ -39,27 +47,34 @@ internal class BitmapPoolTest { @Mock lateinit var mockConfig: Bitmap.Config - private val testedCache = BitmapPool + @Mock + lateinit var mockCacheUtils: CacheUtils + + @Spy + lateinit var spyBitmapPoolHelper: BitmapPoolHelper + private lateinit var testedCache: BitmapPool private var width: Int = 0 private var height: Int = 0 private lateinit var fakeKey: String - private lateinit var internalCache: LruCache - @BeforeEach fun setup(forge: Forge) { - internalCache = LruCache(MAX_CACHE_MEMORY_SIZE_BYTES) - testedCache.setBackingCache(internalCache) - width = forge.anInt(1, 200) height = forge.anInt(1, 200) fakeKey = "$width-$height-$mockConfig" mockBitmap = createMockBitmap(forge) - testedCache.setUsedBitmaps(HashSet()) - testedCache.setBitmapsBySize(HashMap()) + doAnswer { + @Suppress("UNCHECKED_CAST") + (it.arguments[0] as () -> Any).invoke() + }.`when`(spyBitmapPoolHelper).safeCall(any()) + + testedCache = BitmapPool( + bitmapPoolHelper = spyBitmapPoolHelper, + cacheUtils = mockCacheUtils + ) } @Test @@ -108,10 +123,6 @@ internal class BitmapPoolTest { // Given testedCache.put(mockBitmap) - val bitmapsBySize = HashMap>() - bitmapsBySize[fakeKey] = hashSetOf(mockBitmap) - testedCache.setBitmapsBySize(bitmapsBySize) - val secondBitmap = createMockBitmap(forge) testedCache.put(secondBitmap) @@ -132,25 +143,30 @@ internal class BitmapPoolTest { @Test fun `M mark bitmap as free W put() { if bitmap already in the pool }`() { // Given - val usedCache = hashSetOf(mockBitmap) - testedCache.setUsedBitmaps(usedCache) - - val bitmapsBySize = HashMap>() - bitmapsBySize[fakeKey] = hashSetOf(mockBitmap) - testedCache.setBitmapsBySize(bitmapsBySize) + testedCache.put(mockBitmap) + testedCache.getBitmapByProperties(mockBitmap.width, mockBitmap.height, mockBitmap.config) // When testedCache.put(mockBitmap) // Then - assertThat(usedCache).isEmpty() + val actualBitmap = testedCache.getBitmapByProperties(mockBitmap.width, mockBitmap.height, mockBitmap.config) + assertThat(actualBitmap).isEqualTo(mockBitmap) } @Test fun `M add to pool W put() { and bitmap not in pool }`() { - // Given - testedCache.setUsedBitmaps(HashSet()) + // When + testedCache.put(mockBitmap) + val actual = testedCache.getBitmapByProperties(width, height, mockConfig) + + // Then + assertThat(actual).isEqualTo(mockBitmap) + } + @Test + @TestTargetApi(Build.VERSION_CODES.N) + fun `M add to pool W put() { and bitmap not in pool, api N }`() { // When testedCache.put(mockBitmap) val actual = testedCache.getBitmapByProperties(width, height, mockConfig) @@ -168,7 +184,7 @@ internal class BitmapPoolTest { testedCache.put(mockBitmap) // Then - assertThat(internalCache.size()).isEqualTo(0) + assertThat(testedCache.usedBitmaps.size).isEqualTo(0) } @Test @@ -180,7 +196,7 @@ internal class BitmapPoolTest { testedCache.put(mockBitmap) // Then - assertThat(internalCache.size()).isEqualTo(0) + assertThat(testedCache.usedBitmaps.size).isEqualTo(0) } // endregion @@ -223,14 +239,15 @@ internal class BitmapPoolTest { // Then countDownLatch.await(5, TimeUnit.SECONDS) - assertThat(internalCache.size()).isEqualTo(1) - assertThat(internalCache.snapshot().values).contains(mockBitmap) + assertThat(testedCache.bitmapsBySize.size).isEqualTo(1) } @Test fun `M insert multiple bitmaps W put() { multiple threads }`(forge: Forge) { // Given val countDownLatch = CountDownLatch(3) + val bitmapPoolHelper = BitmapPoolHelper() + val key = bitmapPoolHelper.generateKey(mockBitmap) // When repeat(3) { @@ -242,7 +259,103 @@ internal class BitmapPoolTest { // Then countDownLatch.await(5, TimeUnit.SECONDS) - assertThat(internalCache.size()).isEqualTo(3) + assertThat(testedCache.bitmapsBySize.get(key)?.size).isEqualTo(3) + } + + @Test + fun `M return total size of the stored bitmaps W size()`(forge: Forge) { + // Given + testedCache.put(mockBitmap) + + val secondBitmap = createMockBitmap(forge) + testedCache.put(secondBitmap) + + val expectedSize = mockBitmap.allocationByteCount + secondBitmap.allocationByteCount + + // When + val actualSize = testedCache.size() + + // Then + assertThat(actualSize).isEqualTo(expectedSize) + } + + @Test + fun `M call recycle on bitmaps W clear()`() { + // Given + var called = false + whenever(mockBitmap.recycle()).then { + called = true + true + } + testedCache.put(mockBitmap) + + // When + testedCache.clear() + + // Then + assertThat(called).isTrue() + } + + @Test + fun `M remove bitmap from pool W clear()`() { + // Given + testedCache.put(mockBitmap) + + // When + testedCache.clear() + + // Then + assertThat(testedCache.size()).isEqualTo(0) + } + + @Test + fun `M remove bitmap from usedBitmaps W clear()`() { + // Given + testedCache.put(mockBitmap) + testedCache.get(fakeKey) + assertThat(testedCache.usedBitmaps.size).isEqualTo(1) + + // When + testedCache.clear() + + // Then + assertThat(testedCache.usedBitmaps.size).isEqualTo(0) + } + + @Test + fun `M remove bitmap from bitmapsBySize W clear()`() { + // Given + testedCache.put(mockBitmap) + assertThat(testedCache.bitmapsBySize[fakeKey]?.size).isEqualTo(1) + + // When + testedCache.clear() + + // Then + assertThat(testedCache.bitmapsBySize[fakeKey]?.size).isEqualTo(0) + } + + @Test + fun `M clear all items W onLowMemory()`() { + // Given + testedCache.put(mockBitmap) + + // When + testedCache.onLowMemory() + + // Then + assertThat(testedCache.size()).isEqualTo(0) + } + + @Test + fun `M call cacheUtils with correct level W onTrimMemory()`() { + // When + testedCache.onTrimMemory(0) + + // Then + val captor = argumentCaptor() + verify(mockCacheUtils).handleTrimMemory(captor.capture(), any()) + assertThat(captor.firstValue).isEqualTo(0) } // endregion 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/ImageButtonMapperTest.kt index 1c7eafffcc..e97eb6f1d6 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/ImageButtonMapperTest.kt @@ -46,7 +46,7 @@ import org.mockito.quality.Strictness @ForgeConfiguration(ForgeConfigurator::class) internal class ImageButtonMapperTest { - lateinit var testedMapper: ImageButtonMapper + private lateinit var testedMapper: ImageButtonMapper @Mock lateinit var mockImageButton: ImageButton From e814de7f6c3e1ba5ef355a5da981f364906a9607 Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Wed, 16 Aug 2023 12:58:19 +0300 Subject: [PATCH 6/8] Optimize Bitmap Processing Get Bitmap from BitmapDrawable if available Extract base64 logic from mapper to ImageWireframeHelper Get background images if they exist Support scaletypes --- detekt_custom.yml | 2 + .../material/MaskInputTabWireframeMapper.kt | 2 +- .../material/MaskTabWireframeMapper.kt | 2 +- .../material/TabWireframeMapper.kt | 14 +- .../api/apiSurface | 14 +- .../api/dd-sdk-android-session-replay.api | 22 +- .../sessionreplay/SessionReplayPrivacy.kt | 10 +- .../recorder/base64/Base64Serializer.kt | 61 +++-- .../recorder/base64/ImageCompression.kt | 2 +- .../recorder/base64/ImageWireframeHelper.kt | 71 +++++ .../recorder/base64/WebPImageCompression.kt | 2 +- .../recorder/mapper/BaseWireframeMapper.kt | 145 +++++++---- .../recorder/mapper/ImageButtonMapper.kt | 100 +++---- .../recorder/mapper/QueueableViewMapper.kt | 2 +- .../internal/utils/DrawableDimensions.kt | 12 + .../internal/utils/DrawableUtils.kt | 68 +++-- .../recorder/base64/Base64SerializerTest.kt | 171 +++++++++++- .../base64/ImageWireframeHelperTest.kt | 246 ++++++++++++++++++ .../recorder/mapper/ImageButtonMapperTest.kt | 246 ++++++++++++++---- .../internal/utils/DrawableUtilsTest.kt | 244 ++++++++++++++--- .../sessionreplay/ImageComponentsFragment.kt | 20 ++ .../res/layout/fragment_image_components.xml | 85 +++++- 22 files changed, 1248 insertions(+), 293 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelper.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableDimensions.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelperTest.kt diff --git a/detekt_custom.yml b/detekt_custom.yml index 0fa21a4fd7..0b4f8fa220 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -359,6 +359,7 @@ datadog: - "android.webkit.WebViewClient.onReceivedSslError(android.webkit.WebView, android.webkit.SslErrorHandler, android.net.http.SslError)" # endregion # region Android View APIs + - "android.widget.ImageView.getScaleType()" - "android.widget.FrameLayout.LayoutParams.constructor(kotlin.Int, kotlin.Int)" - "android.widget.FrameLayout.removeView(android.view.View)" - "android.widget.LinearLayout.constructor(android.content.Context)" @@ -862,6 +863,7 @@ datadog: - "kotlin.Pair.constructor(com.datadog.android.sessionreplay.model.MobileSegment, com.google.gson.JsonObject)" - "kotlin.Pair.constructor(com.google.gson.JsonObject, kotlin.Long)" - "kotlin.Pair.constructor(kotlin.Int, kotlin.Int)" + - "kotlin.Pair.constructor(kotlin.Long, kotlin.Long)" - "kotlin.Triple.constructor(kotlin.String, kotlin.String, kotlin.String)" - "kotlin.Triple.constructor(kotlin.Nothing?, kotlin.Nothing?, kotlin.Nothing?)" # endregion diff --git a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaskInputTabWireframeMapper.kt b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaskInputTabWireframeMapper.kt index 68c83ea464..f2afbc9510 100644 --- a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaskInputTabWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaskInputTabWireframeMapper.kt @@ -16,6 +16,6 @@ import com.datadog.android.sessionreplay.utils.ViewUtils internal class MaskInputTabWireframeMapper( viewUtils: ViewUtils = ViewUtils, uniqueIdentifierGenerator: UniqueIdentifierGenerator = UniqueIdentifierGenerator, - textViewMapper: WireframeMapper = + textViewMapper: WireframeMapper = MaskInputTextViewMapper() ) : TabWireframeMapper(viewUtils, uniqueIdentifierGenerator, textViewMapper) diff --git a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaskTabWireframeMapper.kt b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaskTabWireframeMapper.kt index dd50b59db6..30fded2dbc 100644 --- a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaskTabWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaskTabWireframeMapper.kt @@ -16,6 +16,6 @@ import com.datadog.android.sessionreplay.utils.ViewUtils internal class MaskTabWireframeMapper( viewUtils: ViewUtils = ViewUtils, uniqueIdentifierGenerator: UniqueIdentifierGenerator = UniqueIdentifierGenerator, - textViewMapper: WireframeMapper = + textViewMapper: WireframeMapper = MaskTextViewMapper() ) : TabWireframeMapper(viewUtils, uniqueIdentifierGenerator, textViewMapper) diff --git a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/TabWireframeMapper.kt b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/TabWireframeMapper.kt index 648541a25e..af934e79de 100644 --- a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/TabWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/TabWireframeMapper.kt @@ -13,7 +13,6 @@ import com.datadog.android.sessionreplay.internal.recorder.mapper.TextViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.WireframeMapper import com.datadog.android.sessionreplay.material.internal.densityNormalized import com.datadog.android.sessionreplay.model.MobileSegment -import com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.TextWireframe import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator import com.datadog.android.sessionreplay.utils.ViewUtils import com.google.android.material.tabs.TabLayout @@ -23,7 +22,7 @@ internal open class TabWireframeMapper( private val viewUtils: ViewUtils = ViewUtils, private val uniqueIdentifierGenerator: UniqueIdentifierGenerator = UniqueIdentifierGenerator, - internal val textViewMapper: WireframeMapper = TextViewMapper() + internal val textViewMapper: WireframeMapper = TextViewMapper() ) : WireframeMapper { override fun map( @@ -47,7 +46,7 @@ internal open class TabWireframeMapper( protected open fun resolveTabIndicatorWireframe( view: TabView, systemInformation: SystemInformation, - textWireframe: TextWireframe? + wireframe: MobileSegment.Wireframe? ): MobileSegment.Wireframe? { val selectorId = uniqueIdentifierGenerator.resolveChildUniqueIdentifier( view, @@ -62,8 +61,11 @@ internal open class TabWireframeMapper( val selectionIndicatorXPos = viewBounds.x + paddingStart val selectionIndicatorYPos = viewBounds.y + viewBounds.height - selectionIndicatorHeight val selectionIndicatorWidth = viewBounds.width - paddingStart - paddingEnd - val selectionIndicatorColor = textWireframe?.textStyle?.color - ?: SELECTED_TAB_INDICATOR_DEFAULT_COLOR + val selectionIndicatorColor = if (wireframe is MobileSegment.Wireframe.TextWireframe) { + wireframe.textStyle.color + } else { + SELECTED_TAB_INDICATOR_DEFAULT_COLOR + } val selectionIndicatorShapeStyle = MobileSegment.ShapeStyle( backgroundColor = selectionIndicatorColor, opacity = view.alpha @@ -79,7 +81,7 @@ internal open class TabWireframeMapper( } private fun findAndResolveLabelWireframes(view: TabView, mappingContext: MappingContext): - List { + List { for (i in 0 until view.childCount) { val viewChild = view.getChildAt(i) ?: continue if (TextView::class.java.isAssignableFrom(viewChild::class.java)) { diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index 6227984509..cc390f958f 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -22,23 +22,13 @@ interface com.datadog.android.sessionreplay.internal.recorder.OptionSelectorDete fun isOptionSelector(android.view.ViewGroup): Boolean data class com.datadog.android.sessionreplay.internal.recorder.SystemInformation constructor(GlobalBounds, Int = Configuration.ORIENTATION_UNDEFINED, Float, String? = null) -class com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer -interface com.datadog.android.sessionreplay.internal.recorder.base64.ImageCompression - fun getMimeType(): String? - fun compressBitmap(android.graphics.Bitmap): ByteArray -class com.datadog.android.sessionreplay.internal.recorder.base64.WebPImageCompression : ImageCompression - override fun getMimeType(): String? - override fun compressBitmap(android.graphics.Bitmap): ByteArray - companion object -abstract class com.datadog.android.sessionreplay.internal.recorder.mapper.BaseWireframeMapper : WireframeMapper, com.datadog.android.sessionreplay.internal.AsyncImageProcessingCallback +abstract class com.datadog.android.sessionreplay.internal.recorder.mapper.BaseWireframeMapper : WireframeMapper, com.datadog.android.sessionreplay.internal.AsyncImageProcessingCallback constructor(com.datadog.android.sessionreplay.utils.StringUtils = StringUtils, com.datadog.android.sessionreplay.utils.ViewUtils = ViewUtils) + override fun map(T, com.datadog.android.sessionreplay.internal.recorder.MappingContext): List protected fun resolveViewId(android.view.View): Long protected fun colorAndAlphaAsStringHexa(Int, Int): String protected fun resolveViewGlobalBounds(android.view.View, Float): com.datadog.android.sessionreplay.internal.recorder.GlobalBounds protected fun android.graphics.drawable.Drawable.resolveShapeStyleAndBorder(Float): Pair? - protected fun resolveChildDrawableUniqueIdentifier(android.view.View): Long? - protected fun getWebPMimeType(): String? - protected fun handleBitmap(android.content.Context, android.util.DisplayMetrics, android.graphics.drawable.Drawable, com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.ImageWireframe) override fun startProcessingImage() override fun finishProcessingImage() companion object diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index 14b255b68a..0cfa0760e8 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -87,24 +87,6 @@ public final class com/datadog/android/sessionreplay/internal/recorder/SystemInf public fun toString ()Ljava/lang/String; } -public final class com/datadog/android/sessionreplay/internal/recorder/base64/Base64Serializer { - public synthetic fun (Ljava/util/concurrent/ExecutorService;Lcom/datadog/android/sessionreplay/internal/utils/DrawableUtils;Lcom/datadog/android/sessionreplay/internal/utils/Base64Utils;Lcom/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression;Lcom/datadog/android/sessionreplay/internal/recorder/base64/Cache;Lcom/datadog/android/sessionreplay/internal/recorder/base64/BitmapPool;Lcom/datadog/android/api/InternalLogger;Lkotlin/jvm/internal/DefaultConstructorMarker;)V -} - -public abstract interface class com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression { - public abstract fun compressBitmap (Landroid/graphics/Bitmap;)[B - public abstract fun getMimeType ()Ljava/lang/String; -} - -public final class com/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompression : com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression { - public static final field Companion Lcom/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompression$Companion; - public fun compressBitmap (Landroid/graphics/Bitmap;)[B - public fun getMimeType ()Ljava/lang/String; -} - -public final class com/datadog/android/sessionreplay/internal/recorder/base64/WebPImageCompression$Companion { -} - public abstract class com/datadog/android/sessionreplay/internal/recorder/mapper/BaseWireframeMapper : com/datadog/android/sessionreplay/internal/AsyncImageProcessingCallback, com/datadog/android/sessionreplay/internal/recorder/mapper/WireframeMapper { public static final field Companion Lcom/datadog/android/sessionreplay/internal/recorder/mapper/BaseWireframeMapper$Companion; public fun ()V @@ -112,9 +94,7 @@ public abstract class com/datadog/android/sessionreplay/internal/recorder/mapper public synthetic fun (Lcom/datadog/android/sessionreplay/utils/StringUtils;Lcom/datadog/android/sessionreplay/utils/ViewUtils;ILkotlin/jvm/internal/DefaultConstructorMarker;)V protected final fun colorAndAlphaAsStringHexa (II)Ljava/lang/String; public fun finishProcessingImage ()V - protected final fun getWebPMimeType ()Ljava/lang/String; - protected final fun handleBitmap (Landroid/content/Context;Landroid/util/DisplayMetrics;Landroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe;)Lkotlin/Unit; - protected final fun resolveChildDrawableUniqueIdentifier (Landroid/view/View;)Ljava/lang/Long; + public fun map (Landroid/view/View;Lcom/datadog/android/sessionreplay/internal/recorder/MappingContext;)Ljava/util/List; protected final fun resolveShapeStyleAndBorder (Landroid/graphics/drawable/Drawable;F)Lkotlin/Pair; protected final fun resolveViewGlobalBounds (Landroid/view/View;F)Lcom/datadog/android/sessionreplay/internal/recorder/GlobalBounds; protected final fun resolveViewId (Landroid/view/View;)J 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 ea78e5e2ab..64a0eb445d 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 @@ -23,6 +23,7 @@ import androidx.appcompat.widget.SwitchCompat import com.datadog.android.sessionreplay.internal.recorder.base64.Base64LRUCache import com.datadog.android.sessionreplay.internal.recorder.base64.Base64Serializer import com.datadog.android.sessionreplay.internal.recorder.base64.BitmapPool +import com.datadog.android.sessionreplay.internal.recorder.base64.ImageWireframeHelper import com.datadog.android.sessionreplay.internal.recorder.mapper.BasePickerMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.ButtonMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckBoxMapper @@ -47,6 +48,7 @@ import com.datadog.android.sessionreplay.internal.recorder.mapper.UnsupportedVie 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 /** @@ -81,10 +83,16 @@ enum class SessionReplayPrivacy { @Suppress("LongMethod") internal fun mappers(): List { val base64Serializer = buildBase64Serializer() + val imageWireframeHelper = ImageWireframeHelper(base64Serializer = base64Serializer) + val uniqueIdentifierGenerator = UniqueIdentifierGenerator val viewWireframeMapper = ViewWireframeMapper() val unsupportedViewMapper = UnsupportedViewMapper() - val imageButtonMapper = ImageButtonMapper(base64Serializer = base64Serializer) + val imageButtonMapper = ImageButtonMapper( + base64Serializer = base64Serializer, + imageWireframeHelper = imageWireframeHelper, + uniqueIdentifierGenerator = uniqueIdentifierGenerator + ) val imageMapper: ViewScreenshotWireframeMapper val textMapper: TextViewMapper val buttonMapper: ButtonMapper 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 789b277057..c396dde6ce 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 @@ -9,8 +9,10 @@ package com.datadog.android.sessionreplay.internal.recorder.base64 import android.content.ComponentCallbacks2 import android.content.Context 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 @@ -18,6 +20,7 @@ import com.datadog.android.api.InternalLogger 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.DrawableDimensions import com.datadog.android.sessionreplay.internal.utils.DrawableUtils import com.datadog.android.sessionreplay.model.MobileSegment import java.util.concurrent.ExecutorService @@ -27,7 +30,7 @@ import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit @Suppress("UndocumentedPublicClass") -class Base64Serializer private constructor( +internal class Base64Serializer private constructor( private val threadPoolExecutor: ExecutorService, private val drawableUtils: DrawableUtils, private val base64Utils: Base64Utils, @@ -37,6 +40,8 @@ class Base64Serializer private constructor( private val logger: InternalLogger ) { private var asyncImageProcessingCallback: AsyncImageProcessingCallback? = null + private var isCacheRegisteredForCallbacks: Boolean = false + private var isBitmapPoolRegisteredForCallbacks: Boolean = false // region internal @@ -48,20 +53,32 @@ class Base64Serializer private constructor( imageWireframe: MobileSegment.Wireframe.ImageWireframe ) { registerCacheForCallbacks(applicationContext) + registerBitmapPoolForCallbacks(applicationContext) asyncImageProcessingCallback?.startProcessingImage() + var shouldCacheBitmap = false val cachedBase64 = base64LRUCache?.get(drawable) if (cachedBase64 != null) { finalizeRecordedDataItem(cachedBase64, imageWireframe, asyncImageProcessingCallback) return } - val bitmap = drawableUtils.createBitmapOfApproxSizeFromDrawable( - applicationContext, - drawable, - displayMetrics - ) + 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() @@ -70,7 +87,7 @@ class Base64Serializer private constructor( Runnable { @Suppress("ThreadSafety") // this runs inside an executor - serialiseBitmap(drawable, bitmap, imageWireframe, asyncImageProcessingCallback) + serialiseBitmap(drawable, bitmap, shouldCacheBitmap, imageWireframe, asyncImageProcessingCallback) }.let { executeRunnable(it) } } @@ -80,6 +97,12 @@ class Base64Serializer private constructor( this.asyncImageProcessingCallback = asyncImageProcessingCallback } + internal fun getDrawableScaledDimensions( + view: ImageView, + drawable: Drawable, + density: Float + ): DrawableDimensions = drawableUtils.getDrawableScaledDimensions(view, drawable, density) + // endregion // region testing @@ -95,10 +118,11 @@ class Base64Serializer private constructor( private fun serialiseBitmap( drawable: Drawable, bitmap: Bitmap, + shouldCacheBitmap: Boolean, imageWireframe: MobileSegment.Wireframe.ImageWireframe, asyncImageProcessingCallback: AsyncImageProcessingCallback? ) { - val base64String = convertBmpToBase64(drawable, bitmap) + val base64String = convertBmpToBase64(drawable, bitmap, shouldCacheBitmap) finalizeRecordedDataItem(base64String, imageWireframe, asyncImageProcessingCallback) } @@ -120,8 +144,16 @@ class Base64Serializer private constructor( } } + @MainThread + private fun registerBitmapPoolForCallbacks(applicationContext: Context) { + if (isBitmapPoolRegisteredForCallbacks) return + + applicationContext.registerComponentCallbacks(bitmapPool) + isBitmapPoolRegisteredForCallbacks = true + } + @WorkerThread - private fun convertBmpToBase64(drawable: Drawable, bitmap: Bitmap): String { + private fun convertBmpToBase64(drawable: Drawable, bitmap: Bitmap, shouldCacheBitmap: Boolean): String { val base64Result: String val byteArray = webPImageCompression.compressBitmap(bitmap) @@ -133,7 +165,9 @@ class Base64Serializer private constructor( base64LRUCache?.put(drawable, base64Result) } - bitmapPool?.put(bitmap) + if (shouldCacheBitmap) { + bitmapPool?.put(bitmap) + } return base64Result } @@ -205,11 +239,4 @@ class Base64Serializer private constructor( } // endregion - - internal companion object { - // The cache is a singleton, so we want to share this flag among - // all instances so that it's registered only once - @VisibleForTesting - internal var isCacheRegisteredForCallbacks: Boolean = false - } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression.kt index e3f2e1c32e..884e22aff3 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageCompression.kt @@ -12,7 +12,7 @@ import java.io.ByteArrayOutputStream /** * Interface for handling image compression formats. */ -interface ImageCompression { +internal interface ImageCompression { /** * Get the mimetype for the image format. 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 new file mode 100644 index 0000000000..f3a4ba579f --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelper.kt @@ -0,0 +1,71 @@ +/* + * 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.base64 + +import android.graphics.drawable.Drawable +import android.view.View +import androidx.annotation.MainThread +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator + +internal class ImageWireframeHelper( + private val imageCompression: ImageCompression = WebPImageCompression(), + private val uniqueIdentifierGenerator: UniqueIdentifierGenerator = UniqueIdentifierGenerator, + private val base64Serializer: Base64Serializer +) { + @MainThread + internal fun createImageWireframe( + view: View, + index: Int, + x: Long, + y: Long, + width: Long, + height: Long, + drawable: Drawable? = null, + shapeStyle: MobileSegment.ShapeStyle? = null, + border: MobileSegment.ShapeBorder? = null, + prefix: String = DRAWABLE_CHILD_NAME + ): MobileSegment.Wireframe.ImageWireframe? { + val id = uniqueIdentifierGenerator.resolveChildUniqueIdentifier(view, prefix + index) + + @Suppress("ComplexCondition") + if (drawable == null || id == null || drawable.intrinsicWidth < 0 || drawable.intrinsicHeight < 0) { + return null + } + + val displayMetrics = view.resources.displayMetrics + val applicationContext = view.context.applicationContext + val mimeType = imageCompression.getMimeType() + + val imageWireframe = + MobileSegment.Wireframe.ImageWireframe( + id = id, + x = x, + y = y, + width = width, + height = height, + shapeStyle = shapeStyle, + border = border, + base64 = "", + mimeType = mimeType, + isEmpty = true + ) + + base64Serializer.handleBitmap( + applicationContext = applicationContext, + displayMetrics = displayMetrics, + drawable = drawable, + imageWireframe = imageWireframe + ) + + return imageWireframe + } + + private companion object { + private const val DRAWABLE_CHILD_NAME = "drawable" + } +} 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 44529019d1..929f5fcb00 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 @@ -15,7 +15,7 @@ import java.io.ByteArrayOutputStream /** * Handle webp image compression. */ -class WebPImageCompression internal constructor() : ImageCompression { +internal class WebPImageCompression : ImageCompression { override fun getMimeType(): String? = MimeTypeMap.getSingleton().getMimeTypeFromExtension(WEBP_EXTENSION) 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 beca003604..0f7b94da37 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 @@ -6,20 +6,18 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper -import android.content.Context import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.InsetDrawable import android.graphics.drawable.RippleDrawable import android.os.Build -import android.util.DisplayMetrics import android.view.View -import androidx.annotation.MainThread import com.datadog.android.sessionreplay.internal.AsyncImageProcessingCallback import com.datadog.android.sessionreplay.internal.recorder.GlobalBounds +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.ImageCompression -import com.datadog.android.sessionreplay.internal.recorder.base64.WebPImageCompression +import com.datadog.android.sessionreplay.internal.recorder.base64.ImageWireframeHelper +import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.StringUtils import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator @@ -29,27 +27,32 @@ import com.datadog.android.sessionreplay.utils.ViewUtils abstract class BaseWireframeMapper( private val stringUtils: StringUtils = StringUtils, private val viewUtils: ViewUtils = ViewUtils -) : WireframeMapper, AsyncImageProcessingCallback { +) : WireframeMapper, AsyncImageProcessingCallback { private var base64Serializer: Base64Serializer? = null - private var webPImageCompression: ImageCompression = WebPImageCompression() - private var uniqueIdentifierGenerator: UniqueIdentifierGenerator = UniqueIdentifierGenerator + private var imageWireframeHelper: ImageWireframeHelper? = null + private var uniqueIdentifierGenerator = UniqueIdentifierGenerator internal constructor( - base64Serializer: Base64Serializer? = null, - webPImageCompression: ImageCompression? = null, - uniqueIdentifierGenerator: UniqueIdentifierGenerator? = null + base64Serializer: Base64Serializer, + imageWireframeHelper: ImageWireframeHelper, + uniqueIdentifierGenerator: UniqueIdentifierGenerator ) : this() { - base64Serializer?.let { - this.base64Serializer = it - } + this.base64Serializer = base64Serializer + this.imageWireframeHelper = imageWireframeHelper + this.uniqueIdentifierGenerator = uniqueIdentifierGenerator + } - webPImageCompression?.let { - this.webPImageCompression = it - } + /** + * Maps the [View] into a list of [MobileSegment.Wireframe]. + */ + override fun map(view: T, mappingContext: MappingContext): List { + val wireframes = mutableListOf() - uniqueIdentifierGenerator?.let { - this.uniqueIdentifierGenerator = it + resolveViewBackground(view)?.let { + wireframes.add(it) } + + return wireframes } /** @@ -104,45 +107,91 @@ abstract class BaseWireframeMapper( } } - /** - * Resolve a unique identifier for a view. - */ - protected fun resolveChildDrawableUniqueIdentifier(view: View): Long? = - uniqueIdentifierGenerator.resolveChildUniqueIdentifier(view, DRAWABLE_CHILD_NAME) - - /** - * Resolve a mimetype from an extension. - */ - protected fun getWebPMimeType(): String? = - webPImageCompression.getMimeType() - - /** - * Resolve drawable and update image wireframe. - */ - @MainThread - protected fun handleBitmap( - applicationContext: Context, - displayMetrics: DisplayMetrics, - drawable: Drawable, - imageWireframe: MobileSegment.Wireframe.ImageWireframe - ) = base64Serializer?.handleBitmap( - applicationContext, - displayMetrics, - drawable, - imageWireframe - ) - internal fun registerAsyncImageProcessingCallback( asyncImageProcessingCallback: AsyncImageProcessingCallback ) { base64Serializer?.registerAsyncLoadingCallback(asyncImageProcessingCallback) } + private fun resolveViewBackground( + view: View + ): MobileSegment.Wireframe? { + val (shapeStyle, border) = view.background?.resolveShapeStyleAndBorder(view.alpha) + ?: (null to null) + + 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() + + return if (border == null && shapeStyle == null) { + resolveBackgroundAsImageWireframe( + view = view, + bounds = bounds, + width = width, + height = height + ) + } else { + resolveBackgroundAsShapeWireframe( + view = view, + bounds = bounds, + width = width, + height = height, + shapeStyle = shapeStyle, + border = border + ) + } + } + + private fun resolveBackgroundAsShapeWireframe( + view: View, + bounds: GlobalBounds, + width: Long, + height: Long, + shapeStyle: MobileSegment.ShapeStyle?, + border: MobileSegment.ShapeBorder? + ): MobileSegment.Wireframe.ShapeWireframe? { + val id = uniqueIdentifierGenerator.resolveChildUniqueIdentifier(view, PREFIX_BACKGROUND_DRAWABLE) + ?: return null + + return MobileSegment.Wireframe.ShapeWireframe( + id, + x = bounds.x, + y = bounds.y, + width = width, + height = height, + shapeStyle = shapeStyle, + border = border + ) + } + + private fun resolveBackgroundAsImageWireframe( + view: View, + bounds: GlobalBounds, + width: Long, + height: Long + ): MobileSegment.Wireframe? { + @Suppress("ThreadSafety") // TODO REPLAY-1861 caller thread of .map is unknown? + return imageWireframeHelper?.createImageWireframe( + view = view, + 0, + x = bounds.x, + y = bounds.y, + width, + height, + view.background, + shapeStyle = null, + border = null, + prefix = PREFIX_BACKGROUND_DRAWABLE + ) + } + override fun startProcessingImage() {} override fun finishProcessingImage() {} companion object { internal const val OPAQUE_ALPHA_VALUE: Int = 255 - private const val DRAWABLE_CHILD_NAME = "drawable" + private const val PREFIX_BACKGROUND_DRAWABLE = "backgroundDrawable" } } 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/ImageButtonMapper.kt index c580dc6e90..bc96df9e6b 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/ImageButtonMapper.kt @@ -6,97 +6,59 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper -import android.graphics.drawable.Drawable import android.widget.ImageButton -import com.datadog.android.sessionreplay.internal.recorder.GlobalBounds 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.ImageCompression -import com.datadog.android.sessionreplay.internal.recorder.base64.WebPImageCompression +import com.datadog.android.sessionreplay.internal.recorder.base64.ImageWireframeHelper +import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator internal class ImageButtonMapper( - webPImageCompression: ImageCompression = WebPImageCompression(), - base64Serializer: Base64Serializer = Base64Serializer.Builder().build(), - uniqueIdentifierGenerator: UniqueIdentifierGenerator = UniqueIdentifierGenerator + private val base64Serializer: Base64Serializer, + private val imageWireframeHelper: ImageWireframeHelper, + private val uniqueIdentifierGenerator: UniqueIdentifierGenerator ) : BaseWireframeMapper( - webPImageCompression = webPImageCompression, base64Serializer = base64Serializer, + imageWireframeHelper = imageWireframeHelper, uniqueIdentifierGenerator = uniqueIdentifierGenerator ) { override fun map( view: ImageButton, mappingContext: MappingContext ): List { - val resources = view.resources - val drawable = view.drawable?.constantState?.newDrawable(resources) - val id = resolveChildDrawableUniqueIdentifier(view) - - if (drawable == null || id == null) return emptyList() - - val screenDensity = mappingContext.systemInformation.screenDensity - val bounds = resolveViewGlobalBounds(view, screenDensity) - - val (shapeStyle, border) = view.background?.resolveShapeStyleAndBorder(view.alpha) - ?: (null to null) - val wireframes = mutableListOf() - // if the drawable has no width/height then there's no point trying to get a bitmap - if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) { - val imageWireframe = resolveImageWireframe( - view, - id, - bounds, - shapeStyle, - border, - drawable - ) - wireframes.add(imageWireframe) - } - - return wireframes - } + // add background wireframes if any + wireframes.addAll(super.map(view, mappingContext)) - // region internal + val drawable = view.drawable?.current ?: return wireframes + val resources = view.resources + val density = resources.displayMetrics.density + val bounds = resolveViewGlobalBounds(view, density) - private fun resolveImageWireframe( - view: ImageButton, - id: Long, - bounds: GlobalBounds, - shapeStyle: MobileSegment.ShapeStyle?, - border: MobileSegment.ShapeBorder?, - drawable: Drawable - ): MobileSegment.Wireframe.ImageWireframe { - val mimeType = getWebPMimeType() - val displayMetrics = view.resources.displayMetrics - val applicationContext = view.context.applicationContext + val (scaledDrawableWidth, scaledDrawableHeight) = + base64Serializer.getDrawableScaledDimensions(view, drawable, density) - val imageWireframe = - MobileSegment.Wireframe.ImageWireframe( - id = id, - x = bounds.x, - y = bounds.y, - width = bounds.width, - height = bounds.height, - shapeStyle = shapeStyle, - border = border, - base64 = "", - mimeType = mimeType, - isEmpty = true - ) + val centerX = (bounds.x + view.width.densityNormalized(density) / 2) - (scaledDrawableWidth / 2) + val centerY = (bounds.y + view.height.densityNormalized(density) / 2) - (scaledDrawableHeight / 2) + // resolve foreground @Suppress("ThreadSafety") // TODO REPLAY-1861 caller thread of .map is unknown? - handleBitmap( - applicationContext = applicationContext, - displayMetrics = displayMetrics, - drawable = drawable, - imageWireframe = imageWireframe - ) + imageWireframeHelper.createImageWireframe( + view = view, + index = wireframes.size, + x = centerX, + y = centerY, + width = scaledDrawableWidth, + height = scaledDrawableHeight, + drawable = drawable.constantState?.newDrawable(resources), + shapeStyle = null, + border = null + )?.let { + wireframes.add(it) + } - return imageWireframe + return wireframes } - - // endregion } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/QueueableViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/QueueableViewMapper.kt index 3dbeeb509a..ec13e8cef4 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/QueueableViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/QueueableViewMapper.kt @@ -18,7 +18,7 @@ internal class QueueableViewMapper( ) : BaseWireframeMapper(), AsyncImageProcessingCallback { override fun map(view: View, mappingContext: MappingContext): List { - (mapper as? BaseWireframeMapper)?.registerAsyncImageProcessingCallback(this) + (mapper as? BaseWireframeMapper)?.registerAsyncImageProcessingCallback(this) return mapper.map(view, mappingContext) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableDimensions.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableDimensions.kt new file mode 100644 index 0000000000..42d5723e95 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/DrawableDimensions.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.sessionreplay.internal.utils + +internal data class DrawableDimensions( + val width: Long, + val height: Long +) 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 5246194af0..64aefb51be 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 @@ -7,16 +7,17 @@ package com.datadog.android.sessionreplay.internal.utils -import android.content.Context import android.graphics.Bitmap import android.graphics.Bitmap.Config import android.graphics.Color import android.graphics.PorterDuff import android.graphics.drawable.Drawable import android.util.DisplayMetrics +import android.widget.ImageView +import android.widget.ImageView.ScaleType import androidx.annotation.MainThread -import androidx.annotation.VisibleForTesting 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 kotlin.math.sqrt @@ -34,14 +35,11 @@ internal class DrawableUtils( @MainThread @Suppress("ReturnCount") internal fun createBitmapOfApproxSizeFromDrawable( - applicationContext: Context, drawable: Drawable, displayMetrics: DisplayMetrics, requestedSizeInBytes: Int = MAX_BITMAP_SIZE_IN_BYTES, config: Config = Config.ARGB_8888 ): Bitmap? { - registerBitmapPoolForCallbacks(applicationContext) - val (width, height) = getScaledWidthAndHeight(drawable, requestedSizeInBytes) val bitmap = getBitmapBySize(displayMetrics, width, height, config) ?: return null @@ -56,6 +54,53 @@ internal class DrawableUtils( return bitmap } + internal fun getDrawableScaledDimensions( + view: ImageView, + 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 + ) + + 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 + } + } + } + + return DrawableDimensions(scaledDrawableWidth, scaledDrawableHeight) + } + private fun getScaledWidthAndHeight( drawable: Drawable, requestedSizeInBytes: Int @@ -91,21 +136,8 @@ internal class DrawableUtils( bitmapPool?.getBitmapByProperties(width, height, config) ?: bitmapWrapper.createBitmap(displayMetrics, width, height, config) - @MainThread - private fun registerBitmapPoolForCallbacks(applicationContext: Context) { - if (isBitmapPoolRegisteredForCallbacks) return - - applicationContext.registerComponentCallbacks(bitmapPool) - isBitmapPoolRegisteredForCallbacks = true - } - internal companion object { private const val MAX_BITMAP_SIZE_IN_BYTES = 10240 // 10kb private const val ARGB_8888_PIXEL_SIZE_BYTES = 4 - - // The cache is a singleton, so we want to share this flag among - // all instances so that it's registered only once - @VisibleForTesting - internal var isBitmapPoolRegisteredForCallbacks: Boolean = false } } 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 890554f84d..21534a23ea 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 @@ -8,9 +8,12 @@ package com.datadog.android.sessionreplay.internal.recorder.base64 import android.content.Context import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable 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.AsyncImageProcessingCallback @@ -18,6 +21,7 @@ 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.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -80,6 +84,9 @@ internal class Base64SerializerTest { @Mock lateinit var mockDisplayMetrics: DisplayMetrics + @Mock + lateinit var mockImageView: ImageView + @Mock lateinit var mockDrawable: Drawable @@ -92,6 +99,9 @@ internal class Base64SerializerTest { @Mock lateinit var mockBitmapPool: BitmapPool + @FloatForgery(min = 0f, max = 1f) + var mockDensity: Float = 0f + @Forgery lateinit var fakeImageWireframe: MobileSegment.Wireframe.ImageWireframe @@ -109,7 +119,6 @@ internal class Base64SerializerTest { whenever( mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( - applicationContext = any(), drawable = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), @@ -146,7 +155,6 @@ internal class Base64SerializerTest { // Given whenever( mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( - applicationContext = any(), drawable = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), @@ -171,7 +179,6 @@ internal class Base64SerializerTest { // Given whenever( mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( - applicationContext = any(), drawable = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), @@ -201,7 +208,6 @@ internal class Base64SerializerTest { whenever( mockDrawableUtils.createBitmapOfApproxSizeFromDrawable( - applicationContext = any(), drawable = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), @@ -225,20 +231,25 @@ internal class Base64SerializerTest { } @Test - fun `M register cache only once for callbacks W handleBitmap() { multiple calls and instances }`() { - // Given - Base64Serializer.isCacheRegisteredForCallbacks = false - val secondInstance = createBase64Serializer() - + fun `M register cache only once for callbacks W handleBitmap() { multiple calls }`() { // When repeat(5) { - secondInstance.handleBitmap( + testedBase64Serializer.handleBitmap( applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, drawable = mockDrawable, imageWireframe = fakeImageWireframe ) + } + // Then + verify(mockApplicationContext, times(1)).registerComponentCallbacks(mockBase64LRUCache) + } + + @Test + fun `M register BitmapPool only once for callbacks W handleBitmap() { multiple calls }`() { + // When + repeat(5) { testedBase64Serializer.handleBitmap( applicationContext = mockApplicationContext, displayMetrics = mockDisplayMetrics, @@ -248,7 +259,7 @@ internal class Base64SerializerTest { } // Then - verify(mockApplicationContext, times(1)).registerComponentCallbacks(mockBase64LRUCache) + verify(mockApplicationContext, times(1)).registerComponentCallbacks(mockBitmapPool) } @Test @@ -266,7 +277,6 @@ internal class Base64SerializerTest { // Then verify(mockDrawableUtils).createBitmapOfApproxSizeFromDrawable( - applicationContext = any(), drawable = any(), displayMetrics = any(), requestedSizeInBytes = anyOrNull(), @@ -317,6 +327,143 @@ internal class Base64SerializerTest { verify(mockBase64LRUCache, times(0)).put(any(), any()) } + @Test + fun `M not use bitmap from bitmapDrawable W handleBitmap() { no bitmap }`() { + // Given + val mockBitmapDrawable = mock() + + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockBitmapDrawable, + imageWireframe = fakeImageWireframe + ) + + // Then + verify(mockDrawableUtils, times(1)).createBitmapOfApproxSizeFromDrawable( + drawable = any(), + displayMetrics = any(), + requestedSizeInBytes = anyOrNull(), + config = anyOrNull() + ) + } + + @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, + imageWireframe = fakeImageWireframe + ) + + // Then + verify(mockDrawableUtils, times(1)).createBitmapOfApproxSizeFromDrawable( + drawable = any(), + displayMetrics = any(), + requestedSizeInBytes = anyOrNull(), + config = anyOrNull() + ) + } + + @Test + fun `M use bitmap from bitmapDrawable W handleBitmap() { has bitmap }`() { + // Given + val mockBitmapDrawable = mock() + whenever(mockBitmap.isRecycled).thenReturn(false) + whenever(mockBitmapDrawable.bitmap).thenReturn(mockBitmap) + + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockBitmapDrawable, + imageWireframe = fakeImageWireframe + ) + + // Then + verifyNoInteractions(mockDrawableUtils) + } + + @Test + fun `M not cache image when caching false W handleBitmap() { from BitmapDrawable with bitmap }`() { + // Given + val mockBitmapDrawable = mock() + whenever(mockBitmap.isRecycled).thenReturn(false) + whenever(mockBitmapDrawable.bitmap).thenReturn(mockBitmap) + + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockBitmapDrawable, + imageWireframe = fakeImageWireframe + ) + + // Then + verify(mockBitmapPool, times(0)).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 + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockBitmapDrawable, + imageWireframe = fakeImageWireframe + ) + + // Then + verify(mockBitmapPool, times(1)).put(any()) + } + + @Test + fun `M cache bitmap W handleBitmap() { not a BitmapDrawable }`() { + // Given + val mockBitmapDrawable = mock() + + // When + testedBase64Serializer.handleBitmap( + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + drawable = mockBitmapDrawable, + imageWireframe = fakeImageWireframe + ) + + // 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 + ) + } + 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/ImageWireframeHelperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelperTest.kt new file mode 100644 index 0000000000..2cf47a5fa8 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/base64/ImageWireframeHelperTest.kt @@ -0,0 +1,246 @@ +/* + * 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.base64 + +import android.content.Context +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.util.DisplayMetrics +import android.view.View +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.recorder.GlobalBounds +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +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 ImageWireframeHelperTest { + private lateinit var testedHelper: ImageWireframeHelper + + @Mock + lateinit var mockBase64Serializer: Base64Serializer + + @Mock + lateinit var mockUniqueIdentifierGenerator: UniqueIdentifierGenerator + + @Mock + lateinit var mockImageCompression: ImageCompression + + @Mock + lateinit var mockView: View + + @Mock + lateinit var mockDrawable: Drawable + + @Mock + lateinit var mockBounds: GlobalBounds + + @Mock + lateinit var mockResources: Resources + + @Mock + lateinit var mockDisplayMetrics: DisplayMetrics + + @Mock + lateinit var mockContext: Context + + @LongForgery + var fakeGeneratedIdentifier: Long = 0L + + @LongForgery(min = 1, max = 300) + var fakeDrawableWidth: Long = 0L + + @LongForgery(min = 1, max = 300) + var fakeDrawableHeight: Long = 0L + + private lateinit var fakeDrawableXY: Pair + + @StringForgery + var fakeMimeType: String = "" + + @BeforeEach + fun `set up`(forge: Forge) { + val fakeScreenWidth = 1000 + val fakeScreenHeight = 1000 + + val randomXLocation = forge.aLong(min = 0, max = fakeScreenWidth - fakeDrawableWidth) + val randomYLocation = forge.aLong(min = 0, max = fakeScreenHeight - fakeDrawableHeight) + fakeDrawableXY = Pair(randomXLocation, randomYLocation) + + whenever(mockUniqueIdentifierGenerator.resolveChildUniqueIdentifier(mockView, "drawable")) + .thenReturn(fakeGeneratedIdentifier) + + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth.toInt()) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight.toInt()) + + whenever(mockView.resources).thenReturn(mockResources) + whenever(mockResources.displayMetrics).thenReturn(mockDisplayMetrics) + + whenever(mockView.context).thenReturn(mockContext) + whenever(mockContext.applicationContext).thenReturn(mockContext) + + whenever(mockImageCompression.getMimeType()).thenReturn(fakeMimeType) + + whenever(mockBounds.width).thenReturn(fakeDrawableWidth) + whenever(mockBounds.height).thenReturn(fakeDrawableHeight) + whenever(mockBounds.x).thenReturn(fakeDrawableXY.first) + whenever(mockBounds.y).thenReturn(fakeDrawableXY.second) + + testedHelper = ImageWireframeHelper( + imageCompression = mockImageCompression, + uniqueIdentifierGenerator = mockUniqueIdentifierGenerator, + base64Serializer = mockBase64Serializer + ) + } + + @Test + fun `M return null W createImageWireframe() { drawable is null }`() { + // When + val wireframe = testedHelper.createImageWireframe( + view = mockView, + index = 0, + x = 0, + y = 0, + width = 0, + height = 0, + drawable = null, + shapeStyle = null, + border = null + ) + + // Then + assertThat(wireframe).isNull() + } + + @Test + fun `M return null W createImageWireframe() { id is null }`() { + // Given + whenever(mockUniqueIdentifierGenerator.resolveChildUniqueIdentifier(any(), any())) + .thenReturn(null) + + // When + val wireframe = testedHelper.createImageWireframe( + view = mockView, + index = 0, + x = 0, + y = 0, + width = 0, + height = 0, + drawable = mockDrawable, + shapeStyle = null, + border = null + ) + + // Then + assertThat(wireframe).isNull() + } + + @Test + fun `M return null W createImageWireframe() { drawable has no intrinsic width }`() { + // Given + whenever(mockDrawable.intrinsicWidth).thenReturn(-1) + + // When + val wireframe = testedHelper.createImageWireframe( + view = mockView, + index = 0, + x = 0, + y = 0, + width = 0, + height = 0, + drawable = mockDrawable, + shapeStyle = null, + border = null + ) + + // Then + assertThat(wireframe).isNull() + } + + @Test + fun `M return null W createImageWireframe() { drawable has no intrinsic height }`() { + // Given + whenever(mockDrawable.intrinsicHeight).thenReturn(-1) + + // When + val wireframe = testedHelper.createImageWireframe( + view = mockView, + index = 0, + x = 0, + y = 0, + width = 0, + height = 0, + drawable = mockDrawable, + shapeStyle = null, + border = null + ) + + // Then + assertThat(wireframe).isNull() + } + + @Test + fun `M return wireframe W createImageWireframe()`( + @LongForgery id: Long + ) { + // Given + whenever( + mockUniqueIdentifierGenerator + .resolveChildUniqueIdentifier(any(), any()) + ) + .thenReturn(id) + + val expectedWireframe = MobileSegment.Wireframe.ImageWireframe( + id = id, + x = fakeDrawableXY.first, + y = fakeDrawableXY.second, + width = fakeDrawableWidth, + height = fakeDrawableHeight, + shapeStyle = null, + border = null, + base64 = "", + mimeType = fakeMimeType, + isEmpty = true + ) + + // When + val wireframe = testedHelper.createImageWireframe( + view = mockView, + index = 0, + x = fakeDrawableXY.first, + y = fakeDrawableXY.second, + width = fakeDrawableWidth, + height = fakeDrawableHeight, + drawable = mockDrawable, + shapeStyle = null, + border = null + ) + + // Then + assertThat(wireframe).isEqualTo(expectedWireframe) + } +} 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/ImageButtonMapperTest.kt index e97eb6f1d6..42bc39ace8 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/ImageButtonMapperTest.kt @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper import android.content.Context import android.content.res.Resources +import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable.ConstantState import android.util.DisplayMetrics @@ -18,10 +19,13 @@ 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.utils.DrawableDimensions import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator import com.datadog.android.sessionreplay.utils.ViewUtils import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.LongForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -33,6 +37,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.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -51,6 +57,9 @@ internal class ImageButtonMapperTest { @Mock lateinit var mockImageButton: ImageButton + @Mock + lateinit var mockImageWireframeHelper: ImageWireframeHelper + @Mock lateinit var mockMappingContext: MappingContext @@ -94,14 +103,19 @@ internal class ImageButtonMapperTest { private val fakeMimeType = Forge().aString() + lateinit var expectedWireframe: MobileSegment.Wireframe.ImageWireframe + @BeforeEach fun setup(forge: Forge) { + whenever(mockImageButton.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(mockDrawable.intrinsicWidth).thenReturn(forge.aPositiveInt()) whenever(mockDrawable.intrinsicHeight).thenReturn(forge.aPositiveInt()) @@ -114,92 +128,186 @@ internal class ImageButtonMapperTest { whenever(mockResources.displayMetrics).thenReturn(mockDisplayMetrics) whenever(mockImageButton.resources).thenReturn(mockResources) - whenever(mockImageButton.background).thenReturn(mockBackground) - whenever(mockContext.applicationContext).thenReturn(mockContext) whenever(mockImageButton.context).thenReturn(mockContext) + whenever(mockBackground.current).thenReturn(mockBackground) 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(), + shapeStyle = null, + border = null, + base64 = "", + mimeType = fakeMimeType, + isEmpty = true + ) + + whenever(mockBase64Serializer.getDrawableScaledDimensions(any(), any(), any())) + .thenReturn(DrawableDimensions(0, 0)) + testedMapper = ImageButtonMapper( - webPImageCompression = mockWebPImageCompression, base64Serializer = mockBase64Serializer, + imageWireframeHelper = mockImageWireframeHelper, uniqueIdentifierGenerator = mockUniqueIdentifierGenerator ) } @Test - fun `M return emptylist W map() { and could not get view id }`() { + fun `M return foreground wireframe W map() { no background }`() { // Given - whenever(mockUniqueIdentifierGenerator.resolveChildUniqueIdentifier(any(), any())) - .thenReturn(null) - - // When - val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + whenever(mockImageButton.background).thenReturn(null) - // Then - assertThat(wireframes).isEmpty() - } - - @Test - fun `M return emptylist W map() { and could not get drawable }`() { - // Given - whenever(mockImageButton.drawable).thenReturn(null) + whenever( + mockImageWireframeHelper.createImageWireframe( + mockImageButton, + 0, + mockGlobalBounds.x, + mockGlobalBounds.y, + mockImageButton.width.toLong(), + mockImageButton.height.toLong(), + mockDrawable.constantState?.newDrawable(mockResources), + null, + null + ) + ).thenReturn(expectedWireframe) // When val wireframes = testedMapper.map(mockImageButton, mockMappingContext) // Then - assertThat(wireframes).isEmpty() + assertThat(wireframes.size).isEqualTo(1) + assertThat(wireframes[0]).isEqualTo(expectedWireframe) } @Test - fun `M return emptylist W map() { drawable has no intrinsicWidth }`() { + fun `M resolve background images W map() { with background }`( + @LongForgery id: Long + ) { // Given - whenever(mockDrawable.intrinsicWidth).thenReturn(-1) + val expectedBackgroundWireframe = MobileSegment.Wireframe.ImageWireframe( + id = id, + x = mockGlobalBounds.x, + y = mockGlobalBounds.y, + width = mockImageButton.width.toLong(), + height = mockImageButton.height.toLong(), + shapeStyle = null, + border = null, + base64 = "", + mimeType = fakeMimeType, + isEmpty = true + ) + + whenever(mockImageButton.background).thenReturn(mockBackground) + mockCreateImageWireframe( + expectedBackgroundWireframe, + expectedWireframe + ) // When val wireframes = testedMapper.map(mockImageButton, mockMappingContext) // Then - assertThat(wireframes).isEmpty() + assertThat(wireframes.size).isEqualTo(2) + assertThat(wireframes[0]).isEqualTo(expectedBackgroundWireframe) + assertThat(wireframes[1]).isEqualTo(expectedWireframe) } @Test - fun `M return emptylist W map() { drawable has no intrinsicHeight }`() { + fun `M set index to 1 W map() { has background wireframe }`( + @LongForgery id: Long + ) { // Given - whenever(mockDrawable.intrinsicHeight).thenReturn(-1) + val expectedBackgroundWireframe = MobileSegment.Wireframe.ImageWireframe( + id = id, + x = mockGlobalBounds.x, + y = mockGlobalBounds.y, + width = mockImageButton.width.toLong(), + height = mockImageButton.height.toLong(), + shapeStyle = null, + border = null, + base64 = "", + mimeType = fakeMimeType, + isEmpty = true + ) + whenever(mockImageButton.background).thenReturn(mockBackground) + + mockCreateImageWireframe( + expectedBackgroundWireframe, + expectedWireframe + ) // When - val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + testedMapper.map(mockImageButton, mockMappingContext) // Then - assertThat(wireframes).isEmpty() + val captor = argumentCaptor() + verify(mockImageWireframeHelper, times(2)).createImageWireframe( + any(), + captor.capture(), + any(), + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + val allValues = captor.allValues + assertThat(allValues[0]).isEqualTo(0) + assertThat(allValues[1]).isEqualTo(1) } @Test - fun `M set null shapestyle and border W map() { view without background }`() { + fun `M set index to 0 W map() { no background wireframe }`() { // Given - whenever(mockImageButton.background).thenReturn(null) + whenever(mockImageButton.background).thenReturn(mockBackground) + + mockCreateImageWireframe( + null, + expectedWireframe + ) // When - val wireframes = testedMapper.map(mockImageButton, mockMappingContext) - val actualWireframe = wireframes[0] as? MobileSegment.Wireframe.ShapeWireframe + testedMapper.map(mockImageButton, mockMappingContext) // Then - assertThat(actualWireframe?.shapeStyle).isNull() - assertThat(actualWireframe?.border).isNull() + val captor = argumentCaptor() + verify(mockImageWireframeHelper, times(2)).createImageWireframe( + any(), + captor.capture(), + any(), + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + val allValues = captor.allValues + assertThat(allValues[0]).isEqualTo(0) + assertThat(allValues[1]).isEqualTo(0) } @Test - fun `M return expected wireframe W map()`() { + fun `M return background of type ImageWireframe W map() { no shapestyle or border }`( + @LongForgery id: Long + ) { // Given - val expectedWireframe = MobileSegment.Wireframe.ImageWireframe( - id = fakeId, - width = mockGlobalBounds.width, - height = mockGlobalBounds.height, + whenever(mockImageButton.background).thenReturn(mockBackground) + + val expectedBackgroundWireframe = MobileSegment.Wireframe.ImageWireframe( + id = id, x = mockGlobalBounds.x, y = mockGlobalBounds.y, + width = mockImageButton.width.toLong(), + height = mockImageButton.height.toLong(), shapeStyle = null, border = null, base64 = "", @@ -207,23 +315,73 @@ internal class ImageButtonMapperTest { isEmpty = true ) + mockCreateImageWireframe( + expectedBackgroundWireframe, + expectedWireframe + ) + // When val wireframes = testedMapper.map(mockImageButton, mockMappingContext) - val actualWireframe = wireframes[0] // Then - assertThat(actualWireframe).isEqualTo(expectedWireframe) - verify(mockBase64Serializer, times(1)) - .handleBitmap(any(), any(), any(), any()) + assertThat(wireframes[0]::class.java).isEqualTo(MobileSegment.Wireframe.ImageWireframe::class.java) } @Test - fun `M call handleBitmap W map()`() { + fun `M return background of type ShapeWireframe W map() { has shapestyle or border }`( + @Mock mockColorDrawable: ColorDrawable + ) { + // Given + whenever(mockImageButton.background).thenReturn(mockColorDrawable) + // When - testedMapper.map(mockImageButton, mockMappingContext) + val wireframes = testedMapper.map(mockImageButton, mockMappingContext) // Then - verify(mockBase64Serializer, times(1)) - .handleBitmap(any(), any(), any(), any()) + assertThat(wireframes[0]::class.java).isEqualTo(MobileSegment.Wireframe.ShapeWireframe::class.java) + } + + @Test + fun `M return no background W map() { cant resolve id for shapeDrawable }`( + @Mock mockColorDrawable: ColorDrawable + ) { + // Given + whenever(mockImageButton.background).thenReturn(mockColorDrawable) + + whenever(mockUniqueIdentifierGenerator.resolveChildUniqueIdentifier(any(), any())) + .thenReturn(null) + + mockCreateImageWireframe( + expectedWireframe, + null + ) + + // When + val wireframes = testedMapper.map(mockImageButton, mockMappingContext) + + // Then + assertThat(wireframes.size).isEqualTo(1) + } + + private fun mockCreateImageWireframe( + expectedFirstWireframe: MobileSegment.Wireframe.ImageWireframe?, + expectedSecondWireframe: MobileSegment.Wireframe.ImageWireframe? + ) { + whenever( + mockImageWireframeHelper.createImageWireframe( + any(), + any(), + any(), + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ) + .thenReturn(expectedFirstWireframe) + .thenReturn(expectedSecondWireframe) } } 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 df86579b1d..0b54aaabb3 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 @@ -6,15 +6,18 @@ package com.datadog.android.sessionreplay.internal.utils -import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.drawable.Drawable import android.util.DisplayMetrics +import android.widget.ImageView import com.datadog.android.sessionreplay.forge.ForgeConfigurator 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 import org.assertj.core.api.Assertions.assertThat @@ -28,7 +31,6 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -51,9 +53,6 @@ internal class DrawableUtilsTest { @Mock private lateinit var mockDrawable: Drawable - @Mock - private lateinit var mockApplicationContext: Context - @Mock private lateinit var mockBitmapWrapper: BitmapWrapper @@ -100,7 +99,6 @@ internal class DrawableUtilsTest { // When testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockApplicationContext, mockDrawable, mockDisplayMetrics, requestedSize, @@ -122,17 +120,19 @@ internal class DrawableUtilsTest { } @Test - fun `M set height higher W createBitmapFromDrawableOfApproxSize() { when resizing }`() { + fun `M set height higher W createBitmapFromDrawableOfApproxSize() { when resizing }`( + @IntForgery(min = 0, max = 500) viewWidth: Int, + @IntForgery(min = 501, max = 1000) viewHeight: Int + ) { // Given - whenever(mockDrawable.intrinsicWidth).thenReturn(900) - whenever(mockDrawable.intrinsicHeight).thenReturn(1000) + whenever(mockDrawable.intrinsicWidth).thenReturn(viewWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(viewHeight) val argumentCaptor = argumentCaptor() val displayMetricsCaptor = argumentCaptor() // When testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockApplicationContext, mockDrawable, mockDisplayMetrics ) @@ -151,17 +151,19 @@ internal class DrawableUtilsTest { } @Test - fun `M set width higher W createBitmapFromDrawableOfApproxSize() { when resizing }`() { + fun `M set width higher W createBitmapFromDrawableOfApproxSize() { when resizing }`( + @IntForgery(min = 501, max = 1000) viewWidth: Int, + @IntForgery(min = 0, max = 500) viewHeight: Int + ) { // Given - whenever(mockDrawable.intrinsicWidth).thenReturn(1000) - whenever(mockDrawable.intrinsicHeight).thenReturn(900) + whenever(mockDrawable.intrinsicWidth).thenReturn(viewWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(viewHeight) val argumentCaptor = argumentCaptor() val displayMetricsCaptor = argumentCaptor() // When val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockApplicationContext, mockDrawable, mockDisplayMetrics, config = mockConfig @@ -195,7 +197,6 @@ internal class DrawableUtilsTest { // When val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockApplicationContext, mockDrawable, mockDisplayMetrics, config = mockConfig @@ -216,7 +217,6 @@ internal class DrawableUtilsTest { // When val result = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockApplicationContext, mockDrawable, mockDisplayMetrics, config = mockConfig @@ -228,17 +228,19 @@ internal class DrawableUtilsTest { // endregion - fun `M use bitmap from pool W createBitmapFromDrawable() { exists in pool }`() { + fun `M use bitmap from pool W createBitmapFromDrawable() { exists in pool }`( + @IntForgery(min = 1, max = 1000) viewWidth: Int, + @IntForgery(min = 1, max = 1000) viewHeight: Int + ) { // Given val mockBitmapFromPool: Bitmap = mock() - whenever(mockDrawable.intrinsicHeight).thenReturn(200) - whenever(mockDrawable.intrinsicWidth).thenReturn(200) + whenever(mockDrawable.intrinsicWidth).thenReturn(viewWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(viewHeight) whenever(mockBitmapPool.getBitmapByProperties(any(), any(), any())) .thenReturn(mockBitmapFromPool) // When val actualBitmap = testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockApplicationContext, mockDrawable, mockDisplayMetrics, config = mockConfig @@ -249,21 +251,201 @@ internal class DrawableUtilsTest { } @Test - fun `M register BitmapPool for callbacks only once W createBitmapOfApproxSizeFromDrawable()`() { + 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 - DrawableUtils.isBitmapPoolRegisteredForCallbacks = false + 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 - repeat(5) { - testedDrawableUtils.createBitmapOfApproxSizeFromDrawable( - mockApplicationContext, - mockDrawable, - mockDisplayMetrics, - config = mockConfig - ) - } + 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 + ) + + // Then + assertThat(result.width).isEqualTo(expectedWidth) + assertThat(result.height).isEqualTo(expectedHeight) + } + + @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 + ) { + // 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 + + // When + val result = testedDrawableUtils.getDrawableScaledDimensions( + mockImageView, + mockDrawable, + fakeDensity + ) + + // Then + assertThat(result.width).isEqualTo(expectedWidth) + assertThat(result.height).isEqualTo(expectedHeight) + } + + @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 + ) { + // 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 + + // When + val result = testedDrawableUtils.getDrawableScaledDimensions( + mockImageView, + mockDrawable, + fakeDensity + ) // Then - verify(mockApplicationContext, times(1)).registerComponentCallbacks(any()) + assertThat(result.width).isEqualTo(expectedWidth) + assertThat(result.height).isEqualTo(expectedHeight) } } 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 c1dbd6960f..8be9d41f2a 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 @@ -12,8 +12,10 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Button +import android.widget.ImageButton import android.widget.ImageView import android.widget.TextView +import androidx.appcompat.widget.AppCompatImageButton import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import com.datadog.android.sample.R @@ -28,6 +30,8 @@ internal class ImageComponentsFragment : Fragment() { 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 override fun onCreateView( inflater: LayoutInflater, @@ -39,6 +43,8 @@ internal class ImageComponentsFragment : Fragment() { 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) return rootView } @@ -55,6 +61,7 @@ internal class ImageComponentsFragment : Fragment() { loadImageView() loadButton() loadTextView() + loadImageButtonBackground() } private fun loadView() { @@ -105,6 +112,19 @@ internal class ImageComponentsFragment : Fragment() { ) } + private fun loadImageButtonBackground() { + viewModel.fetchRemoteImage( + LARGE_IMAGE_URL, + imageButtonRemote, + object : ImageLoadedCallback { + override fun onImageLoaded(resource: Drawable) { + imageButtonRemote.background = resource + appCompatButtonRemote.background = resource + } + } + ) + } + // endregion companion object { 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 2ac342eb20..ad82c9756c 100644 --- a/sample/kotlin/src/main/res/layout/fragment_image_components.xml +++ b/sample/kotlin/src/main/res/layout/fragment_image_components.xml @@ -185,23 +185,41 @@ - - + + + + + + + + + + + + + + + + From 50f22a94e94610b8d82a6e288f75b08ddb804a4f Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:06:14 +0300 Subject: [PATCH 7/8] fix failing test after merge --- .../android/sessionreplay/internal/utils/InvocationUtilsTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtilsTest.kt index 8b7da19560..9f6f2aab9c 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtilsTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/InvocationUtilsTest.kt @@ -104,6 +104,7 @@ internal class InvocationUtilsTest { target = any(), captor.capture(), anyOrNull(), + anyOrNull(), anyOrNull() ) assertThat(captor.firstValue()).isEqualTo(fakeMessage) From 7ad210bdba03ea3a3d7d5c3c1dacda8a6622b607 Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Wed, 30 Aug 2023 14:47:31 +0300 Subject: [PATCH 8/8] Implement base64 for TextViews and Buttons --- .../api/apiSurface | 4 +- .../sessionreplay/SessionReplayPrivacy.kt | 18 +- .../internal/recorder/ViewUtilsInternal.kt | 73 +++++++ .../recorder/base64/ImageWireframeHelper.kt | 94 ++++++++- .../internal/recorder/mapper/ButtonMapper.kt | 19 +- .../recorder/mapper/ImageButtonMapper.kt | 2 +- .../mapper/MaskInputTextViewMapper.kt | 17 +- .../recorder/mapper/MaskTextViewMapper.kt | 19 +- .../recorder/mapper/TextViewMapper.kt | 92 +++++++-- .../recorder/ViewUtilsInternalTest.kt | 146 ++++++++++++- .../base64/ImageWireframeHelperTest.kt | 191 +++++++++++++++--- .../mapper/BaseTextViewWireframeMapperTest.kt | 128 ++++++++++-- .../recorder/mapper/MaskTextViewMapperTest.kt | 7 +- .../sessionreplay/ImageComponentsFragment.kt | 4 +- .../res/layout/fragment_image_components.xml | 117 ++++++----- 15 files changed, 789 insertions(+), 142 deletions(-) diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index cc390f958f..34f1a7288e 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -36,9 +36,9 @@ class com.datadog.android.sessionreplay.internal.recorder.mapper.MaskInputTextVi constructor() class com.datadog.android.sessionreplay.internal.recorder.mapper.MaskTextViewMapper : TextViewMapper constructor() -open class com.datadog.android.sessionreplay.internal.recorder.mapper.TextViewMapper : BaseWireframeMapper +open class com.datadog.android.sessionreplay.internal.recorder.mapper.TextViewMapper : BaseWireframeMapper constructor() - override fun map(android.widget.TextView, com.datadog.android.sessionreplay.internal.recorder.MappingContext): List + override fun map(android.widget.TextView, com.datadog.android.sessionreplay.internal.recorder.MappingContext): List interface com.datadog.android.sessionreplay.internal.recorder.mapper.WireframeMapper fun map(T, com.datadog.android.sessionreplay.internal.recorder.MappingContext): List object com.datadog.android.sessionreplay.utils.StringUtils 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 64a0eb445d..fadc864b82 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 @@ -106,7 +106,11 @@ enum class SessionReplayPrivacy { when (this) { ALLOW -> { imageMapper = ViewScreenshotWireframeMapper(viewWireframeMapper) - textMapper = TextViewMapper() + textMapper = TextViewMapper( + base64Serializer = base64Serializer, + imageWireframeHelper = imageWireframeHelper, + uniqueIdentifierGenerator = uniqueIdentifierGenerator + ) buttonMapper = ButtonMapper(textMapper) editTextViewMapper = EditTextViewMapper(textMapper) checkedTextViewMapper = CheckedTextViewMapper(textMapper) @@ -118,7 +122,11 @@ enum class SessionReplayPrivacy { } MASK -> { imageMapper = ViewScreenshotWireframeMapper(viewWireframeMapper) - textMapper = MaskTextViewMapper() + textMapper = MaskTextViewMapper( + base64Serializer = base64Serializer, + imageWireframeHelper = imageWireframeHelper, + uniqueIdentifierGenerator = uniqueIdentifierGenerator + ) buttonMapper = ButtonMapper(textMapper) editTextViewMapper = EditTextViewMapper(textMapper) checkedTextViewMapper = MaskCheckedTextViewMapper(textMapper) @@ -130,7 +138,11 @@ enum class SessionReplayPrivacy { } MASK_USER_INPUT -> { imageMapper = ViewScreenshotWireframeMapper(viewWireframeMapper) - textMapper = MaskInputTextViewMapper() + textMapper = MaskInputTextViewMapper( + base64Serializer = base64Serializer, + imageWireframeHelper = imageWireframeHelper, + uniqueIdentifierGenerator = uniqueIdentifierGenerator + ) buttonMapper = ButtonMapper(textMapper) editTextViewMapper = EditTextViewMapper(textMapper) checkedTextViewMapper = MaskCheckedTextViewMapper(textMapper) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewUtilsInternal.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewUtilsInternal.kt index 1e7e2ef662..a22540284d 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewUtilsInternal.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewUtilsInternal.kt @@ -6,10 +6,12 @@ package com.datadog.android.sessionreplay.internal.recorder +import android.graphics.drawable.Drawable import android.view.View import android.view.ViewStub import androidx.appcompat.widget.ActionBarContextView import androidx.appcompat.widget.Toolbar +import com.datadog.android.sessionreplay.internal.recorder.base64.ImageWireframeHelper internal class ViewUtilsInternal { @@ -31,4 +33,75 @@ internal class ViewUtilsInternal { return Toolbar::class.java.isAssignableFrom(view::class.java) || android.widget.Toolbar::class.java.isAssignableFrom(view::class.java) } + + internal fun resolveDrawableBounds( + view: View, + drawable: Drawable, + pixelsDensity: Float + ): GlobalBounds { + val coordinates = IntArray(2) + // this will always have size >= 2 + @Suppress("UnsafeThirdPartyFunctionCall") + view.getLocationOnScreen(coordinates) + val x = coordinates[0].densityNormalized(pixelsDensity).toLong() + val y = coordinates[1].densityNormalized(pixelsDensity).toLong() + val width = drawable.intrinsicWidth.densityNormalized(pixelsDensity).toLong() + val height = drawable.intrinsicHeight.densityNormalized(pixelsDensity).toLong() + return GlobalBounds(x = x, y = y, height = height, width = width) + } + + internal fun resolveCompoundDrawableBounds( + view: View, + drawable: Drawable, + pixelsDensity: Float, + position: ImageWireframeHelper.CompoundDrawablePositions + ): GlobalBounds { + val coordinates = IntArray(2) + // this will always have size >= 2 + @Suppress("UnsafeThirdPartyFunctionCall") + view.getLocationOnScreen(coordinates) + + val viewXPosition = coordinates[0].densityNormalized(pixelsDensity).toLong() + val viewYPosition = coordinates[1].densityNormalized(pixelsDensity).toLong() + val drawableWidth = drawable.intrinsicWidth.densityNormalized(pixelsDensity).toLong() + val drawableHeight = drawable.intrinsicHeight.densityNormalized(pixelsDensity).toLong() + val viewWidth = view.width.densityNormalized(pixelsDensity).toLong() + val viewHeight = view.height.densityNormalized(pixelsDensity).toLong() + val viewPaddingStart = view.paddingStart.densityNormalized(pixelsDensity).toLong() + val viewPaddingTop = view.paddingTop.densityNormalized(pixelsDensity).toLong() + val viewPaddingBottom = view.paddingBottom.densityNormalized(pixelsDensity).toLong() + val viewPaddingEnd = view.paddingEnd.densityNormalized(pixelsDensity).toLong() + var xPosition: Long + var yPosition: Long + + when (position) { + ImageWireframeHelper.CompoundDrawablePositions.LEFT -> { + xPosition = viewPaddingStart + yPosition = getCenterVerticalOffset(viewHeight, drawableHeight) + } + ImageWireframeHelper.CompoundDrawablePositions.TOP -> { + xPosition = getCenterHorizontalOffset(viewWidth, drawableWidth) + yPosition = viewPaddingTop + } + ImageWireframeHelper.CompoundDrawablePositions.RIGHT -> { + xPosition = viewWidth - (drawableWidth + viewPaddingEnd) + yPosition = getCenterVerticalOffset(viewHeight, drawableHeight) + } + ImageWireframeHelper.CompoundDrawablePositions.BOTTOM -> { + xPosition = getCenterHorizontalOffset(viewWidth, drawableWidth) + yPosition = viewHeight - (drawableHeight + viewPaddingBottom) + } + } + + xPosition += viewXPosition + yPosition += viewYPosition + + return GlobalBounds(x = xPosition, y = yPosition, height = drawableHeight, width = drawableWidth) + } + + private fun getCenterHorizontalOffset(viewWidth: Long, drawableWidth: Long): Long = + (viewWidth / 2) - (drawableWidth / 2) + + private fun getCenterVerticalOffset(viewHeight: Long, drawableHeight: Long): Long = + (viewHeight / 2) - (drawableHeight / 2) } 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 f3a4ba579f..5e3a691e96 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 @@ -8,19 +8,25 @@ package com.datadog.android.sessionreplay.internal.recorder.base64 import android.graphics.drawable.Drawable import android.view.View +import android.widget.TextView import androidx.annotation.MainThread +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.model.MobileSegment import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator internal class ImageWireframeHelper( private val imageCompression: ImageCompression = WebPImageCompression(), private val uniqueIdentifierGenerator: UniqueIdentifierGenerator = UniqueIdentifierGenerator, - private val base64Serializer: Base64Serializer + private val base64Serializer: Base64Serializer, + private val viewUtilsInternal: ViewUtilsInternal = ViewUtilsInternal() ) { @MainThread internal fun createImageWireframe( view: View, - index: Int, + currentWireframeIndex: Int, x: Long, y: Long, width: Long, @@ -30,10 +36,15 @@ internal class ImageWireframeHelper( border: MobileSegment.ShapeBorder? = null, prefix: String = DRAWABLE_CHILD_NAME ): MobileSegment.Wireframe.ImageWireframe? { - val id = uniqueIdentifierGenerator.resolveChildUniqueIdentifier(view, prefix + index) + val id = uniqueIdentifierGenerator.resolveChildUniqueIdentifier(view, prefix + currentWireframeIndex) @Suppress("ComplexCondition") - if (drawable == null || id == null || drawable.intrinsicWidth < 0 || drawable.intrinsicHeight < 0) { + if ( + drawable == null || + id == null || + drawable.intrinsicWidth <= 0 || + drawable.intrinsicHeight <= 0 + ) { return null } @@ -65,7 +76,78 @@ internal class ImageWireframeHelper( return imageWireframe } - private companion object { - private const val DRAWABLE_CHILD_NAME = "drawable" + @Suppress("NestedBlockDepth") + internal fun createCompoundDrawableWireframes( + view: TextView, + mappingContext: MappingContext, + prevWireframeIndex: Int + ): MutableList { + val result = mutableListOf() + var wireframeIndex = prevWireframeIndex + val density = mappingContext.systemInformation.screenDensity + + // CompoundDrawables returns an array of indexes in the following order: + // left, top, right, bottom + view.compoundDrawables.forEachIndexed { compoundDrawableIndex, _ -> + if (compoundDrawableIndex > CompoundDrawablePositions.values().size) { + return@forEachIndexed + } + + val compoundDrawablePosition = convertIndexToCompoundDrawablePosition( + compoundDrawableIndex + ) ?: return@forEachIndexed + + val drawable = view.compoundDrawables[compoundDrawableIndex] + + if (drawable != null) { + val drawableCoordinates = viewUtilsInternal.resolveCompoundDrawableBounds( + view = view, + drawable = drawable, + pixelsDensity = density, + position = compoundDrawablePosition + ) + + @Suppress("ThreadSafety") // TODO REPLAY-1861 caller thread of .map is unknown? + createImageWireframe( + view = view, + currentWireframeIndex = ++wireframeIndex, + x = drawableCoordinates.x, + y = drawableCoordinates.y, + width = drawable.intrinsicWidth + .densityNormalized(density).toLong(), + height = drawable.intrinsicHeight + .densityNormalized(density).toLong(), + drawable = drawable, + shapeStyle = null, + border = null + )?.let { resultWireframe -> + result.add(resultWireframe) + } + } + } + + return result + } + + @Suppress("MagicNumber") + private fun convertIndexToCompoundDrawablePosition(compoundDrawableIndex: Int): CompoundDrawablePositions? { + return when (compoundDrawableIndex) { + 0 -> CompoundDrawablePositions.LEFT + 1 -> CompoundDrawablePositions.TOP + 2 -> CompoundDrawablePositions.RIGHT + 3 -> CompoundDrawablePositions.BOTTOM + else -> null + } + } + + internal enum class CompoundDrawablePositions { + LEFT, + TOP, + RIGHT, + BOTTOM + } + + 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/ButtonMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ButtonMapper.kt index 1fd6932705..2ad2b3748e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ButtonMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ButtonMapper.kt @@ -13,17 +13,18 @@ import com.datadog.android.sessionreplay.model.MobileSegment internal class ButtonMapper( private val textWireframeMapper: TextViewMapper = TextViewMapper() ) : - WireframeMapper { + WireframeMapper { override fun map(view: Button, mappingContext: MappingContext): - List { - return textWireframeMapper.map(view, mappingContext).map { - if (it.shapeStyle == null && it.border == null) { - // we were not able to resolve the background for this button so just add a border - it.copy(border = MobileSegment.ShapeBorder(BLACK_COLOR, 1)) - } else { - it + List { + return textWireframeMapper.map(view, mappingContext) + .map { + if (it is MobileSegment.Wireframe.TextWireframe && it.shapeStyle == null && it.border == null) { + // we were not able to resolve the background for this button so just add a border + it.copy(border = MobileSegment.ShapeBorder(BLACK_COLOR, 1)) + } else { + it + } } - } } companion object { 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/ImageButtonMapper.kt index bc96df9e6b..e12be51c34 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/ImageButtonMapper.kt @@ -47,7 +47,7 @@ internal class ImageButtonMapper( @Suppress("ThreadSafety") // TODO REPLAY-1861 caller thread of .map is unknown? imageWireframeHelper.createImageWireframe( view = view, - index = wireframes.size, + currentWireframeIndex = wireframes.size, x = centerX, y = centerY, width = scaledDrawableWidth, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskInputTextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskInputTextViewMapper.kt index 61ba3f25ee..8255a0974e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskInputTextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskInputTextViewMapper.kt @@ -7,10 +7,11 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper import android.widget.TextView -import androidx.annotation.VisibleForTesting import com.datadog.android.sessionreplay.SessionReplayPrivacy +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.obfuscator.rules.MaskInputObfuscationRule -import com.datadog.android.sessionreplay.internal.recorder.obfuscator.rules.TextValueObfuscationRule +import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator /** * A [WireframeMapper] implementation to map a [TextView] component and apply the @@ -19,8 +20,14 @@ import com.datadog.android.sessionreplay.internal.recorder.obfuscator.rules.Text class MaskInputTextViewMapper : TextViewMapper { constructor() : super(textValueObfuscationRule = MaskInputObfuscationRule()) - @VisibleForTesting internal constructor( - textValueObfuscationRule: TextValueObfuscationRule - ) : super(textValueObfuscationRule) + base64Serializer: Base64Serializer, + imageWireframeHelper: ImageWireframeHelper, + uniqueIdentifierGenerator: UniqueIdentifierGenerator + ) : super( + base64Serializer = base64Serializer, + imageWireframeHelper = imageWireframeHelper, + uniqueIdentifierGenerator = uniqueIdentifierGenerator, + textValueObfuscationRule = MaskInputObfuscationRule() + ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskTextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskTextViewMapper.kt index 564cdc1f8c..81480e0e2a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskTextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskTextViewMapper.kt @@ -9,8 +9,11 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper import android.widget.TextView import androidx.annotation.VisibleForTesting import com.datadog.android.sessionreplay.SessionReplayPrivacy +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.obfuscator.rules.MaskObfuscationRule import com.datadog.android.sessionreplay.internal.recorder.obfuscator.rules.TextValueObfuscationRule +import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator /** * A [WireframeMapper] implementation to map a [TextView] component and apply the @@ -19,8 +22,22 @@ import com.datadog.android.sessionreplay.internal.recorder.obfuscator.rules.Text class MaskTextViewMapper : TextViewMapper { constructor() : super(textValueObfuscationRule = MaskObfuscationRule()) + internal constructor( + base64Serializer: Base64Serializer, + imageWireframeHelper: ImageWireframeHelper, + uniqueIdentifierGenerator: UniqueIdentifierGenerator + ) : super( + base64Serializer = base64Serializer, + imageWireframeHelper = imageWireframeHelper, + uniqueIdentifierGenerator = uniqueIdentifierGenerator, + textValueObfuscationRule = MaskObfuscationRule() + ) + @VisibleForTesting internal constructor( + base64Serializer: Base64Serializer, + imageWireframeHelper: ImageWireframeHelper, + uniqueIdentifierGenerator: UniqueIdentifierGenerator, textValueObfuscationRule: TextValueObfuscationRule - ) : super(textValueObfuscationRule) + ) : super(base64Serializer, imageWireframeHelper, uniqueIdentifierGenerator, textValueObfuscationRule) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/TextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/TextViewMapper.kt index 0c15c25cf8..852a5db94c 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/TextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/TextViewMapper.kt @@ -9,11 +9,15 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper import android.graphics.Typeface import android.view.Gravity import android.widget.TextView +import com.datadog.android.sessionreplay.internal.recorder.GlobalBounds 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.obfuscator.rules.AllowObfuscationRule import com.datadog.android.sessionreplay.internal.recorder.obfuscator.rules.TextValueObfuscationRule import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator /** * A [WireframeMapper] implementation to map a [TextView] component. @@ -23,26 +27,86 @@ import com.datadog.android.sessionreplay.model.MobileSegment */ @Suppress("TooManyFunctions") open class TextViewMapper : - BaseWireframeMapper { - - internal val textValueObfuscationRule: TextValueObfuscationRule - - constructor() { - textValueObfuscationRule = AllowObfuscationRule() + BaseWireframeMapper { + + internal var textValueObfuscationRule: TextValueObfuscationRule = AllowObfuscationRule() + private var base64Serializer: Base64Serializer? = null + private var imageWireframeHelper: ImageWireframeHelper? = null + private var uniqueIdentifierGenerator: UniqueIdentifierGenerator? = null + + constructor() + + internal constructor( + base64Serializer: Base64Serializer, + imageWireframeHelper: ImageWireframeHelper, + uniqueIdentifierGenerator: UniqueIdentifierGenerator, + textValueObfuscationRule: TextValueObfuscationRule? = null + ) : super(base64Serializer, imageWireframeHelper, uniqueIdentifierGenerator) { + this.base64Serializer = base64Serializer + this.imageWireframeHelper = imageWireframeHelper + this.uniqueIdentifierGenerator = uniqueIdentifierGenerator + textValueObfuscationRule?.let { + this.textValueObfuscationRule = it + } } - internal constructor(textValueObfuscationRule: TextValueObfuscationRule) { + internal constructor( + textValueObfuscationRule: TextValueObfuscationRule + ) { this.textValueObfuscationRule = textValueObfuscationRule } override fun map(view: TextView, mappingContext: MappingContext): - List { + List { + val wireframes = mutableListOf() + + wireframes.addAll(super.map(view, mappingContext)) + + val density = mappingContext.systemInformation.screenDensity val viewGlobalBounds = resolveViewGlobalBounds( view, - mappingContext.systemInformation.screenDensity + density ) - val (shapeStyle, border) = view.background?.resolveShapeStyleAndBorder(view.alpha) - ?: (null to null) + + resolveTextElements( + view, + mappingContext, + viewGlobalBounds + ).let(wireframes::addAll) + + resolveImages( + view, + mappingContext, + wireframes.size + ).let(wireframes::addAll) + + return wireframes + } + + // region Internal + + private fun resolveImages( + view: TextView, + mappingContext: MappingContext, + currentIndex: Int + ): List { + val wireframes = mutableListOf() + + imageWireframeHelper?.createCompoundDrawableWireframes( + view, + mappingContext, + currentIndex + )?.let { result -> + wireframes.addAll(result) + } + + return wireframes + } + private fun resolveTextElements( + view: TextView, + mappingContext: MappingContext, + viewGlobalBounds: GlobalBounds + ): List { return listOf( MobileSegment.Wireframe.TextWireframe( id = resolveViewId(view), @@ -50,8 +114,8 @@ open class TextViewMapper : y = viewGlobalBounds.y, width = viewGlobalBounds.width, height = viewGlobalBounds.height, - shapeStyle = shapeStyle, - border = border, + shapeStyle = null, + border = null, text = textValueObfuscationRule.resolveObfuscatedValue(view, mappingContext), textStyle = resolveTextStyle(view, mappingContext.systemInformation.screenDensity), textPosition = resolveTextPosition( @@ -62,8 +126,6 @@ open class TextViewMapper : ) } - // region Internal - private fun resolveTextStyle(textView: TextView, pixelsDensity: Float): MobileSegment.TextStyle { return MobileSegment.TextStyle( diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewUtilsInternalTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewUtilsInternalTest.kt index 28bc171ce4..2cce8efd58 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewUtilsInternalTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewUtilsInternalTest.kt @@ -6,13 +6,17 @@ package com.datadog.android.sessionreplay.internal.recorder +import android.graphics.drawable.Drawable import android.view.View import android.view.ViewStub +import android.widget.TextView import androidx.appcompat.widget.ActionBarContextView import androidx.appcompat.widget.Toolbar import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.recorder.base64.ImageWireframeHelper import com.datadog.tools.unit.extensions.ApiLevelExtension import fr.xgouchet.elmyr.Forge +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 @@ -20,6 +24,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.mock @@ -35,10 +40,40 @@ import org.mockito.quality.Strictness @ForgeConfiguration(ForgeConfigurator::class) internal class ViewUtilsInternalTest { - lateinit var testViewUtilsInternal: ViewUtilsInternal + private lateinit var testViewUtilsInternal: ViewUtilsInternal + + @Mock + lateinit var mockTextView: TextView + + @Mock + lateinit var mockDrawable: Drawable + + @IntForgery(min = 1) + var fakeViewPadding: Int = 0 + + @IntForgery(min = 1) + var fakeViewHeight: Int = 0 + + @IntForgery(min = 1) + var fakeViewWidth: Int = 0 + + @IntForgery(min = 1) + var fakeDrawableWidth: Int = 0 + + @IntForgery(min = 1) + var fakeDrawableHeight: Int = 0 @BeforeEach fun `set up`() { + whenever(mockTextView.paddingStart).thenReturn(fakeViewPadding) + whenever(mockTextView.paddingEnd).thenReturn(fakeViewPadding) + whenever(mockTextView.paddingTop).thenReturn(fakeViewPadding) + whenever(mockTextView.paddingBottom).thenReturn(fakeViewPadding) + whenever(mockTextView.height).thenReturn(fakeViewHeight) + whenever(mockTextView.width).thenReturn(fakeViewWidth) + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight) + testViewUtilsInternal = ViewUtilsInternal() } @@ -182,5 +217,114 @@ internal class ViewUtilsInternalTest { assertThat(testViewUtilsInternal.isToolbar(mockView)).isFalse } + @Test + fun `M return globalbounds W resolveDrawableBounds()`( + @Mock mockView: View, + @Mock mockDrawable: Drawable, + @IntForgery(0, 100) fakeWidth: Int, + @IntForgery(0, 100) fakeHeight: Int + ) { + // Given + whenever(mockDrawable.intrinsicWidth).thenReturn(fakeWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(fakeHeight) + + // When + val bounds = testViewUtilsInternal.resolveDrawableBounds( + mockView, + mockDrawable, + 0f + ) + + // Then + assertThat(bounds.x).isEqualTo(0) + assertThat(bounds.y).isEqualTo(0) + assertThat(bounds.width).isEqualTo(fakeWidth.toLong()) + assertThat(bounds.height).isEqualTo(fakeHeight.toLong()) + } + // endregion + + @Test + fun `M return bounds W resolveCompoundDrawableBounds() { for left drawable }`() { + // Given + val viewPadding = mockTextView.paddingStart.toLong() + val viewHeight = mockTextView.height.toLong() + val drawableHeight = mockDrawable.intrinsicHeight.toLong() + + // When + val actualBounds = testViewUtilsInternal.resolveCompoundDrawableBounds( + mockTextView, + mockDrawable, + 0f, + ImageWireframeHelper.CompoundDrawablePositions.LEFT + ) + + // Then + assertThat(actualBounds.x).isEqualTo(viewPadding) + assertThat(actualBounds.y).isEqualTo(viewHeight / 2 - drawableHeight / 2) + } + + @Test + fun `M return bounds W resolveCompoundDrawableBounds() { for top drawable }`() { + // Given + val viewPadding = mockTextView.paddingTop.toLong() + val viewWidth = mockTextView.width.toLong() + val drawableWidth = mockDrawable.intrinsicWidth.toLong() + + // When + val actualBounds = testViewUtilsInternal.resolveCompoundDrawableBounds( + mockTextView, + mockDrawable, + 0f, + ImageWireframeHelper.CompoundDrawablePositions.TOP + ) + + // Then + assertThat(actualBounds.x).isEqualTo(viewWidth / 2 - drawableWidth / 2) + assertThat(actualBounds.y).isEqualTo(viewPadding) + } + + @Test + fun `M return bounds W resolveCompoundDrawableBounds() { for right drawable }`() { + // Given + val viewPadding = mockTextView.paddingEnd.toLong() + val viewWidth = mockTextView.width.toLong() + val viewHeight = mockTextView.height.toLong() + val drawableWidth = mockDrawable.intrinsicWidth.toLong() + val drawableHeight = mockDrawable.intrinsicHeight.toLong() + + // When + val actualBounds = testViewUtilsInternal.resolveCompoundDrawableBounds( + mockTextView, + mockDrawable, + 0f, + ImageWireframeHelper.CompoundDrawablePositions.RIGHT + ) + + // Then + assertThat(actualBounds.x).isEqualTo(viewWidth - (drawableWidth + viewPadding)) + assertThat(actualBounds.y).isEqualTo(viewHeight / 2 - drawableHeight / 2) + } + + @Test + fun `M return bounds W resolveCompoundDrawableBounds() { for bottom drawable }`() { + // Given + val viewPadding = mockTextView.paddingBottom.toLong() + val viewWidth = mockTextView.width.toLong() + val viewHeight = mockTextView.height.toLong() + val drawableWidth = mockDrawable.intrinsicWidth.toLong() + val drawableHeight = mockDrawable.intrinsicHeight.toLong() + + // When + val actualBounds = testViewUtilsInternal.resolveCompoundDrawableBounds( + mockTextView, + mockDrawable, + 0f, + ImageWireframeHelper.CompoundDrawablePositions.BOTTOM + ) + + // Then + assertThat(actualBounds.x).isEqualTo(viewWidth / 2 - drawableWidth / 2) + assertThat(actualBounds.y).isEqualTo(viewHeight - (drawableHeight + viewPadding)) + } } 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 2cf47a5fa8..4651b17aab 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 @@ -11,11 +11,17 @@ import android.content.res.Resources import android.graphics.drawable.Drawable import android.util.DisplayMetrics import android.view.View +import android.widget.TextView import com.datadog.android.sessionreplay.forge.ForgeConfigurator 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.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 fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.annotation.LongForgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -53,6 +59,18 @@ internal class ImageWireframeHelperTest { @Mock lateinit var mockView: View + @Mock + lateinit var mockViewUtilsInternal: ViewUtilsInternal + + @Mock + lateinit var mockTextView: TextView + + @Mock + lateinit var mockMappingContext: MappingContext + + @Mock + lateinit var mockSystemInformation: SystemInformation + @Mock lateinit var mockDrawable: Drawable @@ -68,7 +86,7 @@ internal class ImageWireframeHelperTest { @Mock lateinit var mockContext: Context - @LongForgery + @LongForgery(min = 1) var fakeGeneratedIdentifier: Long = 0L @LongForgery(min = 1, max = 300) @@ -82,54 +100,69 @@ internal class ImageWireframeHelperTest { @StringForgery var fakeMimeType: String = "" + @IntForgery(min = 1) + var fakePadding: Int = 0 + @BeforeEach fun `set up`(forge: Forge) { val fakeScreenWidth = 1000 val fakeScreenHeight = 1000 - val randomXLocation = forge.aLong(min = 0, max = fakeScreenWidth - fakeDrawableWidth) - val randomYLocation = forge.aLong(min = 0, max = fakeScreenHeight - fakeDrawableHeight) + val randomXLocation = forge.aLong(min = 1, max = fakeScreenWidth - fakeDrawableWidth) + val randomYLocation = forge.aLong(min = 1, max = fakeScreenHeight - fakeDrawableHeight) fakeDrawableXY = Pair(randomXLocation, randomYLocation) - + whenever(mockMappingContext.systemInformation).thenReturn(mockSystemInformation) + whenever(mockSystemInformation.screenDensity).thenReturn(0f) whenever(mockUniqueIdentifierGenerator.resolveChildUniqueIdentifier(mockView, "drawable")) .thenReturn(fakeGeneratedIdentifier) - whenever(mockDrawable.intrinsicWidth).thenReturn(fakeDrawableWidth.toInt()) whenever(mockDrawable.intrinsicHeight).thenReturn(fakeDrawableHeight.toInt()) - - whenever(mockView.resources).thenReturn(mockResources) whenever(mockResources.displayMetrics).thenReturn(mockDisplayMetrics) - + whenever(mockView.resources).thenReturn(mockResources) whenever(mockView.context).thenReturn(mockContext) whenever(mockContext.applicationContext).thenReturn(mockContext) - whenever(mockImageCompression.getMimeType()).thenReturn(fakeMimeType) - + whenever(mockTextView.resources).thenReturn(mockResources) + whenever(mockTextView.context).thenReturn(mockContext) + whenever(mockViewUtilsInternal.resolveDrawableBounds(any(), any(), any())) + .thenReturn(mockBounds) + whenever(mockTextView.width).thenReturn(fakeDrawableWidth.toInt()) + whenever(mockTextView.height).thenReturn(fakeDrawableHeight.toInt()) + whenever(mockTextView.paddingStart).thenReturn(fakePadding) + whenever(mockTextView.paddingEnd).thenReturn(fakePadding) + whenever(mockTextView.paddingTop).thenReturn(fakePadding) + whenever(mockTextView.paddingBottom).thenReturn(fakePadding) + whenever( + mockUniqueIdentifierGenerator.resolveChildUniqueIdentifier( + mockTextView, + DRAWABLE_CHILD_NAME + 1 + ) + ).thenReturn(fakeGeneratedIdentifier) whenever(mockBounds.width).thenReturn(fakeDrawableWidth) whenever(mockBounds.height).thenReturn(fakeDrawableHeight) - whenever(mockBounds.x).thenReturn(fakeDrawableXY.first) - whenever(mockBounds.y).thenReturn(fakeDrawableXY.second) + whenever(mockBounds.x).thenReturn(0L) + whenever(mockBounds.y).thenReturn(0L) testedHelper = ImageWireframeHelper( imageCompression = mockImageCompression, uniqueIdentifierGenerator = mockUniqueIdentifierGenerator, - base64Serializer = mockBase64Serializer + base64Serializer = mockBase64Serializer, + viewUtilsInternal = mockViewUtilsInternal ) } + // region createImageWireframe + @Test fun `M return null W createImageWireframe() { drawable is null }`() { // When val wireframe = testedHelper.createImageWireframe( view = mockView, - index = 0, + currentWireframeIndex = 0, x = 0, y = 0, width = 0, - height = 0, - drawable = null, - shapeStyle = null, - border = null + height = 0 ) // Then @@ -145,7 +178,7 @@ internal class ImageWireframeHelperTest { // When val wireframe = testedHelper.createImageWireframe( view = mockView, - index = 0, + currentWireframeIndex = 0, x = 0, y = 0, width = 0, @@ -162,12 +195,12 @@ internal class ImageWireframeHelperTest { @Test fun `M return null W createImageWireframe() { drawable has no intrinsic width }`() { // Given - whenever(mockDrawable.intrinsicWidth).thenReturn(-1) + whenever(mockDrawable.intrinsicWidth).thenReturn(0) // When val wireframe = testedHelper.createImageWireframe( view = mockView, - index = 0, + currentWireframeIndex = 0, x = 0, y = 0, width = 0, @@ -184,12 +217,12 @@ internal class ImageWireframeHelperTest { @Test fun `M return null W createImageWireframe() { drawable has no intrinsic height }`() { // Given - whenever(mockDrawable.intrinsicHeight).thenReturn(-1) + whenever(mockDrawable.intrinsicHeight).thenReturn(0) // When val wireframe = testedHelper.createImageWireframe( view = mockView, - index = 0, + currentWireframeIndex = 0, x = 0, y = 0, width = 0, @@ -205,23 +238,24 @@ internal class ImageWireframeHelperTest { @Test fun `M return wireframe W createImageWireframe()`( - @LongForgery id: Long + @Mock mockShapeStyle: MobileSegment.ShapeStyle, + @Mock mockBorder: MobileSegment.ShapeBorder ) { // Given whenever( mockUniqueIdentifierGenerator .resolveChildUniqueIdentifier(any(), any()) ) - .thenReturn(id) + .thenReturn(fakeGeneratedIdentifier) val expectedWireframe = MobileSegment.Wireframe.ImageWireframe( - id = id, + id = fakeGeneratedIdentifier, x = fakeDrawableXY.first, y = fakeDrawableXY.second, width = fakeDrawableWidth, height = fakeDrawableHeight, - shapeStyle = null, - border = null, + shapeStyle = mockShapeStyle, + border = mockBorder, base64 = "", mimeType = fakeMimeType, isEmpty = true @@ -230,17 +264,112 @@ internal class ImageWireframeHelperTest { // When val wireframe = testedHelper.createImageWireframe( view = mockView, - index = 0, + currentWireframeIndex = 0, x = fakeDrawableXY.first, y = fakeDrawableXY.second, width = fakeDrawableWidth, height = fakeDrawableHeight, drawable = mockDrawable, - shapeStyle = null, - border = null + shapeStyle = mockShapeStyle, + border = mockBorder + ) // Then assertThat(wireframe).isEqualTo(expectedWireframe) } + + // endregion + + // region createCompoundDrawableWireframes + + @Test + fun `M return empty list W createCompoundDrawableWireframes() { no compound drawables }`() { + // Given + whenever(mockTextView.compoundDrawables) + .thenReturn(arrayOf(null, null, null, null)) + + // When + val wireframes = testedHelper.createCompoundDrawableWireframes( + mockTextView, + mockMappingContext, + 0 + ) + + // Then + assertThat(wireframes).isEmpty() + } + + @Test + fun `M return wireframe W createCompoundDrawableWireframes()`() { + // Given + whenever( + mockViewUtilsInternal.resolveCompoundDrawableBounds( + any(), + any(), + any(), + any() + ) + ) + .thenReturn(mockBounds) + whenever(mockTextView.compoundDrawables) + .thenReturn(arrayOf(null, mockDrawable, null, null)) + + // When + val wireframes = testedHelper.createCompoundDrawableWireframes( + mockTextView, + mockMappingContext, + 0 + ) + wireframes[0] as MobileSegment.Wireframe.ImageWireframe + + // Then + assertThat(wireframes.size).isEqualTo(1) + } + + @Test + fun `M return multiple wireframes W createCompoundDrawableWireframes() { multiple drawables }`() { + // Given + whenever( + mockViewUtilsInternal.resolveCompoundDrawableBounds( + any(), + any(), + any(), + any() + ) + ) + .thenReturn(mockBounds) + whenever(mockTextView.compoundDrawables) + .thenReturn(arrayOf(null, mockDrawable, null, mockDrawable)) + + // When + val wireframes = testedHelper.createCompoundDrawableWireframes( + mockTextView, + mockMappingContext, + 0 + ) + wireframes[0] as MobileSegment.Wireframe.ImageWireframe + + // Then + assertThat(wireframes.size).isEqualTo(2) + } + + @Test + fun `M skip invalid elements W createCompoundDrawableWireframes() { invalid indices }`() { + // Given + whenever(mockTextView.compoundDrawables) + .thenReturn(arrayOf(null, null, null, null, null, null)) + + // When + val wireframes = testedHelper.createCompoundDrawableWireframes( + mockTextView, + mockMappingContext, + 0 + ) + + // Then + assertThat(wireframes).isEmpty() + } + + // endregion } 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 0e3da2b63d..529e2fad9e 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 @@ -7,14 +7,20 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper import android.content.res.ColorStateList +import android.content.res.Resources import android.graphics.Typeface import android.graphics.drawable.ColorDrawable +import android.util.DisplayMetrics import android.widget.TextView import com.datadog.android.sessionreplay.internal.recorder.aMockTextView +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.obfuscator.rules.TextValueObfuscationRule +import com.datadog.android.sessionreplay.internal.utils.shapeStyle import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.StringUtils +import com.datadog.android.sessionreplay.utils.UniqueIdentifierGenerator import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.StringForgery import org.assertj.core.api.Assertions.assertThat @@ -23,16 +29,32 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTest() { - lateinit var testedTextWireframeMapper: TextViewMapper + private lateinit var testedTextWireframeMapper: TextViewMapper @Mock lateinit var mockObfuscationRule: TextValueObfuscationRule + @Mock + lateinit var mockResources: Resources + + @Mock + lateinit var mockImageWireframeHelper: ImageWireframeHelper + + @Mock + lateinit var mockBase64Serializer: Base64Serializer + + @Mock + lateinit var mockUniqueIdentifierGenerator: UniqueIdentifierGenerator + + @Mock + lateinit var mockDisplayMetrics: DisplayMetrics + @StringForgery lateinit var fakeText: String @@ -41,11 +63,20 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes @BeforeEach fun `set up`() { + whenever(mockUniqueIdentifierGenerator.resolveChildUniqueIdentifier(any(), any())) + .thenReturn(System.identityHashCode(this).toLong()) + + whenever(mockResources.displayMetrics).thenReturn(mockDisplayMetrics) testedTextWireframeMapper = initTestedMapper() } protected open fun initTestedMapper(): TextViewMapper { - return TextViewMapper(mockObfuscationRule) + return TextViewMapper( + imageWireframeHelper = mockImageWireframeHelper, + uniqueIdentifierGenerator = mockUniqueIdentifierGenerator, + base64Serializer = mockBase64Serializer, + textValueObfuscationRule = mockObfuscationRule + ) } @ParameterizedTest @@ -68,7 +99,9 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes whenever(this.textSize).thenReturn(fakeFontSize) whenever(this.currentTextColor).thenReturn(fakeFontColor) whenever(this.text).thenReturn(fakeText) + whenever(this.resources).thenReturn(mockResources) } + whenever(mockObfuscationRule.resolveObfuscatedValue(mockTextView, fakeMappingContext)) .thenReturn(fakeDefaultObfuscatedText) @@ -101,6 +134,7 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes whenever(this.text).thenReturn(fakeText) whenever(this.typeface).thenReturn(mock()) whenever(this.textAlignment).thenReturn(fakeTextAlignment) + whenever(this.resources).thenReturn(mockResources) } whenever(mockObfuscationRule.resolveObfuscatedValue(mockTextView, fakeMappingContext)) .thenReturn(fakeDefaultObfuscatedText) @@ -134,7 +168,9 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes whenever(this.typeface).thenReturn(mock()) whenever(this.textAlignment).thenReturn(TextView.TEXT_ALIGNMENT_GRAVITY) whenever(this.gravity).thenReturn(fakeGravity) + whenever(this.resources).thenReturn(mockResources) } + whenever(mockObfuscationRule.resolveObfuscatedValue(mockTextView, fakeMappingContext)) .thenReturn(fakeDefaultObfuscatedText) @@ -168,6 +204,7 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes whenever(this.totalPaddingBottom).thenReturn(fakeTextPaddingBottom) whenever(this.totalPaddingStart).thenReturn(fakeTextPaddingStart) whenever(this.totalPaddingEnd).thenReturn(fakeTextPaddingEnd) + whenever(this.resources).thenReturn(mockResources) } val expectedWireframeTextPadding = MobileSegment.Padding( fakeTextPaddingTop.densityNormalized(fakeMappingContext.systemInformation.screenDensity).toLong(), @@ -199,7 +236,7 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes } @Test - fun `M resolve a TextWireframe with shapeStyle W map { TextView with ColorDrawable }`( + fun `M resolve a TextWireframe with background ShapeWireframe W map { TextView with ColorDrawable }`( forge: Forge ) { // Given @@ -224,25 +261,47 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes whenever(this.text).thenReturn(fakeText) whenever(this.typeface).thenReturn(mock()) whenever(this.alpha).thenReturn(fakeViewAlpha) + whenever(this.resources).thenReturn(mockResources) } whenever(mockObfuscationRule.resolveObfuscatedValue(mockTextView, fakeMappingContext)) .thenReturn(fakeDefaultObfuscatedText) // When - val textWireframes = testedTextWireframeMapper.map(mockTextView, fakeMappingContext) + val actualWireframes = testedTextWireframeMapper.map(mockTextView, fakeMappingContext) // Then - val expectedWireframes = mockTextView.toTextWireframes().map { - it.copy( - text = fakeDefaultObfuscatedText, - shapeStyle = MobileSegment.ShapeStyle( - backgroundColor = fakeStyleColor, - opacity = fakeViewAlpha, - cornerRadius = null + val textWireframes = mockTextView.toTextWireframes() + + val backgroundWireframe = MobileSegment.Wireframe.ShapeWireframe( + id = System.identityHashCode(this).toLong(), + x = textWireframes[0].x, + y = textWireframes[0].y, + width = mockTextView.width.densityNormalized(fakeMappingContext.systemInformation.screenDensity).toLong(), + height = mockTextView.height.densityNormalized(fakeMappingContext.systemInformation.screenDensity).toLong(), + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = fakeStyleColor, + opacity = fakeViewAlpha, + cornerRadius = null + ), + border = null + ) + + val expectedWireframes = mutableListOf() + expectedWireframes.add(backgroundWireframe) + + expectedWireframes.addAll( + mockTextView.toTextWireframes().map { + it.copy( + text = fakeDefaultObfuscatedText ) - ) - } - assertThat(textWireframes).isEqualTo(expectedWireframes) + } + ) + + assertThat(actualWireframes.size).isEqualTo(2) + assertThat(actualWireframes[0].shapeStyle()).isEqualTo(expectedWireframes[0].shapeStyle()) + assertThat(actualWireframes[1].shapeStyle()).isNull() + assertThat((actualWireframes[1] as MobileSegment.Wireframe.TextWireframe).text) + .isEqualTo((expectedWireframes[1] as MobileSegment.Wireframe.TextWireframe).text) } @Test @@ -259,6 +318,7 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes whenever(this.hint).thenReturn(fakeHintText) whenever(this.hintTextColors).thenReturn(mockColorStateList) whenever(this.typeface).thenReturn(mock()) + whenever(this.resources).thenReturn(mockResources) } whenever(mockObfuscationRule.resolveObfuscatedValue(mockTextView, fakeMappingContext)) .thenReturn(fakeDefaultObfuscatedText) @@ -300,7 +360,9 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes whenever(this.hintTextColors).thenReturn(null) whenever(this.typeface).thenReturn(mock()) whenever(this.currentTextColor).thenReturn(fakeTextColor) + whenever(this.resources).thenReturn(mockResources) } + whenever(mockObfuscationRule.resolveObfuscatedValue(mockTextView, fakeMappingContext)) .thenReturn(fakeDefaultObfuscatedText) @@ -326,4 +388,42 @@ internal abstract class BaseTextViewWireframeMapperTest : BaseWireframeMapperTes } assertThat(textWireframes).isEqualTo(expectedWireframes) } + + @Test + fun `M resolve an imageWireframe W map() { TextView with compoundDrawable }`(forge: Forge) { + // Given + val fakeDefaultObfuscatedText = forge.aString() + val fakeHintText = forge.aString() + val fakeTextColor = forge.anInt(min = 0, max = 0xffffff) + val mockTextView: TextView = forge.aMockTextView().apply { + whenever(this.text).thenReturn("") + whenever(this.hint).thenReturn(fakeHintText) + whenever(this.hintTextColors).thenReturn(null) + whenever(this.typeface).thenReturn(mock()) + whenever(this.currentTextColor).thenReturn(fakeTextColor) + whenever(this.resources).thenReturn(mockResources) + whenever(this.compoundDrawables).thenReturn( + arrayOf( + mock(), + mock(), + mock(), + mock() + ) + ) + } + val mockImageWireframe: MobileSegment.Wireframe.ImageWireframe = mock() + + whenever(mockImageWireframeHelper.createCompoundDrawableWireframes(any(), any(), any())) + .thenReturn(mutableListOf(mockImageWireframe)) + + whenever(mockObfuscationRule.resolveObfuscatedValue(mockTextView, fakeMappingContext)) + .thenReturn(fakeDefaultObfuscatedText) + + // When + val wireframes = testedTextWireframeMapper.map(mockTextView, fakeMappingContext) + val imageWireframes = wireframes.filter { it is MobileSegment.Wireframe.ImageWireframe } + + // Then + assertThat(imageWireframes).isEqualTo(listOf(mockImageWireframe)) + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskTextViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskTextViewMapperTest.kt index c09cf6580e..fd1eab8258 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskTextViewMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/MaskTextViewMapperTest.kt @@ -29,7 +29,12 @@ import org.mockito.quality.Strictness internal class MaskTextViewMapperTest : BaseTextViewWireframeMapperTest() { override fun initTestedMapper(): TextViewMapper { - return MaskTextViewMapper(mockObfuscationRule) + return MaskTextViewMapper( + base64Serializer = mockBase64Serializer, + imageWireframeHelper = mockImageWireframeHelper, + uniqueIdentifierGenerator = mockUniqueIdentifierGenerator, + textValueObfuscationRule = mockObfuscationRule + ) } @Test 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 8be9d41f2a..f4b1d87e0d 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 @@ -94,7 +94,7 @@ internal class ImageComponentsFragment : Fragment() { buttonRemote, object : ImageLoadedCallback { override fun onImageLoaded(resource: Drawable) { - buttonRemote.setCompoundDrawablesWithIntrinsicBounds(resource, null, null, null) + buttonRemote.setCompoundDrawablesWithIntrinsicBounds(null, null, null, resource) } } ) @@ -106,7 +106,7 @@ internal class ImageComponentsFragment : Fragment() { textViewRemote, object : ImageLoadedCallback { override fun onImageLoaded(resource: Drawable) { - textViewRemote.setCompoundDrawablesWithIntrinsicBounds(resource, null, null, null) + textViewRemote.setCompoundDrawablesWithIntrinsicBounds(null, null, null, resource) } } ) 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 ad82c9756c..aff6bdf42f 100644 --- a/sample/kotlin/src/main/res/layout/fragment_image_components.xml +++ b/sample/kotlin/src/main/res/layout/fragment_image_components.xml @@ -35,15 +35,14 @@ + + + + + + + + - + + + + + + + + @@ -193,7 +214,7 @@ @@ -348,14 +366,12 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" - android:weightSum="2" > @@ -364,7 +380,6 @@ android:layout_width="0dp" android:layout_height="100dp" android:background="@drawable/ic_dd_icon_rgb" - android:layout_weight="1" />