Skip to content

Commit 33540c8

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 33540c8

File tree

4 files changed

+41
-8
lines changed

4 files changed

+41
-8
lines changed

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,13 @@ 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. stopTracking() flips the
114+
// wrapper into inert mode so it stops emitting breadcrumbs; it gets GC'd when the window
115+
// is destroyed. We also drop it from the map so the next startTracking() can install a
116+
// fresh active wrapper on top.
115117
final @Nullable SentryWindowCallback ours;
116118
synchronized (wrappedWindowsLock) {
117-
final @Nullable WeakReference<SentryWindowCallback> cached = wrappedWindows.get(window);
119+
final @Nullable WeakReference<SentryWindowCallback> cached = wrappedWindows.remove(window);
118120
ours = cached != null ? cached.get() : null;
119121
}
120122
if (ours != null) {

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

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

22+
// Set to true once stopTracking() has been called. When the wrapper is buried under another
23+
// Window.Callback we can't remove ourselves from the chain, so we flip this flag to make
24+
// handleTouchEvent a no-op and stop emitting gesture breadcrumbs.
25+
private volatile boolean inert;
26+
2227
public SentryWindowCallback(
2328
final @NotNull Window.Callback delegate,
2429
final @NotNull Context context,
@@ -64,6 +69,9 @@ public boolean dispatchTouchEvent(final @Nullable MotionEvent motionEvent) {
6469
}
6570

6671
private void handleTouchEvent(final @NotNull MotionEvent motionEvent) {
72+
if (inert) {
73+
return;
74+
}
6775
gestureDetector.onTouchEvent(motionEvent);
6876
int action = motionEvent.getActionMasked();
6977
if (action == MotionEvent.ACTION_UP) {
@@ -72,6 +80,7 @@ private void handleTouchEvent(final @NotNull MotionEvent motionEvent) {
7280
}
7381

7482
public void stopTracking() {
83+
inert = true;
7584
gestureListener.stopTracing(SpanStatus.CANCELLED);
7685
gestureDetector.release();
7786
}

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

Lines changed: 13 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,28 @@ 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+
// Buried path must drop its map entry so the resume can install a fresh wrapper on top;
168+
// otherwise the stale cache entry makes startTracking() return early and we permanently
169+
// lose gesture tracking for this window.
170+
val newTop = fixture.window.callback
171+
assertIs<SentryWindowCallback>(newTop)
172+
assertNotSame(originalSentryCallback, newTop)
173+
assertSame(outerWrapper, newTop.delegate)
166174
}
167175

168176
@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)