Skip to content

Commit 85a0af4

Browse files
authored
Merge e9db3cb into ce4b2c1
2 parents ce4b2c1 + e9db3cb commit 85a0af4

8 files changed

Lines changed: 424 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Fixes
6+
7+
- Fix ANR caused by `GestureDetectorCompat` Handler/MessageQueue lock contention in `SentryWindowCallback` ([#5138](https://github.com/getsentry/sentry-java/pull/5138))
8+
59
### Internal
610

711
- Bump AGP version from v8.6.0 to v8.13.1 ([#5063](https://github.com/getsentry/sentry-java/pull/5063))

sentry-android-core/proguard-rules.pro

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
##---------------Begin: proguard configuration for android-core ----------
22

33
##---------------Begin: proguard configuration for androidx.core ----------
4-
-keep class androidx.core.view.GestureDetectorCompat { <init>(...); }
54
-keep class androidx.core.app.FrameMetricsAggregator { <init>(...); }
65
-keep interface androidx.core.view.ScrollingView { *; }
76
##---------------End: proguard configuration for androidx.core ----------

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

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,11 @@ public final class UserInteractionIntegration
2828
private @Nullable IScopes scopes;
2929
private @Nullable SentryAndroidOptions options;
3030

31-
private final boolean isAndroidXAvailable;
3231
private final boolean isAndroidxLifecycleAvailable;
3332

3433
public UserInteractionIntegration(
3534
final @NotNull Application application, final @NotNull io.sentry.util.LoadClass classLoader) {
3635
this.application = Objects.requireNonNull(application, "Application is required");
37-
isAndroidXAvailable =
38-
classLoader.isClassAvailable("androidx.core.view.GestureDetectorCompat", options);
3936
isAndroidxLifecycleAvailable =
4037
classLoader.isClassAvailable("androidx.lifecycle.Lifecycle", options);
4138
}
@@ -128,27 +125,19 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) {
128125
.log(SentryLevel.DEBUG, "UserInteractionIntegration enabled: %s", integrationEnabled);
129126

130127
if (integrationEnabled) {
131-
if (isAndroidXAvailable) {
132-
application.registerActivityLifecycleCallbacks(this);
133-
this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed.");
134-
addIntegrationToSdkVersion("UserInteraction");
135-
136-
// In case of a deferred init, we hook into any resumed activity
137-
if (isAndroidxLifecycleAvailable) {
138-
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
139-
if (activity instanceof LifecycleOwner) {
140-
if (((LifecycleOwner) activity).getLifecycle().getCurrentState()
141-
== Lifecycle.State.RESUMED) {
142-
startTracking(activity);
143-
}
128+
application.registerActivityLifecycleCallbacks(this);
129+
this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed.");
130+
addIntegrationToSdkVersion("UserInteraction");
131+
132+
// In case of a deferred init, we hook into any resumed activity
133+
if (isAndroidxLifecycleAvailable) {
134+
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
135+
if (activity instanceof LifecycleOwner) {
136+
if (((LifecycleOwner) activity).getLifecycle().getCurrentState()
137+
== Lifecycle.State.RESUMED) {
138+
startTracking(activity);
144139
}
145140
}
146-
} else {
147-
options
148-
.getLogger()
149-
.log(
150-
SentryLevel.INFO,
151-
"androidx.core is not available, UserInteractionIntegration won't be installed");
152141
}
153142
}
154143
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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 {@code GestureDetectorCompat}/{@link
14+
* GestureDetector} to avoid ANRs caused by Handler/MessageQueue lock contention and IPC calls
15+
* (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+
void onTouchEvent(final @NotNull MotionEvent event) {
48+
final int action = event.getActionMasked();
49+
50+
if (velocityTracker == null) {
51+
velocityTracker = VelocityTracker.obtain();
52+
}
53+
54+
if (action == MotionEvent.ACTION_DOWN) {
55+
velocityTracker.clear();
56+
}
57+
velocityTracker.addMovement(event);
58+
59+
switch (action) {
60+
case MotionEvent.ACTION_DOWN:
61+
downX = event.getX();
62+
downY = event.getY();
63+
lastX = downX;
64+
lastY = downY;
65+
isInTapRegion = true;
66+
67+
if (currentDownEvent != null) {
68+
currentDownEvent.recycle();
69+
}
70+
currentDownEvent = MotionEvent.obtain(event);
71+
72+
listener.onDown(event);
73+
break;
74+
75+
case MotionEvent.ACTION_MOVE:
76+
{
77+
final float x = event.getX();
78+
final float y = event.getY();
79+
final float dx = x - downX;
80+
final float dy = y - downY;
81+
final float distanceSquare = (dx * dx) + (dy * dy);
82+
83+
if (distanceSquare > touchSlopSquare) {
84+
final float scrollX = lastX - x;
85+
final float scrollY = lastY - y;
86+
listener.onScroll(currentDownEvent, event, scrollX, scrollY);
87+
isInTapRegion = false;
88+
lastX = x;
89+
lastY = y;
90+
}
91+
break;
92+
}
93+
94+
case MotionEvent.ACTION_UP:
95+
if (isInTapRegion) {
96+
listener.onSingleTapUp(event);
97+
} else {
98+
final int pointerId = event.getPointerId(0);
99+
velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity);
100+
final float velocityX = velocityTracker.getXVelocity(pointerId);
101+
final float velocityY = velocityTracker.getYVelocity(pointerId);
102+
103+
if (Math.abs(velocityX) > minimumFlingVelocity
104+
|| Math.abs(velocityY) > minimumFlingVelocity) {
105+
listener.onFling(currentDownEvent, event, velocityX, velocityY);
106+
}
107+
}
108+
endGesture();
109+
break;
110+
111+
case MotionEvent.ACTION_CANCEL:
112+
endGesture();
113+
break;
114+
}
115+
}
116+
117+
/** Releases native resources. Call when the detector is no longer needed. */
118+
void release() {
119+
endGesture();
120+
if (velocityTracker != null) {
121+
velocityTracker.recycle();
122+
velocityTracker = null;
123+
}
124+
}
125+
126+
private void endGesture() {
127+
if (currentDownEvent != null) {
128+
currentDownEvent.recycle();
129+
currentDownEvent = null;
130+
}
131+
}
132+
}

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

Lines changed: 4 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) {
@@ -76,6 +73,7 @@ private void handleTouchEvent(final @NotNull MotionEvent motionEvent) {
7673

7774
public void stopTracking() {
7875
gestureListener.stopTracing(SpanStatus.CANCELLED);
76+
gestureDetector.release();
7977
}
8078

8179
public @NotNull Window.Callback getDelegate() {

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

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,8 @@ class UserInteractionIntegrationTest {
3939

4040
fun getSut(
4141
callback: Window.Callback? = null,
42-
isAndroidXAvailable: Boolean = true,
4342
isLifecycleAvailable: Boolean = true,
4443
): UserInteractionIntegration {
45-
whenever(
46-
loadClass.isClassAvailable(
47-
eq("androidx.core.view.GestureDetectorCompat"),
48-
anyOrNull<SentryAndroidOptions>(),
49-
)
50-
)
51-
.thenReturn(isAndroidXAvailable)
5244
whenever(
5345
loadClass.isClassAvailable(
5446
eq("androidx.lifecycle.Lifecycle"),
@@ -99,15 +91,6 @@ class UserInteractionIntegrationTest {
9991
verify(fixture.application).unregisterActivityLifecycleCallbacks(any())
10092
}
10193

102-
@Test
103-
fun `when androidx is unavailable doesn't register a callback`() {
104-
val sut = fixture.getSut(isAndroidXAvailable = false)
105-
106-
sut.register(fixture.scopes, fixture.options)
107-
108-
verify(fixture.application, never()).registerActivityLifecycleCallbacks(any())
109-
}
110-
11194
@Test
11295
fun `registers window callback on activity resumed`() {
11396
val sut = fixture.getSut()

0 commit comments

Comments
 (0)