Skip to content
Open
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Unreleased

### Features

- Show feedback form on device shake ([#5150](https://github.com/getsentry/sentry-java/pull/5150))
- Enable via `options.getFeedbackOptions().setUseShakeGesture(true)`
- Uses the device's accelerometer โ€” no special permissions required

## 8.34.0

### Features
Expand Down
25 changes: 25 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,18 @@ public final class io/sentry/android/core/SentryScreenshotOptions : io/sentry/Se
public fun trackCustomMasking ()V
}

public final class io/sentry/android/core/SentryShakeDetector : android/hardware/SensorEventListener {
public fun <init> (Lio/sentry/ILogger;)V
public fun onAccuracyChanged (Landroid/hardware/Sensor;I)V
public fun onSensorChanged (Landroid/hardware/SensorEvent;)V
public fun start (Landroid/content/Context;Lio/sentry/android/core/SentryShakeDetector$Listener;)V
public fun stop ()V
}

public abstract interface class io/sentry/android/core/SentryShakeDetector$Listener {
public abstract fun onShake ()V
}

public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Button {
public fun <init> (Landroid/content/Context;)V
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;)V
Expand Down Expand Up @@ -485,6 +497,19 @@ public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$
public abstract fun configure (Landroid/content/Context;Lio/sentry/SentryFeedbackOptions;)V
}

public final class io/sentry/android/core/ShakeDetectionIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable {
public fun <init> (Landroid/app/Application;)V
public fun close ()V
public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V
public fun onActivityDestroyed (Landroid/app/Activity;)V
public fun onActivityPaused (Landroid/app/Activity;)V
public fun onActivityResumed (Landroid/app/Activity;)V
public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V
public fun onActivityStarted (Landroid/app/Activity;)V
public fun onActivityStopped (Landroid/app/Activity;)V
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
}

public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerformanceContinuousCollector, io/sentry/android/core/internal/util/SentryFrameMetricsCollector$FrameMetricsCollectorListener {
protected final field lock Lio/sentry/util/AutoClosableReentrantLock;
public fun <init> (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ static void installDefaultIntegrations(
(Application) context, buildInfoProvider, activityFramesTracker));
options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context));
options.addIntegration(new UserInteractionIntegration((Application) context, loadClass));
options.addIntegration(new ShakeDetectionIntegration((Application) context));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: is it possible to only add when useShakeGesture is set to true?

