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] 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(