Skip to content

Commit

Permalink
RUMM-2746 Sync SR touch and screen recorders
Browse files Browse the repository at this point in the history
  • Loading branch information
mariusc83 committed Nov 21, 2022
1 parent 07c6fe9 commit c23b880
Show file tree
Hide file tree
Showing 4 changed files with 232 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Activity> = WeakReference(activity)

override fun onDraw() {
handler.removeCallbacksAndMessages(null)
handler.postDelayed(takeSnapshotRunnable, DEBOUNCE_DURATION_IN_MILLIS)
debouncer.debounce(takeSnapshotRunnable)
}

private val takeSnapshotRunnable: Runnable = Runnable {
Expand All @@ -54,8 +51,4 @@ internal class RecorderOnDrawListener(
currentOrientation = orientation
return orientationChanged
}

companion object {
const val DEBOUNCE_DURATION_IN_MILLIS: Long = 4
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -147,7 +146,7 @@ internal class RecorderOnDrawListenerTest {
@Test
fun `M send OrientationChanged W onDraw() { first time }`() {
// Given
stubHandler()
stubDebouncer()

// When
testedListener.onDraw()
Expand All @@ -167,7 +166,7 @@ internal class RecorderOnDrawListenerTest {
@Test
fun `M send OrientationChanged only once W onDraw() { second time }`() {
// Given
stubHandler()
stubDebouncer()

// When
testedListener.onDraw()
Expand All @@ -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 =
Expand Down Expand Up @@ -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() }
}
}

0 comments on commit c23b880

Please sign in to comment.