if (isFragmentAvailable) {
options.addIntegration(new FragmentLifecycleIntegration((Application) context, true, true));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package io.sentry.android.core;

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.SystemClock;
import io.sentry.ILogger;
import io.sentry.SentryLevel;
import java.util.concurrent.atomic.AtomicLong;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Detects shake gestures using the device's accelerometer.
*
* <p>The accelerometer sensor (TYPE_ACCELEROMETER) does NOT require any special permissions on
* Android. The BODY_SENSORS permission is only needed for heart rate and similar body sensors.
*
* <p>Requires at least {@link #SHAKE_COUNT_THRESHOLD} accelerometer readings above {@link
* #SHAKE_THRESHOLD_GRAVITY} within {@link #SHAKE_WINDOW_MS} to trigger a shake event.
*/
@ApiStatus.Internal
public final class SentryShakeDetector implements SensorEventListener {

private static final float SHAKE_THRESHOLD_GRAVITY = 2.7f;
private static final int SHAKE_WINDOW_MS = 1500;
private static final int SHAKE_COUNT_THRESHOLD = 2;
private static final int SHAKE_COOLDOWN_MS = 1000;

private @Nullable SensorManager sensorManager;
private final @NotNull AtomicLong lastShakeTimestamp = new AtomicLong(0);
private volatile @Nullable Listener listener;
private final @NotNull ILogger logger;

private int shakeCount = 0;
private long firstShakeTimestamp = 0;

public interface Listener {
void onShake();
}

public SentryShakeDetector(final @NotNull ILogger logger) {
this.logger = logger;
}

public void start(final @NotNull Context context, final @NotNull Listener shakeListener) {
this.listener = shakeListener;
sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
if (sensorManager == null) {
logger.log(SentryLevel.WARNING, "SensorManager is not available. Shake detection disabled.");
return;
}
Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
if (accelerometer == null) {
logger.log(
SentryLevel.WARNING, "Accelerometer sensor not available. Shake detection disabled.");
return;
}
sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL);
}

public void stop() {
if (sensorManager != null) {
sensorManager.unregisterListener(this);
sensorManager = null;
}
listener = null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: why not set listener null first?

}

@Override
public void onSensorChanged(final @NotNull SensorEvent event) {
if (event.sensor.getType() != Sensor.TYPE_ACCELEROMETER) {
return;
}
float gX = event.values[0] / SensorManager.GRAVITY_EARTH;
float gY = event.values[1] / SensorManager.GRAVITY_EARTH;
float gZ = event.values[2] / SensorManager.GRAVITY_EARTH;
double gForce = Math.sqrt(gX * gX + gY * gY + gZ * gZ);
if (gForce > SHAKE_THRESHOLD_GRAVITY) {
Comment on lines +81 to +82
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about

Suggested change
double gForce = Math.sqrt(gX * gX + gY * gY + gZ * gZ);
if (gForce > SHAKE_THRESHOLD_GRAVITY) {
double gForceSquared = gX * gX + gY * gY + gZ * gZ;
if (gForceSquared > SHAKE_THRESHOLD_GRAVITY * SHAKE_THRESHOLD_GRAVITY) {

long now = SystemClock.elapsedRealtime();

// Reset counter if outside the detection window
if (now - firstShakeTimestamp > SHAKE_WINDOW_MS) {
shakeCount = 0;
firstShakeTimestamp = now;
}

shakeCount++;

if (shakeCount >= SHAKE_COUNT_THRESHOLD) {
// Enforce cooldown so we don't fire repeatedly
long lastShake = lastShakeTimestamp.get();
if (now - lastShake > SHAKE_COOLDOWN_MS) {
lastShakeTimestamp.set(now);
shakeCount = 0;
final @Nullable Listener currentListener = listener;
if (currentListener != null) {
currentListener.onShake();
}
}
}
}
}

@Override
public void onAccuracyChanged(final @NotNull Sensor sensor, final int accuracy) {
// Not needed for shake detection.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package io.sentry.android.core;

import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion;

import android.app.Activity;
import android.app.Application;
import android.os.Bundle;
import io.sentry.IScopes;
import io.sentry.Integration;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.util.Objects;
import java.io.Closeable;
import java.io.IOException;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Detects shake gestures and shows the user feedback dialog when a shake is detected. Only active
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know the end goal is to add this to improve user feedback, but the way it is, it's doing nothing related to user feedback.
Might be a good idea to rename this to FeedbackShakeIntegration so the name of it self explains the usage of it, instead of leaving it generic, what do you think?

* when {@link io.sentry.SentryFeedbackOptions#isUseShakeGesture()} returns {@code true}.
*/
public final class ShakeDetectionIntegration
implements Integration, Closeable, Application.ActivityLifecycleCallbacks {

private final @NotNull Application application;
private @Nullable SentryShakeDetector shakeDetector;
private @Nullable SentryAndroidOptions options;
private volatile @Nullable Activity currentActivity;
private volatile boolean isDialogShowing = false;
private volatile @Nullable Activity dialogActivity;
private @Nullable Runnable originalOnFormClose;

public ShakeDetectionIntegration(final @NotNull Application application) {
this.application = Objects.requireNonNull(application, "Application is required");
}

@Override
public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions sentryOptions) {
this.options = (SentryAndroidOptions) sentryOptions;

if (!this.options.getFeedbackOptions().isUseShakeGesture()) {
return;
}

addIntegrationToSdkVersion("ShakeDetection");
originalOnFormClose = this.options.getFeedbackOptions().getOnFormClose();
application.registerActivityLifecycleCallbacks(this);
options.getLogger().log(SentryLevel.DEBUG, "ShakeDetectionIntegration installed.");

// In case of a deferred init, hook into any already-resumed activity
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
if (activity != null) {
currentActivity = activity;
startShakeDetection(activity);
}
}

@Override
public void close() throws IOException {
application.unregisterActivityLifecycleCallbacks(this);
stopShakeDetection();
}

@Override
public void onActivityResumed(final @NotNull Activity activity) {
currentActivity = activity;
startShakeDetection(activity);
}

@Override
public void onActivityPaused(final @NotNull Activity activity) {
// Only stop if this is the activity we're tracking. When transitioning between
// activities, B.onResume may fire before A.onPause โ€” stopping unconditionally
// would kill shake detection for the new activity.
if (activity == currentActivity) {
stopShakeDetection();
currentActivity = null;
}
}

@Override
public void onActivityCreated(
final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {}

@Override
public void onActivityStarted(final @NotNull Activity activity) {}

@Override
public void onActivityStopped(final @NotNull Activity activity) {}

@Override
public void onActivitySaveInstanceState(
final @NotNull Activity activity, final @NotNull Bundle outState) {}

@Override
public void onActivityDestroyed(final @NotNull Activity activity) {
// Only reset if this is the activity that hosts the dialog โ€” the dialog cannot
// outlive its host activity being destroyed.
if (activity == dialogActivity) {
isDialogShowing = false;
dialogActivity = null;
}
}

private void startShakeDetection(final @NotNull Activity activity) {
if (options == null) {
return;
}
// Stop any existing detector (e.g. when transitioning between activities)
stopShakeDetection();
shakeDetector = new SentryShakeDetector(options.getLogger());
shakeDetector.start(
activity,
() -> {
final Activity active = currentActivity;
if (active != null && options != null && !isDialogShowing) {
active.runOnUiThread(
() -> {
if (isDialogShowing) {
return;
}
try {
isDialogShowing = true;
dialogActivity = active;
options
.getFeedbackOptions()
.setOnFormClose(
() -> {
isDialogShowing = false;
dialogActivity = null;
options.getFeedbackOptions().setOnFormClose(originalOnFormClose);
if (originalOnFormClose != null) {
originalOnFormClose.run();
}
});
options.getFeedbackOptions().getDialogHandler().showDialog(null, null);
} catch (Throwable e) {
isDialogShowing = false;
dialogActivity = null;
options.getFeedbackOptions().setOnFormClose(originalOnFormClose);
options
.getLogger()
.log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e);
}
});
}
});
}

private void stopShakeDetection() {
if (shakeDetector != null) {
shakeDetector.stop();
shakeDetector = null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ class SentryAndroidTest {
fixture.initSut(context = mock<Application>()) { options ->
optionsRef = options
options.dsn = "https://key@sentry.io/123"
assertEquals(18, options.integrations.size)
assertEquals(19, options.integrations.size)
options.integrations.removeAll {
it is UncaughtExceptionHandlerIntegration ||
it is ShutdownHookIntegration ||
Expand All @@ -488,6 +488,7 @@ class SentryAndroidTest {
it is ActivityLifecycleIntegration ||
it is ActivityBreadcrumbsIntegration ||
it is UserInteractionIntegration ||
it is ShakeDetectionIntegration ||
it is FragmentLifecycleIntegration ||
it is SentryTimberIntegration ||
it is AppComponentsBreadcrumbsIntegration ||
Expand Down
Loading
Loading