Skip to content

Commit 28b6988

Browse files
antonisclaudelucas-zimermangetsentry-bot
authored
feat(feedback): implement shake gesture detection (#5150)
* 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> * fix(feedback): improve shake detection robustness and add tests - Add volatile/AtomicLong for thread-safe cross-thread field access - Use SystemClock.elapsedRealtime() instead of System.currentTimeMillis() - Use SENSOR_DELAY_NORMAL for better battery efficiency - Add multi-shake counting (2+ threshold crossings within 1.5s window) - Handle deferred init for already-resumed activities - Wrap showDialog() in try-catch to prevent app crashes - Improve activity transition handling in onActivityPaused - Mark SentryShakeDetector as @ApiStatus.Internal - Add unit tests for SentryShakeDetector and ShakeDetectionIntegration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): prevent stacking multiple feedback dialogs on repeated shakes Track dialog visibility with an isDialogShowing flag that is set before showing and cleared via the onFormClose callback when the dialog is dismissed. Double-checked on both sensor and UI threads to avoid races. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): restore original onFormClose to prevent callback chain growth Save the user's original onFormClose once during register() and restore it after each dialog dismiss, instead of wrapping it with a new lambda each time. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): reset isDialogShowing on activity pause to prevent stuck flag If showDialog silently fails (e.g. activity destroyed between post and execution), isDialogShowing would stay true forever, permanently disabling shake-to-feedback. Reset it in onActivityPaused since the dialog cannot outlive its host activity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): move isDialogShowing reset from onActivityPaused to onActivityDestroyed AlertDialog survives pause/resume cycles (e.g. screen off/on), so resetting isDialogShowing in onActivityPaused allowed duplicate dialogs. Move the reset to onActivityDestroyed where the dialog truly cannot survive. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): scope dialog flag to hosting activity and restore callback on error - Only reset isDialogShowing in onActivityDestroyed when it's the activity that hosts the dialog, not any unrelated activity. - Restore originalOnFormClose in the catch block when showDialog throws. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Optimise comparison Co-authored-by: LucasZF <lucas-zimerman1@hotmail.com> * ref(feedback): address review feedback from lucas-zimerman - Rename ShakeDetectionIntegration to FeedbackShakeIntegration to clarify its purpose is feedback-specific (#1) - Avoid Math.sqrt by comparing squared gForce values (#3) - Null out listener before unregistering sensor to prevent in-flight callbacks during stop (#4) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): capture onFormClose at shake time instead of registration Capture the current onFormClose callback just before showing the dialog rather than caching it during register(). This ensures callbacks set by the user after SDK init are preserved across shake-triggered dialogs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Reverse sample changes * fix(feedback): restore onFormClose in onActivityDestroyed fallback path When the dialog's host activity is destroyed and onDismiss doesn't fire, onActivityDestroyed now restores the previous onFormClose callback on global options, preventing a stale wrapper from affecting subsequent non-shake feedback dialogs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): make previousOnFormClose volatile for thread safety Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): always restore onFormClose in onActivityDestroyed even when null The previous null check on previousOnFormClose skipped restoration when no user callback was set, leaving a stale wrapper in global options. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update changelog * ref(feedback): address review feedback for shake gesture detection - Reuse single SentryShakeDetector instance across activity transitions instead of re-creating on every resume (reduces allocations) - Memoize SensorManager and Sensor lookups to avoid repeated binder calls - Use getDefaultSensor(TYPE_ACCELEROMETER, false) to avoid wakeup sensor - Deliver sensor events on a background HandlerThread instead of main thread - Use SentryUserFeedbackDialog.Builder directly with tracked activity instead of going through showDialog/CurrentActivityHolder - Merge dialogActivity into currentActivity, use AppState.isInBackground() to gate against background shakes - Fix integration count in SentryAndroidTest (19 -> 20) Tested manually on Pixel 8 Pro by enabling useShakeGesture in the sample app's SentryAndroid.init and verifying shake opens the feedback dialog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Format code * feat(feedback): add manifest meta-data support for useShakeGesture Allow enabling shake gesture via AndroidManifest.xml: <meta-data android:name="io.sentry.feedback.use-shake-gesture" android:value="true" /> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): preserve currentActivity in onActivityPaused when dialog is showing onActivityPaused always fires before onActivityDestroyed. Without this fix, currentActivity was set to null in onPause, making the cleanup condition in onActivityDestroyed (activity == currentActivity) always false. This left isDialogShowing permanently stuck as true, disabling shake-to-feedback for the rest of the session. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): pass real logger to SentryShakeDetector on init The detector was constructed with NoOpLogger and the logger field was final, so all diagnostic messages (sensor unavailable warnings) were silently swallowed. Now init(context, logger) updates the logger from SentryOptions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Format code * fix(feedback): clear stale activity ref and reset shake state on stop - Clear currentActivity in onActivityDestroyed to prevent holding a stale reference to a destroyed activity context - Reset shakeCount and firstShakeTimestamp in stop() to prevent cross-session false triggers across pause/resume cycles Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): clean up dialog state when a different activity resumes When a dialog is showing on Activity A and the user navigates to Activity B (e.g. via notification), onActivityResumed(B) overwrites currentActivity. Later onActivityDestroyed(A) can't match and cleanup never runs, leaving isDialogShowing permanently stuck. Now we detect this in onActivityResumed and clean up proactively. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): capture onFormClose as local variable in lambda The onFormClose lambda was reading previousOnFormClose field at dismiss time. If onActivityResumed or onActivityDestroyed already restored and nulled the field, the lambda would overwrite onFormClose with null. Now captured as a local variable at dialog creation time. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): restore onFormClose in close() when dialog is showing When close() is called while a dialog is showing, lifecycle callbacks are unregistered so onActivityDestroyed cleanup won't fire. Restore previousOnFormClose and reset dialog state in close() to prevent the callback from being permanently overwritten. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(feedback): check isFinishing/isDestroyed before showing dialog Add proactive activity validity check inside the runOnUiThread lambda to avoid hitting the catch block with a BadTokenException when the activity becomes invalid between the shake callback and UI execution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Format code * Enable the feature on the demo app for easier testing * Use a weak reference for activity * Reuse sentry-shake handler thread * Add close to the API * fix(feedback): use instanceof check for SentryAndroidOptions cast Follow the established defensive pattern used by all other Android integrations instead of an unchecked cast that could throw ClassCastException if a hybrid SDK passes a different options type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: LucasZF <lucas-zimerman1@hotmail.com> Co-authored-by: Sentry Github Bot <bot+github-bot@sentry.io>
1 parent 9c1b406 commit 28b6988

File tree

13 files changed

+716
-1
lines changed

13 files changed

+716
-1
lines changed

CHANGELOG.md

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

33
## Unreleased
44

5+
### Features
6+
7+
- Show feedback form on device shake ([#5150](https://github.com/getsentry/sentry-java/pull/5150))
8+
- Enable via `options.getFeedbackOptions().setUseShakeGesture(true)` or manifest meta-data `io.sentry.feedback.use-shake-gesture`
9+
- Uses the device's accelerometer — no special permissions required
10+
511
### Fixes
612

713
- Support masking/unmasking and click/scroll detection for Jetpack Compose 1.10+ ([#5189](https://github.com/getsentry/sentry-java/pull/5189))

sentry-android-core/api/sentry-android-core.api

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,19 @@ public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : i
269269
public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
270270
}
271271

272+
public final class io/sentry/android/core/FeedbackShakeIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable {
273+
public fun <init> (Landroid/app/Application;)V
274+
public fun close ()V
275+
public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V
276+
public fun onActivityDestroyed (Landroid/app/Activity;)V
277+
public fun onActivityPaused (Landroid/app/Activity;)V
278+
public fun onActivityResumed (Landroid/app/Activity;)V
279+
public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V
280+
public fun onActivityStarted (Landroid/app/Activity;)V
281+
public fun onActivityStopped (Landroid/app/Activity;)V
282+
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
283+
}
284+
272285
public abstract interface class io/sentry/android/core/IDebugImagesLoader {
273286
public abstract fun clearDebugImages ()V
274287
public abstract fun loadDebugImages ()Ljava/util/List;
@@ -462,6 +475,19 @@ public final class io/sentry/android/core/SentryScreenshotOptions : io/sentry/Se
462475
public fun trackCustomMasking ()V
463476
}
464477

478+
public final class io/sentry/android/core/SentryShakeDetector : android/hardware/SensorEventListener {
479+
public fun <init> (Lio/sentry/ILogger;)V
480+
public fun close ()V
481+
public fun onAccuracyChanged (Landroid/hardware/Sensor;I)V
482+
public fun onSensorChanged (Landroid/hardware/SensorEvent;)V
483+
public fun start (Landroid/content/Context;Lio/sentry/android/core/SentryShakeDetector$Listener;)V
484+
public fun stop ()V
485+
}
486+
487+
public abstract interface class io/sentry/android/core/SentryShakeDetector$Listener {
488+
public abstract fun onShake ()V
489+
}
490+
465491
public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Button {
466492
public fun <init> (Landroid/content/Context;)V
467493
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;)V

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
@@ -410,6 +410,7 @@ static void installDefaultIntegrations(
410410
(Application) context, buildInfoProvider, activityFramesTracker));
411411
options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context));
412412
options.addIntegration(new UserInteractionIntegration((Application) context, loadClass));
413+
options.addIntegration(new FeedbackShakeIntegration((Application) context));
413414
if (isFragmentAvailable) {
414415
options.addIntegration(new FragmentLifecycleIntegration((Application) context, true, true));
415416
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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 java.lang.ref.WeakReference;
16+
import org.jetbrains.annotations.NotNull;
17+
import org.jetbrains.annotations.Nullable;
18+
19+
/**
20+
* Detects shake gestures and shows the user feedback dialog when a shake is detected. Only active
21+
* when {@link io.sentry.SentryFeedbackOptions#isUseShakeGesture()} returns {@code true}.
22+
*/
23+
public final class FeedbackShakeIntegration
24+
implements Integration, Closeable, Application.ActivityLifecycleCallbacks {
25+
26+
private final @NotNull Application application;
27+
private final @NotNull SentryShakeDetector shakeDetector;
28+
private @Nullable SentryAndroidOptions options;
29+
private volatile @Nullable WeakReference<Activity> currentActivityRef;
30+
private volatile boolean isDialogShowing = false;
31+
private volatile @Nullable Runnable previousOnFormClose;
32+
33+
public FeedbackShakeIntegration(final @NotNull Application application) {
34+
this.application = Objects.requireNonNull(application, "Application is required");
35+
this.shakeDetector = new SentryShakeDetector(io.sentry.NoOpLogger.getInstance());
36+
}
37+
38+
@Override
39+
public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions sentryOptions) {
40+
this.options =
41+
Objects.requireNonNull(
42+
(sentryOptions instanceof SentryAndroidOptions)
43+
? (SentryAndroidOptions) sentryOptions
44+
: null,
45+
"SentryAndroidOptions is required");
46+
47+
if (!this.options.getFeedbackOptions().isUseShakeGesture()) {
48+
return;
49+
}
50+
51+
shakeDetector.init(application, options.getLogger());
52+
53+
addIntegrationToSdkVersion("FeedbackShake");
54+
application.registerActivityLifecycleCallbacks(this);
55+
options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration installed.");
56+
57+
// In case of a deferred init, hook into any already-resumed activity
58+
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
59+
if (activity != null) {
60+
currentActivityRef = new WeakReference<>(activity);
61+
startShakeDetection(activity);
62+
}
63+
}
64+
65+
@Override
66+
public void close() throws IOException {
67+
application.unregisterActivityLifecycleCallbacks(this);
68+
shakeDetector.close();
69+
// Restore onFormClose if a dialog is still showing, since lifecycle callbacks
70+
// are now unregistered and onActivityDestroyed cleanup won't fire.
71+
if (isDialogShowing) {
72+
isDialogShowing = false;
73+
if (options != null) {
74+
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
75+
}
76+
previousOnFormClose = null;
77+
}
78+
currentActivityRef = null;
79+
}
80+
81+
@Override
82+
public void onActivityResumed(final @NotNull Activity activity) {
83+
// If a dialog is showing on a different activity (e.g. user navigated via notification),
84+
// clean up since the dialog's host activity is going away and onActivityDestroyed
85+
// won't match currentActivity anymore.
86+
final @Nullable Activity current = currentActivityRef != null ? currentActivityRef.get() : null;
87+
if (isDialogShowing && current != null && current != activity) {
88+
isDialogShowing = false;
89+
if (options != null) {
90+
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
91+
}
92+
previousOnFormClose = null;
93+
}
94+
currentActivityRef = new WeakReference<>(activity);
95+
startShakeDetection(activity);
96+
}
97+
98+
@Override
99+
public void onActivityPaused(final @NotNull Activity activity) {
100+
// Only stop if this is the activity we're tracking. When transitioning between
101+
// activities, B.onResume may fire before A.onPause — stopping unconditionally
102+
// would kill shake detection for the new activity.
103+
final @Nullable Activity current = currentActivityRef != null ? currentActivityRef.get() : null;
104+
if (activity == current) {
105+
stopShakeDetection();
106+
// Keep currentActivityRef set when a dialog is showing so onActivityDestroyed
107+
// can still match and clean up. Otherwise the cleanup condition
108+
// (activity == current) would always be false since onPause fires
109+
// before onDestroy.
110+
if (!isDialogShowing) {
111+
currentActivityRef = null;
112+
}
113+
}
114+
}
115+
116+
@Override
117+
public void onActivityCreated(
118+
final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {}
119+
120+
@Override
121+
public void onActivityStarted(final @NotNull Activity activity) {}
122+
123+
@Override
124+
public void onActivityStopped(final @NotNull Activity activity) {}
125+
126+
@Override
127+
public void onActivitySaveInstanceState(
128+
final @NotNull Activity activity, final @NotNull Bundle outState) {}
129+
130+
@Override
131+
public void onActivityDestroyed(final @NotNull Activity activity) {
132+
// Only reset if this is the activity that hosts the dialog — the dialog cannot
133+
// outlive its host activity being destroyed.
134+
final @Nullable Activity current = currentActivityRef != null ? currentActivityRef.get() : null;
135+
if (isDialogShowing && activity == current) {
136+
isDialogShowing = false;
137+
currentActivityRef = null;
138+
if (options != null) {
139+
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
140+
}
141+
previousOnFormClose = null;
142+
}
143+
}
144+
145+
private void startShakeDetection(final @NotNull Activity activity) {
146+
if (options == null) {
147+
return;
148+
}
149+
// Stop any existing detection (e.g. when transitioning between activities)
150+
stopShakeDetection();
151+
shakeDetector.start(
152+
activity,
153+
() -> {
154+
final @Nullable WeakReference<Activity> ref = currentActivityRef;
155+
final Activity active = ref != null ? ref.get() : null;
156+
final Boolean inBackground = AppState.getInstance().isInBackground();
157+
if (active != null
158+
&& options != null
159+
&& !isDialogShowing
160+
&& !Boolean.TRUE.equals(inBackground)) {
161+
active.runOnUiThread(
162+
() -> {
163+
if (isDialogShowing || active.isFinishing() || active.isDestroyed()) {
164+
return;
165+
}
166+
try {
167+
isDialogShowing = true;
168+
final Runnable captured = options.getFeedbackOptions().getOnFormClose();
169+
previousOnFormClose = captured;
170+
options
171+
.getFeedbackOptions()
172+
.setOnFormClose(
173+
() -> {
174+
isDialogShowing = false;
175+
options.getFeedbackOptions().setOnFormClose(captured);
176+
if (captured != null) {
177+
captured.run();
178+
}
179+
previousOnFormClose = null;
180+
});
181+
new SentryUserFeedbackDialog.Builder(active).create().show();
182+
} catch (Throwable e) {
183+
isDialogShowing = false;
184+
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
185+
previousOnFormClose = null;
186+
options
187+
.getLogger()
188+
.log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e);
189+
}
190+
});
191+
}
192+
});
193+
}
194+
195+
private void stopShakeDetection() {
196+
shakeDetector.stop();
197+
}
198+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ final class ManifestMetadataReader {
167167

168168
static final String FEEDBACK_SHOW_BRANDING = "io.sentry.feedback.show-branding";
169169

170+
static final String FEEDBACK_USE_SHAKE_GESTURE = "io.sentry.feedback.use-shake-gesture";
171+
170172
static final String SPOTLIGHT_ENABLE = "io.sentry.spotlight.enable";
171173

172174
static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url";
@@ -661,6 +663,9 @@ static void applyMetadata(
661663
metadata, logger, FEEDBACK_USE_SENTRY_USER, feedbackOptions.isUseSentryUser()));
662664
feedbackOptions.setShowBranding(
663665
readBool(metadata, logger, FEEDBACK_SHOW_BRANDING, feedbackOptions.isShowBranding()));
666+
feedbackOptions.setUseShakeGesture(
667+
readBool(
668+
metadata, logger, FEEDBACK_USE_SHAKE_GESTURE, feedbackOptions.isUseShakeGesture()));
664669

665670
options.setEnableSpotlight(
666671
readBool(metadata, logger, SPOTLIGHT_ENABLE, options.isEnableSpotlight()));

0 commit comments

Comments
 (0)