From c23b8807eb311ceeb75e6bc8e78f7e3153753b8d Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Fri, 18 Nov 2022 10:17:22 +0100 Subject: [PATCH] RUMM-2746 Sync SR touch and screen recorders --- .../sessionreplay/recorder/Debouncer.kt | 51 ++++++ .../recorder/RecorderOnDrawListener.kt | 11 +- .../sessionreplay/recorder/DebouncerTest.kt | 168 ++++++++++++++++++ .../recorder/RecorderOnDrawListenerTest.kt | 32 ++-- 4 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/Debouncer.kt create mode 100644 library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/DebouncerTest.kt diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/Debouncer.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/Debouncer.kt new file mode 100644 index 0000000000..dfb27baeee --- /dev/null +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/Debouncer.kt @@ -0,0 +1,51 @@ +/* + * 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.recorder + +import android.os.Handler +import android.os.Looper +import java.util.concurrent.TimeUnit + +internal class Debouncer( + private val handler: Handler = Handler(Looper.getMainLooper()), + private val maxRecordDelayInNs: Long = MAX_DELAY_THRESHOLD_NS +) { + + private var lastTimeRecordWasPerformed = 0L + private var firstRequest = true + + internal fun debounce(runnable: Runnable) { + if (firstRequest) { + // we will initialize the lastTimeRecordWasPerformed here to the current time in nano + // reason why we are not initializing this in the constructor is that in case the + // component was initialized earlier than the first debounce request was requested + // it will execute the runnable directly and will not pass through the handler. + lastTimeRecordWasPerformed = System.nanoTime() + firstRequest = false + } + handler.removeCallbacksAndMessages(null) + val timePassedSinceLastExecution = System.nanoTime() - lastTimeRecordWasPerformed + if (timePassedSinceLastExecution >= maxRecordDelayInNs) { + executeRunnable(runnable) + } else { + handler.postDelayed({ executeRunnable(runnable) }, DEBOUNCE_TIME_IN_NS) + } + } + + private fun executeRunnable(runnable: Runnable) { + runnable.run() + lastTimeRecordWasPerformed = System.nanoTime() + } + + companion object { + // one frame time + private val MAX_DELAY_THRESHOLD_NS: Long = TimeUnit.MILLISECONDS.toNanos(16) + + // one frame time + internal val DEBOUNCE_TIME_IN_NS: Long = TimeUnit.MILLISECONDS.toNanos(16) + } +} diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListener.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListener.kt index 3808ba6abe..fdd7735579 100644 --- a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListener.kt +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListener.kt @@ -8,8 +8,6 @@ package com.datadog.android.sessionreplay.recorder import android.app.Activity import android.content.res.Configuration -import android.os.Handler -import android.os.Looper import android.view.View import android.view.ViewTreeObserver import com.datadog.android.sessionreplay.processor.Processor @@ -20,14 +18,13 @@ internal class RecorderOnDrawListener( private val pixelsDensity: Float, private val processor: Processor, private val snapshotProducer: SnapshotProducer, - private val handler: Handler = Handler(Looper.getMainLooper()) + private val debouncer: Debouncer = Debouncer() ) : ViewTreeObserver.OnDrawListener { private var currentOrientation = Configuration.ORIENTATION_UNDEFINED private val trackedActivity: WeakReference = WeakReference(activity) override fun onDraw() { - handler.removeCallbacksAndMessages(null) - handler.postDelayed(takeSnapshotRunnable, DEBOUNCE_DURATION_IN_MILLIS) + debouncer.debounce(takeSnapshotRunnable) } private val takeSnapshotRunnable: Runnable = Runnable { @@ -54,8 +51,4 @@ internal class RecorderOnDrawListener( currentOrientation = orientation return orientationChanged } - - companion object { - const val DEBOUNCE_DURATION_IN_MILLIS: Long = 4 - } } diff --git a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/DebouncerTest.kt b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/DebouncerTest.kt new file mode 100644 index 0000000000..ae0e85d912 --- /dev/null +++ b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/DebouncerTest.kt @@ -0,0 +1,168 @@ +/* + * 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.recorder + +import android.os.Handler +import com.datadog.android.sessionreplay.utils.ForgeConfigurator +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +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.quality.Strictness +import java.util.concurrent.TimeUnit +import kotlin.math.ceil + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class DebouncerTest { + + @Mock + lateinit var mockHandler: Handler + + lateinit var testedDebouncer: Debouncer + + @BeforeEach + fun `set up`() { + testedDebouncer = Debouncer(mockHandler, TEST_MAX_DELAY_THRESHOLD_IN_NS) + } + + @Test + fun `M delegate to the delayed handler W debounce { first request }`() { + // Given + val fakeRunnable = TestRunnable() + whenever(mockHandler.postDelayed(any(), any())).then { + (it.arguments[0] as Runnable).run() + true + } + + // When + testedDebouncer.debounce(fakeRunnable) + + // Then + verify(mockHandler).removeCallbacksAndMessages(null) + verify(mockHandler).postDelayed(any(), eq(Debouncer.DEBOUNCE_TIME_IN_NS)) + } + + @Test + fun `M skip the handler and execute the runnable in place W debounce { threshold reached }`() { + // Given + val fakeRunnable = TestRunnable() + val fakeSecondRunnable = TestRunnable() + testedDebouncer.debounce(fakeRunnable) + Thread.sleep(TimeUnit.NANOSECONDS.toMillis(TEST_MAX_DELAY_THRESHOLD_IN_NS)) + + // When + testedDebouncer.debounce(fakeSecondRunnable) + + // Then + verify(mockHandler, times(1)).postDelayed( + any(), + eq(Debouncer.DEBOUNCE_TIME_IN_NS) + ) + verify(mockHandler, times(2)).removeCallbacksAndMessages(null) + assertThat(fakeRunnable.wasExecuted).isFalse + assertThat(fakeSecondRunnable.wasExecuted).isTrue + } + + @Test + fun `M execute the runnable once W debounce { high frequency, delay threshold reached }`( + forge: Forge + ) { + // Given + val fakeDelayedRunnables = forge.aList(size = forge.anInt(min = 1, max = 10)) { TestRunnable() } + val delayInterval = ceil( + TEST_MAX_DELAY_THRESHOLD_IN_NS.toDouble() / + fakeDelayedRunnables.size.toDouble() + ).toLong() + val fakeExecutedRunnable = TestRunnable() + fakeDelayedRunnables.forEach { + testedDebouncer.debounce(it) + Thread.sleep(TimeUnit.NANOSECONDS.toMillis(delayInterval)) + } + + // When + testedDebouncer.debounce(fakeExecutedRunnable) + + // Then + verify(mockHandler, times(fakeDelayedRunnables.size)) + .postDelayed(any(), eq(Debouncer.DEBOUNCE_TIME_IN_NS)) + verify(mockHandler, times(fakeDelayedRunnables.size + 1)) + .removeCallbacksAndMessages(null) + assertThat(fakeDelayedRunnables.filter { it.wasExecuted }.size).isEqualTo(0) + assertThat(fakeExecutedRunnable.wasExecuted).isTrue + } + + @Test + fun `M switch to the handler W debounce { delay threshold reached, more runnables after }`( + forge: Forge + ) { + // Given + val fakeDelayedRunnablesPack1 = forge.aList(size = forge.anInt(min = 1, max = 10)) { + TestRunnable() + } + val fakeDelayedRunnablesPack2 = forge.aList(size = forge.anInt(min = 1, max = 10)) { + TestRunnable() + } + val delayInterval = ceil( + TEST_MAX_DELAY_THRESHOLD_IN_NS.toDouble() / + fakeDelayedRunnablesPack1.size.toDouble() + ).toLong() + val fakeExecutedRunnable = TestRunnable() + fakeDelayedRunnablesPack1.forEach { + testedDebouncer.debounce(it) + Thread.sleep(TimeUnit.NANOSECONDS.toMillis(delayInterval)) + } + testedDebouncer.debounce(fakeExecutedRunnable) + + // When + fakeDelayedRunnablesPack2.forEach { + testedDebouncer.debounce(it) + } + + // Then + val numOfDelayedInvocations = fakeDelayedRunnablesPack1.size + + fakeDelayedRunnablesPack2.size + verify(mockHandler, times(numOfDelayedInvocations)).postDelayed( + any(), + eq(Debouncer.DEBOUNCE_TIME_IN_NS) + ) + val numOfCancelInvocations = fakeDelayedRunnablesPack1.size + + fakeDelayedRunnablesPack2.size + 1 + verify(mockHandler, times(numOfCancelInvocations)).removeCallbacksAndMessages(null) + assertThat(fakeDelayedRunnablesPack1.filter { it.wasExecuted }.size).isEqualTo(0) + assertThat(fakeDelayedRunnablesPack2.filter { it.wasExecuted }.size).isEqualTo(0) + assertThat(fakeExecutedRunnable.wasExecuted).isTrue + } + + private class TestRunnable : Runnable { + var wasExecuted: Boolean = false + + override fun run() { + wasExecuted = true + } + } + + companion object { + private val TEST_MAX_DELAY_THRESHOLD_IN_NS = TimeUnit.SECONDS.toNanos(2) + } +} diff --git a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListenerTest.kt b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListenerTest.kt index 1a23dc09bd..7ba19f8680 100644 --- a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListenerTest.kt +++ b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListenerTest.kt @@ -9,7 +9,6 @@ package com.datadog.android.sessionreplay.recorder import android.app.Activity import android.content.res.Configuration import android.content.res.Resources -import android.os.Handler import android.view.View import android.view.Window import com.datadog.android.sessionreplay.processor.Processor @@ -57,15 +56,15 @@ internal class RecorderOnDrawListenerTest { lateinit var mockResources: Resources lateinit var configuration: Configuration - @Mock - lateinit var mockHandler: Handler - @Mock lateinit var mockSnapshotProducer: SnapshotProducer @Mock lateinit var mockProcessor: Processor + @Mock + lateinit var mockDebouncer: Debouncer + @FloatForgery(min = 1.0f, max = 100.0f) var fakeDensity: Float = 0f @@ -104,14 +103,14 @@ internal class RecorderOnDrawListenerTest { fakeDensity, mockProcessor, mockSnapshotProducer, - mockHandler + mockDebouncer ) } @Test fun `M take and process snapshot W onDraw()`() { // Given - stubHandler() + stubDebouncer() // When testedListener.onDraw() @@ -135,7 +134,7 @@ internal class RecorderOnDrawListenerTest { fun `M do nothing W onDraw() { activity window is null }`() { // Given whenever(mockActivity.window).thenReturn(null) - stubHandler() + stubDebouncer() // When testedListener.onDraw() @@ -147,7 +146,7 @@ internal class RecorderOnDrawListenerTest { @Test fun `M send OrientationChanged W onDraw() { first time }`() { // Given - stubHandler() + stubDebouncer() // When testedListener.onDraw() @@ -167,7 +166,7 @@ internal class RecorderOnDrawListenerTest { @Test fun `M send OrientationChanged only once W onDraw() { second time }`() { // Given - stubHandler() + stubDebouncer() // When testedListener.onDraw() @@ -192,7 +191,7 @@ internal class RecorderOnDrawListenerTest { @Test fun `M send OrientationChanged twice W onDraw(){called 2 times with different orientation}`() { // Given - stubHandler() + stubDebouncer() // When val configuration1 = @@ -226,16 +225,7 @@ internal class RecorderOnDrawListenerTest { ) } - private fun stubHandler() { - whenever( - mockHandler.postDelayed( - any(), - eq(RecorderOnDrawListener.DEBOUNCE_DURATION_IN_MILLIS) - ) - ) - .then { - (it.arguments[0] as Runnable).run() - true - } + private fun stubDebouncer() { + whenever(mockDebouncer.debounce(any())).then { (it.arguments[0] as Runnable).run() } } }