-
-
Notifications
You must be signed in to change notification settings - Fork 466
feat(feedback): implement shake gesture detection #5150
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c04bebe
177bb48
d13da1b
0cc2f3b
83eed6d
dbbd37e
527abb7
0b090bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what about
Suggested change
|
||||||||||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| * 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; | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| @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; | ||
| } | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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(); | ||
| } | ||
| }); | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| options.getFeedbackOptions().getDialogHandler().showDialog(null, null); | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } catch (Throwable e) { | ||
| isDialogShowing = false; | ||
| dialogActivity = null; | ||
| options.getFeedbackOptions().setOnFormClose(originalOnFormClose); | ||
| options | ||
| .getLogger() | ||
| .log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e); | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }); | ||
| } | ||
| }); | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| private void stopShakeDetection() { | ||
| if (shakeDetector != null) { | ||
| shakeDetector.stop(); | ||
| shakeDetector = null; | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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
useShakeGestureis set to true?