Skip to content

Commit 41beaed

Browse files
romtsnclaude
andcommitted
fix(android): Replace GestureDetectorCompat with lightweight SentryGestureDetector to fix ANR
GestureDetectorCompat internally uses Handler.sendMessage/removeMessages which acquires a synchronized lock on the main thread MessageQueue, plus recordGestureClassification triggers IPC calls. This caused ANRs under load (SDK-CRASHES-JAVA-596, 175K+ occurrences). Replace with a minimal custom detector that only detects click, scroll, and fling without any Handler scheduling, MessageQueue contention, or IPC overhead. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent fc52bb8 commit 41beaed

File tree

3 files changed

+129
-8
lines changed

3 files changed

+129
-8
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package io.sentry.android.core.internal.gestures;
2+
3+
import android.content.Context;
4+
import android.view.GestureDetector;
5+
import android.view.MotionEvent;
6+
import android.view.VelocityTracker;
7+
import android.view.ViewConfiguration;
8+
import org.jetbrains.annotations.ApiStatus;
9+
import org.jetbrains.annotations.NotNull;
10+
import org.jetbrains.annotations.Nullable;
11+
12+
/**
13+
* A lightweight gesture detector that replaces {@link
14+
* androidx.core.view.GestureDetectorCompat}/{@link GestureDetector} to avoid ANRs caused by
15+
* Handler/MessageQueue lock contention and IPC calls (FrameworkStatsLog.write).
16+
*
17+
* <p>Only detects click (tap), scroll, and fling — the gestures used by {@link
18+
* SentryGestureListener}. Long-press, show-press, and double-tap detection (which require Handler
19+
* message scheduling) are intentionally omitted.
20+
*/
21+
@ApiStatus.Internal
22+
public final class SentryGestureDetector {
23+
24+
private final @NotNull GestureDetector.OnGestureListener listener;
25+
private final int touchSlopSquare;
26+
private final int minimumFlingVelocity;
27+
private final int maximumFlingVelocity;
28+
29+
private boolean isInTapRegion;
30+
private float downX;
31+
private float downY;
32+
private float lastX;
33+
private float lastY;
34+
private @Nullable MotionEvent currentDownEvent;
35+
private @Nullable VelocityTracker velocityTracker;
36+
37+
SentryGestureDetector(
38+
final @NotNull Context context, final @NotNull GestureDetector.OnGestureListener listener) {
39+
this.listener = listener;
40+
final ViewConfiguration config = ViewConfiguration.get(context);
41+
final int touchSlop = config.getScaledTouchSlop();
42+
this.touchSlopSquare = touchSlop * touchSlop;
43+
this.minimumFlingVelocity = config.getScaledMinimumFlingVelocity();
44+
this.maximumFlingVelocity = config.getScaledMaximumFlingVelocity();
45+
}
46+
47+
boolean onTouchEvent(final @NotNull MotionEvent event) {
48+
final int action = event.getActionMasked();
49+
50+
if (velocityTracker == null) {
51+
velocityTracker = VelocityTracker.obtain();
52+
}
53+
velocityTracker.addMovement(event);
54+
55+
switch (action) {
56+
case MotionEvent.ACTION_DOWN:
57+
downX = event.getX();
58+
downY = event.getY();
59+
lastX = downX;
60+
lastY = downY;
61+
isInTapRegion = true;
62+
63+
if (currentDownEvent != null) {
64+
currentDownEvent.recycle();
65+
}
66+
currentDownEvent = MotionEvent.obtain(event);
67+
68+
listener.onDown(event);
69+
break;
70+
71+
case MotionEvent.ACTION_MOVE:
72+
{
73+
final float x = event.getX();
74+
final float y = event.getY();
75+
final float dx = x - downX;
76+
final float dy = y - downY;
77+
final float distanceSquare = (dx * dx) + (dy * dy);
78+
79+
if (distanceSquare > touchSlopSquare) {
80+
final float scrollX = lastX - x;
81+
final float scrollY = lastY - y;
82+
listener.onScroll(currentDownEvent, event, scrollX, scrollY);
83+
isInTapRegion = false;
84+
lastX = x;
85+
lastY = y;
86+
}
87+
break;
88+
}
89+
90+
case MotionEvent.ACTION_UP:
91+
if (isInTapRegion) {
92+
listener.onSingleTapUp(event);
93+
} else if (velocityTracker != null) {
94+
final int pointerId = event.getPointerId(0);
95+
velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity);
96+
final float velocityX = velocityTracker.getXVelocity(pointerId);
97+
final float velocityY = velocityTracker.getYVelocity(pointerId);
98+
99+
if (Math.abs(velocityX) > minimumFlingVelocity
100+
|| Math.abs(velocityY) > minimumFlingVelocity) {
101+
listener.onFling(currentDownEvent, event, velocityX, velocityY);
102+
}
103+
}
104+
cleanup();
105+
break;
106+
107+
case MotionEvent.ACTION_CANCEL:
108+
cleanup();
109+
break;
110+
}
111+
112+
return false;
113+
}
114+
115+
private void cleanup() {
116+
if (velocityTracker != null) {
117+
velocityTracker.recycle();
118+
velocityTracker = null;
119+
}
120+
if (currentDownEvent != null) {
121+
currentDownEvent.recycle();
122+
currentDownEvent = null;
123+
}
124+
}
125+
}

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
package io.sentry.android.core.internal.gestures;
22

