Skip to content

Commit b7674b8

Browse files
romtsnclaude
andcommitted
fix(user-interaction): Inert buried SentryWindowCallback and drop its cache entry on stop
Two follow-ups to the buried-wrapper path: - stopTracking() now sets an inert flag that short-circuits handleTouchEvent, so a SentryWindowCallback that can't be cut out of the chain stops forwarding events to its gesture detector and listener. Without this, the "stopped" wrapper kept emitting ui.click breadcrumbs, so as soon as a fresh wrapper was installed on top the duplicates came back. - unwrapWindow removes the wrapped window from the tracking map in the buried path too. Previously only the top-of-chain path cleared it, which meant the next startTracking() found a stale (but alive, since the inert wrapper is still referenced by the chain) entry and returned early, permanently losing gesture tracking for that window. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8c36cbf commit b7674b8

File tree

4 files changed

+36
-8
lines changed

4 files changed

+36
-8
lines changed

sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,12 @@ private void unwrapWindow(final @NotNull Window window) {
110110
}
111111

112112
// Another wrapper (e.g. Session Replay) sits on top of ours — cutting it out of the chain
113-
// would break its instrumentation, so we leave the chain alone and only release our
114-
// resources. The inert wrapper gets GC'd when the window is destroyed.
113+
// would break its instrumentation, so we leave the chain alone and just call stopTracking()
114+
// to release our resources. The upstream wrapper holds a reference to ours, so it'll be
115+
// GC'd whenever that upstream holder is (typically when the window is destroyed).
115116
final @Nullable SentryWindowCallback ours;
116117
synchronized (wrappedWindowsLock) {
117-
final @Nullable WeakReference<SentryWindowCallback> cached = wrappedWindows.get(window);
118+
final @Nullable WeakReference<SentryWindowCallback> cached = wrappedWindows.remove(window);
118119
ours = cached != null ? cached.get() : null;
119120
}
120121
if (ours != null) {

sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ public final class SentryWindowCallback extends WindowCallbackAdapter {
1919
private final @Nullable SentryOptions options;
2020
private final @NotNull MotionEventObtainer motionEventObtainer;
2121

22+
// When we can't be removed from the callback chain (see UserInteractionIntegration),
23+
// stopTracking() flips this so handleTouchEvent short-circuits.
24+
private volatile boolean inert;
25+
2226
public SentryWindowCallback(
2327
final @NotNull Window.Callback delegate,
2428
final @NotNull Context context,
@@ -64,6 +68,9 @@ public boolean dispatchTouchEvent(final @Nullable MotionEvent motionEvent) {
6468
}
6569

6670
private void handleTouchEvent(final @NotNull MotionEvent motionEvent) {
71+
if (inert) {
72+
return;
73+
}
6774
gestureDetector.onTouchEvent(motionEvent);
6875
int action = motionEvent.getActionMasked();
6976
if (action == MotionEvent.ACTION_UP) {
@@ -72,6 +79,7 @@ private void handleTouchEvent(final @NotNull MotionEvent motionEvent) {
7279
}
7380

7481
public void stopTracking() {
82+
inert = true;
7583
gestureListener.stopTracing(SpanStatus.CANCELLED);
7684
gestureDetector.release();
7785
}

sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import kotlin.test.BeforeTest
1414
import kotlin.test.Test
1515
import kotlin.test.assertIs
1616
import kotlin.test.assertIsNot
17+
import kotlin.test.assertNotSame
1718
import kotlin.test.assertSame
1819
import org.junit.runner.RunWith
1920
import org.mockito.kotlin.any
@@ -148,21 +149,25 @@ class UserInteractionIntegrationTest {
148149
}
149150

150151
@Test
151-
fun `does not double-wrap when another callback wraps SentryWindowCallback`() {
152+
fun `resume after buried pause installs a fresh wrapper on top`() {
152153
val sut = fixture.getSut()
153154
sut.register(fixture.scopes, fixture.options)
154155

155156
sut.onActivityResumed(fixture.activity)
156-
val sentryCallback = fixture.window.callback
157-
assertIs<SentryWindowCallback>(sentryCallback)
157+
val originalSentryCallback = fixture.window.callback
158+
assertIs<SentryWindowCallback>(originalSentryCallback)
158159

159-
val outerWrapper = WrapperCallback(sentryCallback)
160+
// Third-party wraps on top of us mid-activity.
161+
val outerWrapper = WrapperCallback(originalSentryCallback)
160162
fixture.window.callback = outerWrapper
161163

162164
sut.onActivityPaused(fixture.activity)
163165
sut.onActivityResumed(fixture.activity)
164166

165-
assertSame(outerWrapper, fixture.window.callback)
167+
val newTop = fixture.window.callback
168+
assertIs<SentryWindowCallback>(newTop)
169+
assertNotSame(originalSentryCallback, newTop)
170+
assertSame(outerWrapper, newTop.delegate)
166171
}
167172

168173
@Test

sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,18 @@ class SentryWindowCallbackTest {
8282

8383
verify(fixture.gestureDetector, never()).onTouchEvent(any())
8484
}
85+
86+
@Test
87+
fun `after stopTracking does not forward touches to detector or listener`() {
88+
val event = mock<MotionEvent> { whenever(it.actionMasked).thenReturn(MotionEvent.ACTION_UP) }
89+
val sut = fixture.getSut()
90+
91+
sut.stopTracking()
92+
sut.dispatchTouchEvent(event)
93+
94+
verify(fixture.gestureDetector, never()).onTouchEvent(any())
95+
verify(fixture.gestureListener, never()).onUp(any())
96+
// super.dispatchTouchEvent still delegates to the wrapped delegate so the chain keeps working.
97+
verify(fixture.delegate).dispatchTouchEvent(event)
98+
}
8599
}

0 commit comments

Comments
 (0)