Skip to content

Commit

Permalink
Merge 55c2d4b into 7653989
Browse files Browse the repository at this point in the history
  • Loading branch information
romtsn authored Aug 6, 2024
2 parents 7653989 + 55c2d4b commit e886ec4
Show file tree
Hide file tree
Showing 6 changed files with 278 additions and 200 deletions.
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

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

0 comments on commit e886ec4

Please sign in to comment.