Skip to content

Commit c04bebe

Browse files
antonisclaude
andcommitted
feat(feedback): implement shake gesture detection for user feedback form
Adds SentryShakeDetector (accelerometer-based) and ShakeDetectionIntegration that shows the feedback dialog when a shake is detected. Controlled by SentryFeedbackOptions.useShakeGesture (default false). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b8bd880 commit c04bebe

File tree

5 files changed

+216
-1
lines changed

5 files changed

+216
-1
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ static void installDefaultIntegrations(
404404
(Application) context, buildInfoProvider, activityFramesTracker));
405405
options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context));
406406
options.addIntegration(new UserInteractionIntegration((Application) context, loadClass));
407+
options.addIntegration(new ShakeDetectionIntegration((Application) context));
407408
if (isFragmentAvailable) {
408409
options.addIntegration(new FragmentLifecycleIntegration((Application) context, true, true));
409410
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package io.sentry.android.core;
2+
3+
import android.content.Context;
4+
import android.hardware.Sensor;
5+
import android.hardware.SensorEvent;
6+
import android.hardware.SensorEventListener;
7+
import android.hardware.SensorManager;
8+
import io.sentry.ILogger;
9+
import io.sentry.SentryLevel;
10+
import org.jetbrains.annotations.NotNull;
11+
import org.jetbrains.annotations.Nullable;
12+
13+
/**
14+
* Detects shake gestures using the device's accelerometer.
15+
*
16+
* <p>The accelerometer sensor (TYPE_ACCELEROMETER) does NOT require any special permissions on
17+
* Android. The BODY_SENSORS permission is only needed for heart rate and similar body sensors.
18+
*/
19+
public final class SentryShakeDetector implements SensorEventListener {
20+
21+
private static final float SHAKE_THRESHOLD_GRAVITY = 2.7f;
22+
private static final int SHAKE_COOLDOWN_MS = 1000;
23+
24+
private @Nullable SensorManager sensorManager;
25+
private long lastShakeTimestamp = 0;
26+
private @Nullable Listener listener;
27+
private final @NotNull ILogger logger;
28+
29+
public interface Listener {
30+
void onShake();
31+
}
32+
33+
public SentryShakeDetector(final @NotNull ILogger logger) {
34+
this.logger = logger;
35+
}
36+
37+
public void start(final @NotNull Context context, final @NotNull Listener shakeListener) {
38+
this.listener = shakeListener;
39+
sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
40+
if (sensorManager == null) {
41+
logger.log(SentryLevel.WARNING, "SensorManager is not available. Shake detection disabled.");
42+
return;
43+
}
44+
Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
45+
if (accelerometer == null) {
46+
logger.log(
47+
SentryLevel.WARNING, "Accelerometer sensor not available. Shake detection disabled.");
48+
return;
49+
}
50+
sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI);
51+
}
52+
53+
public void stop() {
54+
if (sensorManager != null) {
55+
sensorManager.unregisterListener(this);
56+
sensorManager = null;
57+
}
58+
listener = null;
59+
}
60+
61+
@Override
62+
public void onSensorChanged(final @NotNull SensorEvent event) {
63+
if (event.sensor.getType() != Sensor.TYPE_ACCELEROMETER) {
64+
return;
65+
}
66+
float gX = event.values[0] / SensorManager.GRAVITY_EARTH;
67+
float gY = event.values[1] / SensorManager.GRAVITY_EARTH;
68+
float gZ = event.values[2] / SensorManager.GRAVITY_EARTH;
69+
double gForce = Math.sqrt(gX * gX + gY * gY + gZ * gZ);
70+
if (gForce > SHAKE_THRESHOLD_GRAVITY) {
71+
long now = System.currentTimeMillis();
72+
if (now - lastShakeTimestamp > SHAKE_COOLDOWN_MS) {
73+
lastShakeTimestamp = now;
74+
if (listener != null) {
75+
listener.onShake();
76+
}
77+
}
78+
}
79+
}
80+
81+
@Override
82+
public void onAccuracyChanged(final @NotNull Sensor sensor, final int accuracy) {
83+
// Not needed for shake detection.
84+
}
85+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package io.sentry.android.core;
2+
3+
import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion;
4+
5+
import android.app.Activity;
6+
import android.app.Application;
7+
import android.os.Bundle;
8+
import io.sentry.IScopes;
9+
import io.sentry.Integration;
10+
import io.sentry.SentryLevel;
11+
import io.sentry.SentryOptions;
12+
import io.sentry.util.Objects;
13+
import java.io.Closeable;
14+
import java.io.IOException;
15+
import org.jetbrains.annotations.NotNull;
16+
import org.jetbrains.annotations.Nullable;
17+
18+
/**
19+
* Detects shake gestures and shows the user feedback dialog when a shake is detected. Only active
20+
* when {@link io.sentry.SentryFeedbackOptions#isUseShakeGesture()} returns {@code true}.
21+
*/
22+
public final class ShakeDetectionIntegration
23+
implements Integration, Closeable, Application.ActivityLifecycleCallbacks {
24+
25+
private final @NotNull Application application;
26+
private @Nullable SentryShakeDetector shakeDetector;
27+
private @Nullable SentryAndroidOptions options;
28+
private @Nullable Activity currentActivity;
29+
30+
public ShakeDetectionIntegration(final @NotNull Application application) {
31+
this.application = Objects.requireNonNull(application, "Application is required");
32+
}
33+
34+
@Override
35+
public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions sentryOptions) {
36+
this.options = (SentryAndroidOptions) sentryOptions;
37+
38+
if (!this.options.getFeedbackOptions().isUseShakeGesture()) {
39+
return;
40+
}
41+
42+
addIntegrationToSdkVersion("ShakeDetection");
43+
application.registerActivityLifecycleCallbacks(this);
44+
options.getLogger().log(SentryLevel.DEBUG, "ShakeDetectionIntegration installed.");
45+
}
46+
47+
@Override
48+
public void close() throws IOException {
49+
application.unregisterActivityLifecycleCallbacks(this);
50+
stopShakeDetection();
51+
}
52+
53+
@Override
54+
public void onActivityResumed(final @NotNull Activity activity) {
55+
currentActivity = activity;
56+
startShakeDetection(activity);
57+
}
58+
59+
@Override
60+
public void onActivityPaused(final @NotNull Activity activity) {
61+
stopShakeDetection();
62+
currentActivity = null;
63+
}
64+
65+
@Override
66+
public void onActivityCreated(
67+
final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {}
68+
69+
@Override
70+
public void onActivityStarted(final @NotNull Activity activity) {}
71+
72+
@Override
73+
public void onActivityStopped(final @NotNull Activity activity) {}
74+
75+
@Override
76+
public void onActivitySaveInstanceState(
77+
final @NotNull Activity activity, final @NotNull Bundle outState) {}
78+
79+
@Override
80+
public void onActivityDestroyed(final @NotNull Activity activity) {}
81+
82+
private void startShakeDetection(final @NotNull Activity activity) {
83+
if (shakeDetector != null || options == null) {
84+
return;
85+
}
86+
shakeDetector = new SentryShakeDetector(options.getLogger());
87+
shakeDetector.start(
88+
activity,
89+
() -> {
90+
final Activity active = currentActivity;
91+
if (active != null && options != null) {
92+
active.runOnUiThread(
93+
() -> options.getFeedbackOptions().getDialogHandler().showDialog(null, null));
94+
}
95+
});
96+
}
97+
98+
private void stopShakeDetection() {
99+
if (shakeDetector != null) {
100+
shakeDetector.stop();
101+
shakeDetector = null;
102+
}
103+
}
104+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,7 @@ class SentryAndroidTest {
476476
fixture.initSut(context = mock<Application>()) { options ->
477477
optionsRef = options
478478
options.dsn = "https://key@sentry.io/123"
479-
assertEquals(18, options.integrations.size)
479+
assertEquals(19, options.integrations.size)
480480
options.integrations.removeAll {
481481
it is UncaughtExceptionHandlerIntegration ||
482482
it is ShutdownHookIntegration ||
@@ -488,6 +488,7 @@ class SentryAndroidTest {
488488
it is ActivityLifecycleIntegration ||
489489
it is ActivityBreadcrumbsIntegration ||
490490
it is UserInteractionIntegration ||
491+
it is ShakeDetectionIntegration ||
491492
it is FragmentLifecycleIntegration ||
492493
it is SentryTimberIntegration ||
493494
it is AppComponentsBreadcrumbsIntegration ||

sentry/src/main/java/io/sentry/SentryFeedbackOptions.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ public final class SentryFeedbackOptions {
3535
/** Displays the Sentry logo inside of the form. Defaults to true. */
3636
private boolean showBranding = true;
3737

38+
/** Shows the feedback form when a shake gesture is detected. Defaults to {@code false}. */
39+
private boolean useShakeGesture = false;
40+
3841
// Text Customization
3942
/** The title of the feedback form. Defaults to "Report a Bug". */
4043
private @NotNull CharSequence formTitle = "Report a Bug";
@@ -102,6 +105,7 @@ public SentryFeedbackOptions(final @NotNull SentryFeedbackOptions other) {
102105
this.showEmail = other.showEmail;
103106
this.useSentryUser = other.useSentryUser;
104107
this.showBranding = other.showBranding;
108+
this.useShakeGesture = other.useShakeGesture;
105109
this.formTitle = other.formTitle;
106110
this.submitButtonLabel = other.submitButtonLabel;
107111
this.cancelButtonLabel = other.cancelButtonLabel;
@@ -234,6 +238,24 @@ public void setShowBranding(final boolean showBranding) {
234238
this.showBranding = showBranding;
235239
}
236240

241+
/**
242+
* Shows the feedback form when a shake gesture is detected. Defaults to {@code false}.
243+
*
244+
* @return true if shake gesture triggers the feedback form
245+
*/
246+
public boolean isUseShakeGesture() {
247+
return useShakeGesture;
248+
}
249+
250+
/**
251+
* Sets whether the feedback form is shown when a shake gesture is detected.
252+
*
253+
* @param useShakeGesture true to enable shake gesture triggering
254+
*/
255+
public void setUseShakeGesture(final boolean useShakeGesture) {
256+
this.useShakeGesture = useShakeGesture;
257+
}
258+
237259
/**
238260
* The title of the feedback form. Defaults to "Report a Bug".
239261
*
@@ -547,6 +569,8 @@ public String toString() {
547569
+ useSentryUser
548570
+ ", showBranding="
549571
+ showBranding
572+
+ ", useShakeGesture="
573+
+ useShakeGesture
550574
+ ", formTitle='"
551575
+ formTitle
552576
+ '\''

0 commit comments

Comments
 (0)