Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Decouple gesture tracking from WindowRecorder
  • Loading branch information
romtsn committed Aug 6, 2024
commit 55c2d4bfb9515527334c099cb60dbd6e7c8ab772
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import io.sentry.android.replay.capture.BufferCaptureStrategy
import io.sentry.android.replay.capture.CaptureStrategy
import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment
import io.sentry.android.replay.capture.SessionCaptureStrategy
import io.sentry.android.replay.gestures.GestureRecorder
import io.sentry.android.replay.gestures.TouchRecorderCallback
import io.sentry.android.replay.util.MainLooperHandler
import io.sentry.android.replay.util.sample
import io.sentry.android.replay.util.submitSafely
Expand All @@ -37,6 +39,7 @@ import java.io.File
import java.security.SecureRandom
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.LazyThreadSafetyMode.NONE
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL 😄


public class ReplayIntegration(
private val context: Context,
Expand All @@ -62,16 +65,20 @@ public class ReplayIntegration(
recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)?,
replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)?,
replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null,
mainLooperHandler: MainLooperHandler? = null
mainLooperHandler: MainLooperHandler? = null,
gestureRecorderProvider: (() -> GestureRecorder)? = null
) : this(context, dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) {
this.replayCaptureStrategyProvider = replayCaptureStrategyProvider
this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler()
this.gestureRecorderProvider = gestureRecorderProvider
}

private lateinit var options: SentryOptions
private var hub: IHub? = null
private var recorder: Recorder? = null
private var gestureRecorder: GestureRecorder? = null
private val random by lazy { SecureRandom() }
private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() }

// TODO: probably not everything has to be thread-safe here
internal val isEnabled = AtomicBoolean(false)
Expand All @@ -81,6 +88,7 @@ public class ReplayIntegration(
private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter.getInstance()
private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null
private var mainLooperHandler: MainLooperHandler = MainLooperHandler()
private var gestureRecorderProvider: (() -> GestureRecorder)? = null

private lateinit var recorderConfig: ScreenshotRecorderConfig

Expand All @@ -100,7 +108,8 @@ public class ReplayIntegration(
}

this.hub = hub
recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this, mainLooperHandler)
recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, mainLooperHandler)
gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this)
isEnabled.set(true)

