Skip to content

Commit

Permalink
RUMM-2689 SR Flush buffered motion event positions periodically
Browse files Browse the repository at this point in the history
  • Loading branch information
mariusc83 committed Oct 27, 2022
1 parent 0edde83 commit 18ad05c
Show file tree
Hide file tree
Showing 2 changed files with 192 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ internal class RecorderWindowCallback(
private val copyEvent: (MotionEvent) -> MotionEvent = {
@Suppress("UnsafeThirdPartyFunctionCall") // NPE cannot happen here
MotionEvent.obtain(it)
}
},
private val motionUpdateThresholdInNs: Long = MOTION_UPDATE_DELAY_THRESHOLD_NS,
private val flushPositionBufferThresholdInNs: Long = FLUSH_BUFFER_THRESHOLD_NS
) : Window.Callback by wrappedCallback {

internal var positions: MutableList<MobileSegment.Position> = LinkedList()
private var lastOnMoveUpdate: Long = 0L
private var lastOnMoveUpdateTimeInNs: Long = 0L
private var lastPerformedFlushTimeInNs: Long = System.nanoTime()

// region Window.Callback

Expand Down Expand Up @@ -63,19 +66,28 @@ internal class RecorderWindowCallback(
private fun handleEvent(event: MotionEvent) {
when (event.action.and(MotionEvent.ACTION_MASK)) {
MotionEvent.ACTION_DOWN -> {
// reset the flush time to avoid flush in the next event
lastPerformedFlushTimeInNs = System.nanoTime()
updatePositions(event)
lastOnMoveUpdate = 0
// reset the on move update time in order to take into account the first move event
lastOnMoveUpdateTimeInNs = 0
}
MotionEvent.ACTION_MOVE -> {
if (System.nanoTime() - lastOnMoveUpdate >= MOTION_UPDATE_DELAY_NS) {
if (System.nanoTime() - lastOnMoveUpdateTimeInNs >= motionUpdateThresholdInNs) {
updatePositions(event)
lastOnMoveUpdate = System.nanoTime()
lastOnMoveUpdateTimeInNs = System.nanoTime()
}
// make sure we flush from time to time to avoid glitches in the player
if (System.nanoTime() - lastPerformedFlushTimeInNs >=
flushPositionBufferThresholdInNs
) {
flushPositions()
}
}
MotionEvent.ACTION_UP -> {
updatePositions(event)
flushPositions()
lastOnMoveUpdate = 0
lastOnMoveUpdateTimeInNs = 0
}
}
}
Expand All @@ -97,16 +109,26 @@ internal class RecorderWindowCallback(
}

private fun flushPositions() {
if (positions.isEmpty()) {
return
}
val touchData = MobileSegment.MobileIncrementalData
.TouchData(LinkedList(positions))
processor.process(touchData)
positions.clear()
lastPerformedFlushTimeInNs = System.nanoTime()
}

// endregion

companion object {
private const val EVENT_CONSUMED: Boolean = true
internal val MOTION_UPDATE_DELAY_NS: Long = TimeUnit.MILLISECONDS.toNanos(20)

// every frame we collect the move event positions
internal val MOTION_UPDATE_DELAY_THRESHOLD_NS: Long =
TimeUnit.MILLISECONDS.toNanos(16)

// every 10 frames we flush the buffer
internal val FLUSH_BUFFER_THRESHOLD_NS: Long = MOTION_UPDATE_DELAY_THRESHOLD_NS * 10
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import com.datadog.android.sessionreplay.processor.Processor
import com.datadog.android.sessionreplay.utils.ForgeConfigurator
import com.datadog.android.sessionreplay.utils.TimeProvider
import com.datadog.tools.unit.forge.aThrowable
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.doThrow
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import fr.xgouchet.elmyr.Forge
Expand Down Expand Up @@ -72,7 +74,9 @@ internal class RecorderWindowCallbackTest {
fakeDensity.toFloat(),
mockWrappedCallback,
mockTimeProvider,
copyEvent = { it }
copyEvent = { it },
TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS,
TEST_FLUSH_BUFFER_THRESHOLD_NS
)
}

Expand Down Expand Up @@ -163,41 +167,173 @@ internal class RecorderWindowCallbackTest {
}

@Test
fun `M update the positions W onTouchEvent() { ActionMove }`(forge: Forge) {
fun `M debounce the positions update W onTouchEvent() {one gesture cycle}`(forge: Forge) {
// Given
val fakePositions = forge.positions()
val relatedMotionEvent = fakePositions.asMotionEvent(MotionEvent.ACTION_MOVE)
val fakeEvent1Positions = forge.positions()
val relatedMotionEvent1 = fakeEvent1Positions.asMotionEvent(MotionEvent.ACTION_DOWN)
val fakeEvent2Positions = forge.positions()
val relatedMotionEvent2 = fakeEvent2Positions.asMotionEvent(MotionEvent.ACTION_MOVE)
val fakeEvent3Positions = forge.positions()
val relatedMotionEvent3 = fakeEvent3Positions.asMotionEvent(MotionEvent.ACTION_MOVE)
val fakeEvent4Positions = forge.positions()
val relatedMotionEvent4 = fakeEvent4Positions.asMotionEvent(MotionEvent.ACTION_MOVE)
val fakeEvent5Positions = forge.positions()
val relatedMotionEvent5 = fakeEvent5Positions.asMotionEvent(MotionEvent.ACTION_UP)

// When
testedWindowCallback.dispatchTouchEvent(relatedMotionEvent)
testedWindowCallback.dispatchTouchEvent(relatedMotionEvent1)
testedWindowCallback.dispatchTouchEvent(relatedMotionEvent2)
// must skip 3 as the motion update delay was not reached
testedWindowCallback.dispatchTouchEvent(relatedMotionEvent3)
Thread.sleep(
TimeUnit.NANOSECONDS
.toMillis(TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS)
)
testedWindowCallback.dispatchTouchEvent(relatedMotionEvent4)

testedWindowCallback.dispatchTouchEvent(relatedMotionEvent5)

// Then
assertThat(testedWindowCallback.positions).isEqualTo(fakePositions)
verify(mockProcessor).process(
MobileSegment.MobileIncrementalData.TouchData(
fakeEvent1Positions +
fakeEvent2Positions +
fakeEvent4Positions +
fakeEvent5Positions
)
)
}

@Test
fun `M debounce the positions update W onTouchEvent() { multiple ActionMove }`(forge: Forge) {
fun `M perform intermediary flush W onTouchEvent() {one long gesture cycle}`(forge: Forge) {
// Given
val fakeEvent1Positions = forge.positions()
val relatedMotionEvent1 = fakeEvent1Positions.asMotionEvent(MotionEvent.ACTION_MOVE)
val fakeEvent2Positions = forge.positions()
val relatedMotionEvent2 = fakeEvent2Positions.asMotionEvent(MotionEvent.ACTION_MOVE)
val fakeEvent3Positions = forge.positions()
val relatedMotionEvent3 = fakeEvent3Positions.asMotionEvent(MotionEvent.ACTION_MOVE)
val fakeDownEventPositions = forge.positions()
val fakeDownEvent = fakeDownEventPositions.asMotionEvent(MotionEvent.ACTION_DOWN)
val fakeMovePositionsBeforeFlush = forge.aList(size = forge.anInt(min = 1, max = 5)) {
positions()
}
val fakeMoveEventsBeforeFlush = fakeMovePositionsBeforeFlush
.map { it.asMotionEvent(MotionEvent.ACTION_MOVE) }
val fakeMovePositionsAfterFlush = forge.aList(size = forge.anInt(min = 1, max = 5)) {
positions()
}
val fakeMoveEventsAfterFlush = fakeMovePositionsAfterFlush
.map { it.asMotionEvent(MotionEvent.ACTION_MOVE) }
val fakeUpEventPositions = forge.positions()
val fakeUpEvent = fakeUpEventPositions.asMotionEvent(MotionEvent.ACTION_UP)
val expectedTouchData1 = MobileSegment.MobileIncrementalData.TouchData(
fakeDownEventPositions +
fakeMovePositionsBeforeFlush.flatten()
)
val expectedTouchData2 = MobileSegment.MobileIncrementalData.TouchData(
fakeMovePositionsAfterFlush.flatten() +
fakeUpEventPositions
)

// When
testedWindowCallback.dispatchTouchEvent(relatedMotionEvent1)
// must skip 2 as the motion update delay was not reached
testedWindowCallback.dispatchTouchEvent(relatedMotionEvent2)
testedWindowCallback.dispatchTouchEvent(fakeDownEvent)

fakeMoveEventsBeforeFlush.forEachIndexed { index, event ->
if (index == fakeMoveEventsBeforeFlush.size - 1) {
Thread.sleep(TimeUnit.NANOSECONDS.toMillis(TEST_FLUSH_BUFFER_THRESHOLD_NS))
} else {
Thread.sleep(TimeUnit.NANOSECONDS.toMillis(TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS))
}
testedWindowCallback.dispatchTouchEvent(event)
}
fakeMoveEventsAfterFlush.forEach {
Thread.sleep(TimeUnit.NANOSECONDS.toMillis(TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS))
testedWindowCallback.dispatchTouchEvent(it)
}

testedWindowCallback.dispatchTouchEvent(fakeUpEvent)

// Then
val argumentCaptor = argumentCaptor<MobileSegment.MobileIncrementalData.TouchData>()
verify(mockProcessor, times(2)).process(argumentCaptor.capture())
assertThat(argumentCaptor.firstValue).isEqualTo(expectedTouchData1)
assertThat(argumentCaptor.lastValue).isEqualTo(expectedTouchData2)
}

@Test
fun `M always collect the first move event after down W onTouchEvent()`(forge: Forge) {
// Given
val fakeGesture1DownPositions = forge.positions()
val fakeGesture1DownEvent = fakeGesture1DownPositions.asMotionEvent(MotionEvent.ACTION_DOWN)
val fakeGesture1MovePositions = forge.positions()
val fakeGesture1MoveEvent = fakeGesture1MovePositions.asMotionEvent(MotionEvent.ACTION_MOVE)
val fakeGesture1UpPositions = forge.positions()
val fakeGesture1UpEvent = fakeGesture1UpPositions.asMotionEvent(MotionEvent.ACTION_UP)

val fakeGesture2DownPositions = forge.positions()
val fakeGesture2DownEvent = fakeGesture2DownPositions.asMotionEvent(MotionEvent.ACTION_DOWN)
val fakeGesture2MovePositions = forge.positions()
val fakeGesture2MoveEvent = fakeGesture2MovePositions.asMotionEvent(MotionEvent.ACTION_MOVE)
val fakeGesture2UpPositions = forge.positions()
val fakeGesture2UpEvent = fakeGesture2UpPositions.asMotionEvent(MotionEvent.ACTION_UP)

val expectedTouchData1 = MobileSegment.MobileIncrementalData.TouchData(
fakeGesture1DownPositions +
fakeGesture1MovePositions +
fakeGesture1UpPositions
)

val expectedTouchData2 = MobileSegment.MobileIncrementalData.TouchData(
fakeGesture2DownPositions +
fakeGesture2MovePositions +
fakeGesture2UpPositions
)

// When
testedWindowCallback.dispatchTouchEvent(fakeGesture1DownEvent)
testedWindowCallback.dispatchTouchEvent(fakeGesture1MoveEvent)
testedWindowCallback.dispatchTouchEvent(fakeGesture1UpEvent)
testedWindowCallback.dispatchTouchEvent(fakeGesture2DownEvent)
testedWindowCallback.dispatchTouchEvent(fakeGesture2MoveEvent)
testedWindowCallback.dispatchTouchEvent(fakeGesture2UpEvent)

// Then
val argumentCaptor = argumentCaptor<MobileSegment.MobileIncrementalData.TouchData>()
verify(mockProcessor, times(2)).process(argumentCaptor.capture())
assertThat(argumentCaptor.firstValue).isEqualTo(expectedTouchData1)
assertThat(argumentCaptor.lastValue).isEqualTo(expectedTouchData2)
}

@Test
fun `M flush the intermediary positions W onTouchEvent() {move event longer than threshold}`(
forge: Forge
) {
// Given
val fakeDownPositions = forge.positions()
val fakeDownEvent = fakeDownPositions.asMotionEvent(MotionEvent.ACTION_DOWN)
val fakeEvent1MovePositions = forge.positions()
val fakeMotion1Event = fakeEvent1MovePositions.asMotionEvent(MotionEvent.ACTION_MOVE)
val fakeEvent2MovePositions = forge.positions()
val fakeMotion2Event = fakeEvent2MovePositions.asMotionEvent(MotionEvent.ACTION_MOVE)

val expectedTouchData1 = MobileSegment.MobileIncrementalData.TouchData(
fakeDownPositions +
fakeEvent1MovePositions
)
val expectedTouchData2 = MobileSegment.MobileIncrementalData.TouchData(fakeEvent2MovePositions)

// When
testedWindowCallback.dispatchTouchEvent(fakeDownEvent)
Thread.sleep(
TimeUnit.NANOSECONDS.toMillis(TEST_FLUSH_BUFFER_THRESHOLD_NS)
)
testedWindowCallback.dispatchTouchEvent(fakeMotion1Event)
Thread.sleep(
TimeUnit.NANOSECONDS
.toMillis(RecorderWindowCallback.MOTION_UPDATE_DELAY_NS)
.toMillis(TEST_FLUSH_BUFFER_THRESHOLD_NS)
)
testedWindowCallback.dispatchTouchEvent(relatedMotionEvent3)
testedWindowCallback.dispatchTouchEvent(fakeMotion2Event)

// Then
assertThat(testedWindowCallback.positions)
.isEqualTo(fakeEvent1Positions + fakeEvent3Positions)
val argumentCaptor = argumentCaptor<MobileSegment.MobileIncrementalData.TouchData>()
verify(mockProcessor, times(2)).process(argumentCaptor.capture())
assertThat(argumentCaptor.firstValue).isEqualTo(expectedTouchData1)
assertThat(argumentCaptor.lastValue).isEqualTo(expectedTouchData2)
}

@Test
Expand Down Expand Up @@ -269,5 +405,12 @@ internal class RecorderWindowCallbackTest {

companion object {
private val FLOAT_MAX_INT_VALUE = Math.pow(2.0, 23.0).toFloat()

// We need to test with higher threshold values in order to avoid flakiness
private val TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS: Long =
TimeUnit.MILLISECONDS.toNanos(100)

private val TEST_FLUSH_BUFFER_THRESHOLD_NS: Long =
TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS * 10
}
}

0 comments on commit 18ad05c

Please sign in to comment.