Skip to content

Commit e886ec4

Browse files
authored
Merge 55c2d4b into 7653989
2 parents 7653989 + 55c2d4b commit e886ec4

File tree

6 files changed

+278
-200
lines changed

6 files changed

+278
-200
lines changed

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt

+28-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import io.sentry.android.replay.capture.BufferCaptureStrategy
2020
import io.sentry.android.replay.capture.CaptureStrategy
2121
import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment
2222
import io.sentry.android.replay.capture.SessionCaptureStrategy
23+
import io.sentry.android.replay.gestures.GestureRecorder
24+
import io.sentry.android.replay.gestures.TouchRecorderCallback
2325
import io.sentry.android.replay.util.MainLooperHandler
2426
import io.sentry.android.replay.util.sample
2527
import io.sentry.android.replay.util.submitSafely
@@ -37,6 +39,7 @@ import java.io.File
3739
import java.security.SecureRandom
3840
import java.util.LinkedList
3941
import java.util.concurrent.atomic.AtomicBoolean
42+
import kotlin.LazyThreadSafetyMode.NONE
4043

4144
public class ReplayIntegration(
4245
private val context: Context,
@@ -62,16 +65,20 @@ public class ReplayIntegration(
6265
recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)?,
6366
replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)?,
6467
replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null,
65-
mainLooperHandler: MainLooperHandler? = null
68+
mainLooperHandler: MainLooperHandler? = null,
69+
gestureRecorderProvider: (() -> GestureRecorder)? = null
6670
) : this(context, dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) {
6771
this.replayCaptureStrategyProvider = replayCaptureStrategyProvider
6872
this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler()
73+
this.gestureRecorderProvider = gestureRecorderProvider
6974
}
7075

7176
private lateinit var options: SentryOptions
7277
private var hub: IHub? = null
7378
private var recorder: Recorder? = null
79+
private var gestureRecorder: GestureRecorder? = null
7480
private val random by lazy { SecureRandom() }
81+
private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() }
7582

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

8593
private lateinit var recorderConfig: ScreenshotRecorderConfig
8694

@@ -100,7 +108,8 @@ public class ReplayIntegration(
100108
}
101109

102110
this.hub = hub
103-
recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this, mainLooperHandler)
111+
recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, mainLooperHandler)
112+
gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this)
104113
isEnabled.set(true)
105114