try {
Expand Down Expand Up @@ -147,6 +156,7 @@ public class ReplayIntegration(

captureStrategy?.start(recorderConfig)
recorder?.start(recorderConfig)
registerRootViewListeners()
}

override fun resume() {
Expand Down Expand Up @@ -197,7 +207,9 @@ public class ReplayIntegration(
return
}

unregisterRootViewListeners()
recorder?.stop()
gestureRecorder?.stop()
captureStrategy?.stop()
isRecording.set(false)
captureStrategy?.close()
Expand Down Expand Up @@ -252,6 +264,20 @@ public class ReplayIntegration(
captureStrategy?.onTouchEvent(event)
}

private fun registerRootViewListeners() {
if (recorder is OnRootViewsChangedListener) {
rootViewsSpy.listeners += (recorder as OnRootViewsChangedListener)
}
rootViewsSpy.listeners += gestureRecorder
}

private fun unregisterRootViewListeners() {
if (recorder is OnRootViewsChangedListener) {
rootViewsSpy.listeners -= (recorder as OnRootViewsChangedListener)
}
rootViewsSpy.listeners -= gestureRecorder
}

private fun cleanupReplays(unfinishedReplayId: String = "") {
// clean up old replays
options.cacheDirPath?.let { cacheDir ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,13 @@ import kotlin.LazyThreadSafetyMode.NONE
internal class WindowRecorder(
private val options: SentryOptions,
private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null,
private val touchRecorderCallback: TouchRecorderCallback? = null,
private val mainLooperHandler: MainLooperHandler
) : Recorder {
) : Recorder, OnRootViewsChangedListener {

internal companion object {
private const val TAG = "WindowRecorder"
}

private val rootViewsSpy by lazy(NONE) {
RootViewsSpy.install()
}

private val isRecording = AtomicBoolean(false)
private val rootViews = ArrayList<WeakReference<View>>()
private var recorder: ScreenshotRecorder? = null
Expand All @@ -43,15 +38,11 @@ internal class WindowRecorder(
Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory())
}

private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added ->
override fun onRootViewsChanged(root: View, added: Boolean) {
if (added) {
rootViews.add(WeakReference(root))
recorder?.bind(root)

root.startGestureTracking()
} else {
root.stopGestureTracking()

recorder?.unbind(root)
rootViews.removeAll { it.get() == root }

Expand All @@ -68,11 +59,10 @@ internal class WindowRecorder(
}

recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, screenshotRecorderCallback)
rootViewsSpy.listeners += onRootViewsChangedListener
capturingTask = capturer.scheduleAtFixedRateSafely(
options,
"$TAG.capture",
0L,
100L,
1000L / recorderConfig.frameRate,
MILLISECONDS
) {
Expand All @@ -88,7 +78,6 @@ internal class WindowRecorder(
}

override fun stop() {
rootViewsSpy.listeners -= onRootViewsChangedListener
rootViews.forEach { recorder?.unbind(it.get()) }
recorder?.close()
rootViews.clear()
Expand All @@ -103,55 +92,6 @@ internal class WindowRecorder(
capturer.gracefullyShutdown(options)
}

private fun View.startGestureTracking() {
val window = phoneWindow
if (window == null) {
options.logger.log(DEBUG, "Window is invalid, not tracking gestures")
return
}

if (touchRecorderCallback == null) {
options.logger.log(DEBUG, "TouchRecorderCallback is null, not tracking gestures")
return
}

val delegate = window.callback
window.callback = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate)
}

private fun View.stopGestureTracking() {
val window = phoneWindow
if (window == null) {
options.logger.log(DEBUG, "Window was null in stopGestureTracking")
return
}

if (window.callback is SentryReplayGestureRecorder) {
val delegate = (window.callback as SentryReplayGestureRecorder).delegate
window.callback = delegate
}
}

private class SentryReplayGestureRecorder(
private val options: SentryOptions,
private val touchRecorderCallback: TouchRecorderCallback?,
delegate: Window.Callback?
) : FixedWindowCallback(delegate) {
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
if (event != null) {
val copy: MotionEvent = MotionEvent.obtainNoHistory(event)
try {
touchRecorderCallback?.onTouchEvent(copy)
} catch (e: Throwable) {
options.logger.log(ERROR, "Error dispatching touch event", e)
} finally {
copy.recycle()
}
}
return super.dispatchTouchEvent(event)
}
}

private class RecorderExecutorServiceThreadFactory : ThreadFactory {
private var cnt = 0
override fun newThread(r: Runnable): Thread {
Expand All @@ -161,7 +101,3 @@ internal class WindowRecorder(
}
}
}

public interface TouchRecorderCallback {
fun onTouchEvent(event: MotionEvent)
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import io.sentry.android.replay.ScreenshotRecorderConfig
import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment
import io.sentry.android.replay.capture.CaptureStrategy.Companion.currentEventsLock
import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment
import io.sentry.android.replay.gestures.ReplayGestureConverter
import io.sentry.android.replay.util.PersistableLinkedList
import io.sentry.android.replay.util.gracefullyShutdown
import io.sentry.android.replay.util.submitSafely
Expand Down Expand Up @@ -56,15 +57,12 @@ internal abstract class BaseCaptureStrategy(

internal companion object {
private const val TAG = "CaptureStrategy"

// rrweb values
private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50
private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500
}

private val persistingExecutor: ScheduledExecutorService by lazy {
Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory())
}
private val gestureConverter = ReplayGestureConverter(dateProvider)

protected val isTerminating = AtomicBoolean(false)
protected var cache: ReplayCache? = null
Expand Down Expand Up @@ -94,9 +92,6 @@ internal abstract class BaseCaptureStrategy(
persistingExecutor,
cacheProvider = { cache }
)
private val currentPositions = LinkedHashMap<Int, ArrayList<Position>>(10)
private var touchMoveBaseline = 0L
private var lastCapturedMoveEvent = 0L

protected val replayExecutor: ScheduledExecutorService by lazy {
executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory())
Expand Down Expand Up @@ -169,7 +164,7 @@ internal abstract class BaseCaptureStrategy(
}

override fun onTouchEvent(event: MotionEvent) {
val rrwebEvents = event.toRRWebIncrementalSnapshotEvent()
val rrwebEvents = gestureConverter.convert(event, recorderConfig)
if (rrwebEvents != null) {
synchronized(currentEventsLock) {
currentEvents += rrwebEvents
Expand Down Expand Up @@ -199,126 +194,6 @@ internal abstract class BaseCaptureStrategy(
}
}

private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): List<RRWebIncrementalSnapshotEvent>? {
val event = this
return when (event.actionMasked) {
MotionEvent.ACTION_MOVE -> {
// we only throttle move events as those can be overwhelming
val now = dateProvider.currentTimeMillis
if (lastCapturedMoveEvent != 0L && lastCapturedMoveEvent + TOUCH_MOVE_DEBOUNCE_THRESHOLD > now) {
return null
}
lastCapturedMoveEvent = now

currentPositions.keys.forEach { pId ->
val pIndex = event.findPointerIndex(pId)

if (pIndex == -1) {
// no data for this pointer
return@forEach
}

// idk why but rrweb does it like dis
if (touchMoveBaseline == 0L) {
touchMoveBaseline = now
}

currentPositions[pId]!! += Position().apply {
x = event.getX(pIndex) * recorderConfig.scaleFactorX
y = event.getY(pIndex) * recorderConfig.scaleFactorY
id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE
timeOffset = now - touchMoveBaseline
}
}

val totalOffset = now - touchMoveBaseline
return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) {
val moveEvents = mutableListOf<RRWebInteractionMoveEvent>()
for ((pointerId, positions) in currentPositions) {
if (positions.isNotEmpty()) {
moveEvents += RRWebInteractionMoveEvent().apply {
this.timestamp = now
this.positions = positions.map { pos ->
pos.timeOffset -= totalOffset
pos
}
this.pointerId = pointerId
}
currentPositions[pointerId]!!.clear()
}
}
touchMoveBaseline = 0L
moveEvents
} else {
null
}
}

MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_POINTER_DOWN -> {
val pId = event.getPointerId(event.actionIndex)
val pIndex = event.findPointerIndex(pId)

if (pIndex == -1) {
// no data for this pointer
return null
}

// new finger down - add a new pointer for tracking movement
currentPositions[pId] = ArrayList()
listOf(
RRWebInteractionEvent().apply {
timestamp = dateProvider.currentTimeMillis
x = event.getX(pIndex) * recorderConfig.scaleFactorX
y = event.getY(pIndex) * recorderConfig.scaleFactorY
id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE
pointerId = pId
interactionType = InteractionType.TouchStart
}
)
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_POINTER_UP -> {
val pId = event.getPointerId(event.actionIndex)
val pIndex = event.findPointerIndex(pId)

if (pIndex == -1) {
// no data for this pointer
return null
}

// finger lift up - remove the pointer from tracking
currentPositions.remove(pId)
listOf(
RRWebInteractionEvent().apply {
timestamp = dateProvider.currentTimeMillis
x = event.getX(pIndex) * recorderConfig.scaleFactorX
y = event.getY(pIndex) * recorderConfig.scaleFactorY
id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE
pointerId = pId
interactionType = InteractionType.TouchEnd
}
)
}
MotionEvent.ACTION_CANCEL -> {
// gesture cancelled - remove all pointers from tracking
currentPositions.clear()
listOf(
RRWebInteractionEvent().apply {
timestamp = dateProvider.currentTimeMillis
x = event.x * recorderConfig.scaleFactorX
y = event.y * recorderConfig.scaleFactorY
id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE
pointerId = 0 // the pointerId is not used for TouchCancel, so just set it to 0
interactionType = InteractionType.TouchCancel
}
)
}

else -> null
}
}

private inline fun <T> persistableAtomicNullable(
initialValue: T? = null,
propertyName: String,
Expand Down
Loading