Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

### Fixes

- Fix ANR caused by `GestureDetectorCompat` Handler/MessageQueue lock contention in `SentryWindowCallback` ([#5138](https://github.com/getsentry/sentry-java/pull/5138))
- Fix crash when unregistering `SystemEventsBroadcastReceiver` with try-catch block. ([#5106](https://github.com/getsentry/sentry-java/pull/5106))

### Dependencies
Expand Down
1 change: 0 additions & 1 deletion sentry-android-core/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
##---------------Begin: proguard configuration for android-core ----------

##---------------Begin: proguard configuration for androidx.core ----------
-keep class androidx.core.view.GestureDetectorCompat { <init>(...); }
-keep class androidx.core.app.FrameMetricsAggregator { <init>(...); }
-keep interface androidx.core.view.ScrollingView { *; }
##---------------End: proguard configuration for androidx.core ----------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,11 @@ public final class UserInteractionIntegration
private @Nullable IScopes scopes;
private @Nullable SentryAndroidOptions options;

private final boolean isAndroidXAvailable;
private final boolean isAndroidxLifecycleAvailable;

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

if (integrationEnabled) {
if (isAndroidXAvailable) {
application.registerActivityLifecycleCallbacks(this);
this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed.");
addIntegrationToSdkVersion("UserInteraction");

// In case of a deferred init, we hook into any resumed activity
if (isAndroidxLifecycleAvailable) {
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
if (activity instanceof LifecycleOwner) {
if (((LifecycleOwner) activity).getLifecycle().getCurrentState()
== Lifecycle.State.RESUMED) {
startTracking(activity);
}
application.registerActivityLifecycleCallbacks(this);
this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed.");
addIntegrationToSdkVersion("UserInteraction");

// In case of a deferred init, we hook into any resumed activity
if (isAndroidxLifecycleAvailable) {
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
if (activity instanceof LifecycleOwner) {
if (((LifecycleOwner) activity).getLifecycle().getCurrentState()
== Lifecycle.State.RESUMED) {
startTracking(activity);
}
}
} else {
options
.getLogger()
.log(
SentryLevel.INFO,
"androidx.core is not available, UserInteractionIntegration won't be installed");
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package io.sentry.android.core.internal.gestures;

import android.content.Context;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.ViewConfiguration;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* A lightweight gesture detector that replaces {@link
* androidx.core.view.GestureDetectorCompat}/{@link GestureDetector} to avoid ANRs caused by
* Handler/MessageQueue lock contention and IPC calls (FrameworkStatsLog.write).
*
* <p>Only detects click (tap), scroll, and fling — the gestures used by {@link
* SentryGestureListener}. Long-press, show-press, and double-tap detection (which require Handler
* message scheduling) are intentionally omitted.
*/
@ApiStatus.Internal
public final class SentryGestureDetector {

private final @NotNull GestureDetector.OnGestureListener listener;
private final int touchSlopSquare;
private final int minimumFlingVelocity;
private final int maximumFlingVelocity;

private boolean isInTapRegion;
private float downX;
private float downY;
private float lastX;
private float lastY;
private @Nullable MotionEvent currentDownEvent;
private @Nullable VelocityTracker velocityTracker;

SentryGestureDetector(
final @NotNull Context context, final @NotNull GestureDetector.OnGestureListener listener) {
this.listener = listener;
final ViewConfiguration config = ViewConfiguration.get(context);
final int touchSlop = config.getScaledTouchSlop();
this.touchSlopSquare = touchSlop * touchSlop;
this.minimumFlingVelocity = config.getScaledMinimumFlingVelocity();
this.maximumFlingVelocity = config.getScaledMaximumFlingVelocity();
}

boolean onTouchEvent(final @NotNull MotionEvent event) {
final int action = event.getActionMasked();

if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain();
}

if (action == MotionEvent.ACTION_DOWN) {
velocityTracker.clear();
}
velocityTracker.addMovement(event);

switch (action) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
downY = event.getY();
lastX = downX;
lastY = downY;
isInTapRegion = true;

if (currentDownEvent != null) {
currentDownEvent.recycle();
}
currentDownEvent = MotionEvent.obtain(event);

listener.onDown(event);
break;

case MotionEvent.ACTION_MOVE:
{
final float x = event.getX();
final float y = event.getY();
final float dx = x - downX;
final float dy = y - downY;
final float distanceSquare = (dx * dx) + (dy * dy);

if (distanceSquare > touchSlopSquare) {
final float scrollX = lastX - x;
final float scrollY = lastY - y;
listener.onScroll(currentDownEvent, event, scrollX, scrollY);
isInTapRegion = false;
lastX = x;
lastY = y;
}
break;
}

case MotionEvent.ACTION_UP:
if (isInTapRegion) {
listener.onSingleTapUp(event);
} else if (velocityTracker != null) {
final int pointerId = event.getPointerId(0);
velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity);
final float velocityX = velocityTracker.getXVelocity(pointerId);
final float velocityY = velocityTracker.getYVelocity(pointerId);

if (Math.abs(velocityX) > minimumFlingVelocity
|| Math.abs(velocityY) > minimumFlingVelocity) {
listener.onFling(currentDownEvent, event, velocityX, velocityY);
}
}
cleanup();
break;

case MotionEvent.ACTION_CANCEL:
cleanup();
break;
}

return false;
}

private void cleanup() {
if (velocityTracker != null) {
velocityTracker.recycle();
velocityTracker = null;
}
if (currentDownEvent != null) {
currentDownEvent.recycle();
currentDownEvent = null;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
package io.sentry.android.core.internal.gestures;

import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.view.MotionEvent;
import android.view.Window;
import androidx.core.view.GestureDetectorCompat;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.SpanStatus;
Expand All @@ -18,7 +15,7 @@ public final class SentryWindowCallback extends WindowCallbackAdapter {

private final @NotNull Window.Callback delegate;
private final @NotNull SentryGestureListener gestureListener;
private final @NotNull GestureDetectorCompat gestureDetector;
private final @NotNull SentryGestureDetector gestureDetector;
private final @Nullable SentryOptions options;
private final @NotNull MotionEventObtainer motionEventObtainer;

Expand All @@ -29,15 +26,15 @@ public SentryWindowCallback(
final @Nullable SentryOptions options) {
this(
delegate,
new GestureDetectorCompat(context, gestureListener, new Handler(Looper.getMainLooper())),
new SentryGestureDetector(context, gestureListener),
gestureListener,
options,
new MotionEventObtainer() {});
}

SentryWindowCallback(
final @NotNull Window.Callback delegate,
final @NotNull GestureDetectorCompat gestureDetector,
final @NotNull SentryGestureDetector gestureDetector,
final @NotNull SentryGestureListener gestureListener,
final @Nullable SentryOptions options,
final @NotNull MotionEventObtainer motionEventObtainer) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,8 @@ class UserInteractionIntegrationTest {

fun getSut(
callback: Window.Callback? = null,
isAndroidXAvailable: Boolean = true,
isLifecycleAvailable: Boolean = true,
): UserInteractionIntegration {
whenever(
loadClass.isClassAvailable(
eq("androidx.core.view.GestureDetectorCompat"),
anyOrNull<SentryAndroidOptions>(),
)
)
.thenReturn(isAndroidXAvailable)
whenever(
loadClass.isClassAvailable(
eq("androidx.lifecycle.Lifecycle"),
Expand Down Expand Up @@ -99,15 +91,6 @@ class UserInteractionIntegrationTest {
verify(fixture.application).unregisterActivityLifecycleCallbacks(any())
}

@Test
fun `when androidx is unavailable doesn't register a callback`() {
val sut = fixture.getSut(isAndroidXAvailable = false)

sut.register(fixture.scopes, fixture.options)

verify(fixture.application, never()).registerActivityLifecycleCallbacks(any())
}

@Test
fun `registers window callback on activity resumed`() {
val sut = fixture.getSut()
Expand Down
Loading
Loading