106115
try {
@@ -147,6 +156,7 @@ public class ReplayIntegration(
147156

148157
captureStrategy?.start(recorderConfig)
149158
recorder?.start(recorderConfig)
159+
registerRootViewListeners()
150160
}
151161

152162
override fun resume() {
@@ -197,7 +207,9 @@ public class ReplayIntegration(
197207
return
198208
}
199209

210+
unregisterRootViewListeners()
200211
recorder?.stop()
212+
gestureRecorder?.stop()
201213
captureStrategy?.stop()
202214
isRecording.set(false)
203215
captureStrategy?.close()
@@ -252,6 +264,20 @@ public class ReplayIntegration(
252264
captureStrategy?.onTouchEvent(event)
253265
}
254266

267+
private fun registerRootViewListeners() {
268+
if (recorder is OnRootViewsChangedListener) {
269+
rootViewsSpy.listeners += (recorder as OnRootViewsChangedListener)
270+
}
271+
rootViewsSpy.listeners += gestureRecorder
272+
}
273+
274+
private fun unregisterRootViewListeners() {
275+
if (recorder is OnRootViewsChangedListener) {
276+
rootViewsSpy.listeners -= (recorder as OnRootViewsChangedListener)
277+
}
278+
rootViewsSpy.listeners -= gestureRecorder
279+
}
280+
255281
private fun cleanupReplays(unfinishedReplayId: String = "") {
256282
// clean up old replays
257283
options.cacheDirPath?.let { cacheDir ->

sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt

+3-67
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,13 @@ import kotlin.LazyThreadSafetyMode.NONE
2323
internal class WindowRecorder(
2424
private val options: SentryOptions,
2525
private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null,
26-
private val touchRecorderCallback: TouchRecorderCallback? = null,
2726
private val mainLooperHandler: MainLooperHandler
28-
) : Recorder {
27+
) : Recorder, OnRootViewsChangedListener {
2928

3029
internal companion object {
3130
private const val TAG = "WindowRecorder"
3231
}
3332

34-
private val rootViewsSpy by lazy(NONE) {
35-
RootViewsSpy.install()
36-
}
37-
3833
private val isRecording = AtomicBoolean(false)
3934
private val rootViews = ArrayList<WeakReference<View>>()
4035
private var recorder: ScreenshotRecorder? = null
@@ -43,15 +38,11 @@ internal class WindowRecorder(
4338
Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory())
4439
}
4540

46-
private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added ->
41+
override fun onRootViewsChanged(root: View, added: Boolean) {
4742
if (added) {
4843
rootViews.add(WeakReference(root))
4944
recorder?.bind(root)
50-
51-
root.startGestureTracking()
5245
} else {
53-
root.stopGestureTracking()
54-
5546
recorder?.unbind(root)
5647
rootViews.removeAll { it.get() == root }
5748

@@ -68,11 +59,10 @@ internal class WindowRecorder(
6859
}
6960

7061
recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, screenshotRecorderCallback)
71-
rootViewsSpy.listeners += onRootViewsChangedListener
7262
capturingTask = capturer.scheduleAtFixedRateSafely(
7363
options,
7464
"$TAG.capture",
75-
0L,
65+
100L,
7666
1000L / recorderConfig.frameRate,
7767
MILLISECONDS
7868
) {
@@ -88,7 +78,6 @@ internal class WindowRecorder(
8878
}
8979

9080
override fun stop() {
91-
rootViewsSpy.listeners -= onRootViewsChangedListener
9281
rootViews.forEach { recorder?.unbind(it.get()) }
9382
recorder?.close()
9483
rootViews.clear()
@@ -103,55 +92,6 @@ internal class WindowRecorder(
10392
capturer.gracefullyShutdown(options)
10493
}
10594

106-
private fun View.startGestureTracking() {
107-
val window = phoneWindow
108-
if (window == null) {
109-
options.logger.log(DEBUG, "Window is invalid, not tracking gestures")
110-
return
111-
}
112-
113-
if (touchRecorderCallback == null) {
114-
options.logger.log(DEBUG, "TouchRecorderCallback is null, not tracking gestures")
115-
return
116-
}
117-
118-
val delegate = window.callback
119-
window.callback = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate)
120-
}
121-
122-
private fun View.stopGestureTracking() {
123-
val window = phoneWindow
124-
if (window == null) {
125-
options.logger.log(DEBUG, "Window was null in stopGestureTracking")
126-
return
127-
}
128-
129-
if (window.callback is SentryReplayGestureRecorder) {
130-
val delegate = (window.callback as SentryReplayGestureRecorder).delegate
131-
window.callback = delegate
132-
}
133-
}
134-
135-
private class SentryReplayGestureRecorder(
136-
private val options: SentryOptions,
137-
private val touchRecorderCallback: TouchRecorderCallback?,
138-
delegate: Window.Callback?
139-
) : FixedWindowCallback(delegate) {
140-
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
141-
if (event != null) {
142-
val copy: MotionEvent = MotionEvent.obtainNoHistory(event)
143-
try {
144-
touchRecorderCallback?.onTouchEvent(copy)
145-
} catch (e: Throwable) {
146-
options.logger.log(ERROR, "Error dispatching touch event", e)
147-
} finally {
148-
copy.recycle()
149-
}
150-
}
151-
return super.dispatchTouchEvent(event)
152-
}
153-
}
154-
15595
private class RecorderExecutorServiceThreadFactory : ThreadFactory {
15696
private var cnt = 0
15797
override fun newThread(r: Runnable): Thread {
@@ -161,7 +101,3 @@ internal class WindowRecorder(
161101
}
162102
}
163103
}
164-
165-
public interface TouchRecorderCallback {
166-
fun onTouchEvent(event: MotionEvent)
167-
}

sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt

+3-128
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import io.sentry.android.replay.ScreenshotRecorderConfig
2323
import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment
2424
import io.sentry.android.replay.capture.CaptureStrategy.Companion.currentEventsLock
2525
import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment
26+
import io.sentry.android.replay.gestures.ReplayGestureConverter
2627
import io.sentry.android.replay.util.PersistableLinkedList
2728
import io.sentry.android.replay.util.gracefullyShutdown
2829
import io.sentry.android.replay.util.submitSafely
@@ -56,15 +57,12 @@ internal abstract class BaseCaptureStrategy(
5657

5758
internal companion object {
5859
private const val TAG = "CaptureStrategy"
59-
60-
// rrweb values
61-
private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50
62-
private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500
6360
}
6461

6562
private val persistingExecutor: ScheduledExecutorService by lazy {
6663
Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory())
6764
}
65+
private val gestureConverter = ReplayGestureConverter(dateProvider)
6866

6967
protected val isTerminating = AtomicBoolean(false)
7068
protected var cache: ReplayCache? = null
@@ -94,9 +92,6 @@ internal abstract class BaseCaptureStrategy(
9492
persistingExecutor,
9593
cacheProvider = { cache }
9694
)
97-
private val currentPositions = LinkedHashMap<Int, ArrayList<Position>>(10)
98-
private var touchMoveBaseline = 0L
99-
private var lastCapturedMoveEvent = 0L
10095

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

171166
override fun onTouchEvent(event: MotionEvent) {
172-
val rrwebEvents = event.toRRWebIncrementalSnapshotEvent()
167+
val rrwebEvents = gestureConverter.convert(event, recorderConfig)
173168
if (rrwebEvents != null) {
174169
synchronized(currentEventsLock) {
175170
currentEvents += rrwebEvents
@@ -199,126 +194,6 @@ internal abstract class BaseCaptureStrategy(
199194
}
200195
}
201196

202-
private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): List<RRWebIncrementalSnapshotEvent>? {
203-
val event = this
204-
return when (event.actionMasked) {
205-
MotionEvent.ACTION_MOVE -> {
206-
// we only throttle move events as those can be overwhelming
207-
val now = dateProvider.currentTimeMillis
208-
if (lastCapturedMoveEvent != 0L && lastCapturedMoveEvent + TOUCH_MOVE_DEBOUNCE_THRESHOLD > now) {
209-
return null
210-
}
211-
lastCapturedMoveEvent = now
212-
213-
currentPositions.keys.forEach { pId ->
214-
val pIndex = event.findPointerIndex(pId)
215-
216-
if (pIndex == -1) {
217-
// no data for this pointer
218-
return@forEach
219-
}
220-
221-
// idk why but rrweb does it like dis
222-
if (touchMoveBaseline == 0L) {
223-
touchMoveBaseline = now
224-
}
225-
226-
currentPositions[pId]!! += Position().apply {
227-
x = event.getX(pIndex) * recorderConfig.scaleFactorX
228-
y = event.getY(pIndex) * recorderConfig.scaleFactorY
229-
id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE
230-
timeOffset = now - touchMoveBaseline
231-
}
232-
}
233-
234-
val totalOffset = now - touchMoveBaseline
235-
return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) {
236-
val moveEvents = mutableListOf<RRWebInteractionMoveEvent>()
237-
for ((pointerId, positions) in currentPositions) {
238-
if (positions.isNotEmpty()) {
239-
moveEvents += RRWebInteractionMoveEvent().apply {
240-
this.timestamp = now
241-
this.positions = positions.map { pos ->
242-
pos.timeOffset -= totalOffset
243-
pos
244-
}
245-
this.pointerId = pointerId
246-
}
247-
currentPositions[pointerId]!!.clear()
248-
}
249-
}
250-
touchMoveBaseline = 0L
251-
moveEvents
252-
} else {
253-
null
254-
}
255-
}
256-
257-
MotionEvent.ACTION_DOWN,
258-
MotionEvent.ACTION_POINTER_DOWN -> {
259-
val pId = event.getPointerId(event.actionIndex)
260-
val pIndex = event.findPointerIndex(pId)
261-
262-
if (pIndex == -1) {
263-
// no data for this pointer
264-
return null
265-
}
266-
267-
// new finger down - add a new pointer for tracking movement
268-
currentPositions[pId] = ArrayList()
269-
listOf(
270-
RRWebInteractionEvent().apply {
271-
timestamp = dateProvider.currentTimeMillis
272-
x = event.getX(pIndex) * recorderConfig.scaleFactorX
273-
y = event.getY(pIndex) * recorderConfig.scaleFactorY
274-
id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE
275-
pointerId = pId
276-
interactionType = InteractionType.TouchStart
277-
}
278-
)
279-
}
280-
MotionEvent.ACTION_UP,
281-
MotionEvent.ACTION_POINTER_UP -> {
282-
val pId = event.getPointerId(event.actionIndex)
283-
val pIndex = event.findPointerIndex(pId)
284-
285-
if (pIndex == -1) {
286-
// no data for this pointer
287-
return null
288-
}
289-
290-
// finger lift up - remove the pointer from tracking
291-
currentPositions.remove(pId)
292-
listOf(
293-
RRWebInteractionEvent().apply {
294-
timestamp = dateProvider.currentTimeMillis
295-
x = event.getX(pIndex) * recorderConfig.scaleFactorX
296-
y = event.getY(pIndex) * recorderConfig.scaleFactorY
297-
id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE
298-
pointerId = pId
299-
interactionType = InteractionType.TouchEnd
300-
}
301-
)
302-
}
303-
MotionEvent.ACTION_CANCEL -> {
304-
// gesture cancelled - remove all pointers from tracking
305-
currentPositions.clear()
306-
listOf(
307-
RRWebInteractionEvent().apply {
308-
timestamp = dateProvider.currentTimeMillis
309-
x = event.x * recorderConfig.scaleFactorX
310-
y = event.y * recorderConfig.scaleFactorY
311-
id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE
312-
pointerId = 0 // the pointerId is not used for TouchCancel, so just set it to 0
313-
interactionType = InteractionType.TouchCancel
314-
}
315-
)
316-
}
317-
318-
else -> null
319-
}
320-
}
321-
322197
private inline fun <T> persistableAtomicNullable(
323198
initialValue: T? = null,
324199
propertyName: String,

0 commit comments

Comments
 (0)