Skip to content

Commit 792d200

Browse files
romtsnclaude
andcommitted
fix(replay): Track wrapped windows and inert buried recorders on stop
Track wrapped windows in a WeakHashMap so GestureRecorder skips re-wrapping already-instrumented windows and can locate its own recorder even when another wrapper (e.g. UserInteractionIntegration) has been installed on top of it. When our wrapper is buried in the callback chain, inert() it instead of mutating the chain so unrelated instrumentation isn't broken; the next replay session wraps on top with a fresh active recorder. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e60ca1b commit 792d200

File tree

2 files changed

+75
-12
lines changed

2 files changed

+75
-12
lines changed

sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import io.sentry.android.replay.phoneWindow
1111
import io.sentry.android.replay.util.FixedWindowCallback
1212
import io.sentry.util.AutoClosableReentrantLock
1313
import java.lang.ref.WeakReference
14+
import java.util.WeakHashMap
1415

1516
internal class GestureRecorder(
1617
private val options: SentryOptions,
@@ -19,6 +20,11 @@ internal class GestureRecorder(
1920
private val rootViews = ArrayList<WeakReference<View>>()
2021
private val rootViewsLock = AutoClosableReentrantLock()
2122

23+
// WeakReference value, because the callback chain strongly references the wrapper — a strong
24+
// value would prevent the window from ever being GC'd.
25+
private val wrappedWindows = WeakHashMap<Window, WeakReference<SentryReplayGestureRecorder>>()
26+
private val wrappedWindowsLock = AutoClosableReentrantLock()
27+
2228
override fun onRootViewsChanged(root: View, added: Boolean) {
2329
rootViewsLock.acquire().use {
2430
if (added) {
@@ -45,10 +51,16 @@ internal class GestureRecorder(
4551
return
4652
}
4753

48-
val delegate = window.callback
49-
if (delegate !is SentryReplayGestureRecorder) {
50-
window.callback = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate)
54+
wrappedWindowsLock.acquire().use {
55+
if (wrappedWindows[window]?.get() != null) {
56+
return
57+
}
5158
}
59+
60+
val delegate = window.callback
61+
val wrapper = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate)
62+
window.callback = wrapper
63+
wrappedWindowsLock.acquire().use { wrappedWindows[window] = WeakReference(wrapper) }
5264
}
5365

5466
private fun View.stopGestureTracking() {
@@ -60,14 +72,25 @@ internal class GestureRecorder(
6072

6173
val callback = window.callback
6274
if (callback is SentryReplayGestureRecorder) {
63-
val delegate = callback.delegate
64-
window.callback = delegate
75+
window.callback = callback.delegate
76+
wrappedWindowsLock.acquire().use { wrappedWindows.remove(window) }
77+
return
78+
}
79+
80+
// Another wrapper (e.g. UserInteractionIntegration) sits on top of ours — cutting it out of
81+
// the chain would break its instrumentation, so we inert our buried wrapper instead. The
82+
// next replay session will then wrap on top with a fresh active instance.
83+
val ours: SentryReplayGestureRecorder?
84+
wrappedWindowsLock.acquire().use {
85+
ours = wrappedWindows[window]?.get()
86+
wrappedWindows.remove(window)
6587
}
88+
ours?.inert()
6689
}
6790

6891
internal class SentryReplayGestureRecorder(
6992
private val options: SentryOptions,
70-
private val touchRecorderCallback: TouchRecorderCallback?,
93+
@Volatile private var touchRecorderCallback: TouchRecorderCallback?,
7194
delegate: Window.Callback?,
7295
) : FixedWindowCallback(delegate) {
7396
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
@@ -83,6 +106,14 @@ internal class GestureRecorder(
83106
}
84107
return super.dispatchTouchEvent(event)
85108
}
109+
110+
/**
111+
* Turns this wrapper into a passthrough when it can't be removed from the chain (another
112+
* wrapper sits on top). Subsequent dispatches only delegate, skipping the recorder callback.
113+
*/
114+
fun inert() {
115+
touchRecorderCallback = null
116+
}
86117
}
87118
}
88119

sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.app.Activity
55
import android.os.Bundle
66
import android.view.MotionEvent
77
import android.view.View
8+
import android.view.Window
89
import android.widget.LinearLayout
910
import androidx.test.ext.junit.runners.AndroidJUnit4
1011
import io.sentry.SentryOptions
@@ -14,6 +15,7 @@ import io.sentry.android.replay.phoneWindow
1415
import kotlin.test.Test
1516
import kotlin.test.assertEquals
1617
import kotlin.test.assertFalse
18+
import kotlin.test.assertSame
1719
import kotlin.test.assertTrue
1820
import org.junit.runner.RunWith
1921
import org.robolectric.Robolectric
@@ -37,17 +39,44 @@ class GestureRecorderTest {
3739
}
3840

3941
@Test
40-
fun `when new window added and window callback is already wrapped, does not wrap it again`() {
42+
fun `does not double-wrap when root is added twice and another callback wraps on top`() {
4143
val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get()
4244
val gestureRecorder = fixture.getSut()
4345

44-
activity.root.phoneWindow?.callback = SentryReplayGestureRecorder(fixture.options, null, null)
4546
gestureRecorder.onRootViewsChanged(activity.root, true)
47+
val ourWrapper = activity.root.phoneWindow?.callback as SentryReplayGestureRecorder
4648

47-
assertFalse(
48-
(activity.root.phoneWindow?.callback as SentryReplayGestureRecorder).delegate
49-
is SentryReplayGestureRecorder
50-
)
49+
val outer = WrapperCallback(ourWrapper)
50+
activity.root.phoneWindow?.callback = outer
51+
52+
gestureRecorder.onRootViewsChanged(activity.root, true)
53+
54+
assertSame(outer, activity.root.phoneWindow?.callback)
55+
}
56+
57+
@Test
58+
fun `when stopped with another wrapper on top, inerts the buried recorder`() {
59+
var called = false
60+
val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get()
61+
val gestureRecorder =
62+
fixture.getSut(
63+
touchRecorderCallback =
64+
object : TouchRecorderCallback {
65+
override fun onTouchEvent(event: MotionEvent) {
66+
called = true
67+
}
68+
}
69+
)
70+
71+
gestureRecorder.onRootViewsChanged(activity.root, true)
72+
val ourWrapper = activity.root.phoneWindow?.callback as SentryReplayGestureRecorder
73+
activity.root.phoneWindow?.callback = WrapperCallback(ourWrapper)
74+
75+
gestureRecorder.onRootViewsChanged(activity.root, false)
76+
77+
val motionEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0)
78+
ourWrapper.dispatchTouchEvent(motionEvent)
79+
assertFalse(called)
5180
}
5281

5382
@Test
@@ -109,6 +138,9 @@ class GestureRecorderTest {
109138
}
110139
}
111140

141+
private open class WrapperCallback(@JvmField val delegate: Window.Callback) :
142+
Window.Callback by delegate
143+
112144
private class TestActivity : Activity() {
113145
lateinit var root: View
114146

0 commit comments

Comments
 (0)