33
import android.content.Context;
4-
import android.os.Handler;
5-
import android.os.Looper;
64
import android.view.MotionEvent;
75
import android.view.Window;
8-
import androidx.core.view.GestureDetectorCompat;
96
import io.sentry.SentryLevel;
107
import io.sentry.SentryOptions;
118
import io.sentry.SpanStatus;
@@ -18,7 +15,7 @@ public final class SentryWindowCallback extends WindowCallbackAdapter {
1815

1916
private final @NotNull Window.Callback delegate;
2017
private final @NotNull SentryGestureListener gestureListener;
21-
private final @NotNull GestureDetectorCompat gestureDetector;
18+
private final @NotNull SentryGestureDetector gestureDetector;
2219
private final @Nullable SentryOptions options;
2320
private final @NotNull MotionEventObtainer motionEventObtainer;
2421

@@ -29,15 +26,15 @@ public SentryWindowCallback(
2926
final @Nullable SentryOptions options) {
3027
this(
3128
delegate,
32-
new GestureDetectorCompat(context, gestureListener, new Handler(Looper.getMainLooper())),
29+
new SentryGestureDetector(context, gestureListener),
3330
gestureListener,
3431
options,
3532
new MotionEventObtainer() {});
3633
}
3734

3835
SentryWindowCallback(
3936
final @NotNull Window.Callback delegate,
40-
final @NotNull GestureDetectorCompat gestureDetector,
37+
final @NotNull SentryGestureDetector gestureDetector,
4138
final @NotNull SentryGestureListener gestureListener,
4239
final @Nullable SentryOptions options,
4340
final @NotNull MotionEventObtainer motionEventObtainer) {

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package io.sentry.android.core.internal.gestures
22

33
import android.view.MotionEvent
44
import android.view.Window
5-
import androidx.core.view.GestureDetectorCompat
65
import io.sentry.android.core.SentryAndroidOptions
76
import io.sentry.android.core.internal.gestures.SentryWindowCallback.MotionEventObtainer
87
import kotlin.test.Test
@@ -18,7 +17,7 @@ class SentryWindowCallbackTest {
1817
class Fixture {
1918
val delegate = mock<Window.Callback>()
2019
val options = SentryAndroidOptions().apply { dsn = "https://[email protected]/proj" }
21-
val gestureDetector = mock<GestureDetectorCompat>()
20+
val gestureDetector = mock<SentryGestureDetector>()
2221
val gestureListener = mock<SentryGestureListener>()
2322
val motionEventCopy = mock<MotionEvent>()
2423

0 commit comments

Comments
 (0)