Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c04bebe
feat(feedback): implement shake gesture detection for user feedback form
antonis Mar 3, 2026
177bb48
fix(feedback): improve shake detection robustness and add tests
antonis Mar 4, 2026
d13da1b
Merge branch 'main' into antonis/feedback-shake
antonis Mar 4, 2026
0cc2f3b
fix(feedback): prevent stacking multiple feedback dialogs on repeatedโ€ฆ
antonis Mar 4, 2026
83eed6d
fix(feedback): restore original onFormClose to prevent callback chainโ€ฆ
antonis Mar 4, 2026
dbbd37e
fix(feedback): reset isDialogShowing on activity pause to prevent stuโ€ฆ
antonis Mar 4, 2026
527abb7
fix(feedback): move isDialogShowing reset from onActivityPaused to onโ€ฆ
antonis Mar 4, 2026
0b090bc
fix(feedback): scope dialog flag to hosting activity and restore callโ€ฆ
antonis Mar 4, 2026
d77d60d
Optimise comparison
antonis Mar 5, 2026
1fd4f5b
ref(feedback): address review feedback from lucas-zimerman
antonis Mar 5, 2026
43b5ebc
fix(feedback): capture onFormClose at shake time instead of registration
antonis Mar 5, 2026
0cb8d00
Reverse sample changes
antonis Mar 5, 2026
285afde
fix(feedback): restore onFormClose in onActivityDestroyed fallback path
antonis Mar 5, 2026
87d508a
fix(feedback): make previousOnFormClose volatile for thread safety
antonis Mar 5, 2026
8e2dee3
fix(feedback): always restore onFormClose in onActivityDestroyed evenโ€ฆ
antonis Mar 5, 2026
d180230
Merge branch 'main' into antonis/feedback-shake
antonis Mar 5, 2026
344d6fe
Merge branch 'main' into antonis/feedback-shake
antonis Mar 9, 2026
6993267
Update changelog
antonis Mar 9, 2026
bac65b8
Merge remote-tracking branch 'origin/main' into antonis/feedback-shake
antonis Mar 9, 2026
9aa138e
ref(feedback): address review feedback for shake gesture detection
antonis Mar 9, 2026
60b8a66
Format code
getsentry-bot Mar 9, 2026
65122ca
feat(feedback): add manifest meta-data support for useShakeGesture
antonis Mar 9, 2026
59cfc0a
fix(feedback): preserve currentActivity in onActivityPaused when dialโ€ฆ
antonis Mar 9, 2026
69ee931
fix(feedback): pass real logger to SentryShakeDetector on init
antonis Mar 9, 2026
7ba0447
Format code
getsentry-bot Mar 9, 2026
a9ac3e1
Merge branch 'antonis/feedback-shake' of github.com:getsentry/sentry-โ€ฆ
antonis Mar 9, 2026
a734533
fix(feedback): clear stale activity ref and reset shake state on stop
antonis Mar 9, 2026
8b9bab2
fix(feedback): clean up dialog state when a different activity resumes
antonis Mar 9, 2026
301cc33
fix(feedback): capture onFormClose as local variable in lambda
antonis Mar 9, 2026
1eb6c23
fix(feedback): restore onFormClose in close() when dialog is showing
antonis Mar 9, 2026
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
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)` or manifest meta-data `io.sentry.feedback.use-shake-gesture`
- Uses the device's accelerometer โ€” no special permissions required

## 8.34.1

### Fixes
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 @@ -269,6 +269,19 @@ public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : i
public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
}

public final class io/sentry/android/core/FeedbackShakeIntegration : 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 abstract interface class io/sentry/android/core/IDebugImagesLoader {
public abstract fun clearDebugImages ()V
public abstract fun loadDebugImages ()Ljava/util/List;
Expand Down Expand Up @@ -462,6 +475,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
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ static void installDefaultIntegrations(
(Application) context, buildInfoProvider, activityFramesTracker));
options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context));
options.addIntegration(new UserInteractionIntegration((Application) context, loadClass));
options.addIntegration(new FeedbackShakeIntegration((Application) context));
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,188 @@
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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated with 1fd4f5b

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

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

public FeedbackShakeIntegration(final @NotNull Application application) {
this.application = Objects.requireNonNull(application, "Application is required");
this.shakeDetector = new SentryShakeDetector(io.sentry.NoOpLogger.getInstance());
}

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

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

shakeDetector.init(application, options.getLogger());

addIntegrationToSdkVersion("FeedbackShake");
application.registerActivityLifecycleCallbacks(this);
options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration 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();
// Restore onFormClose if a dialog is still showing, since lifecycle callbacks
// are now unregistered and onActivityDestroyed cleanup won't fire.
if (isDialogShowing) {
isDialogShowing = false;
if (options != null) {
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
}
previousOnFormClose = null;
}
currentActivity = null;
}

@Override
public void onActivityResumed(final @NotNull Activity activity) {
// If a dialog is showing on a different activity (e.g. user navigated via notification),
// clean up since the dialog's host activity is going away and onActivityDestroyed
// won't match currentActivity anymore.
if (isDialogShowing && currentActivity != null && currentActivity != activity) {
isDialogShowing = false;
if (options != null) {
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
}
previousOnFormClose = null;
}
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();
// Keep currentActivity set when a dialog is showing so onActivityDestroyed
// can still match and clean up. Otherwise the cleanup condition
// (activity == currentActivity) would always be false since onPause fires
// before onDestroy.
if (!isDialogShowing) {
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 (isDialogShowing && activity == currentActivity) {
isDialogShowing = false;
currentActivity = null;
if (options != null) {
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
}
previousOnFormClose = null;
}
}

private void startShakeDetection(final @NotNull Activity activity) {
if (options == null) {
return;
}
// Stop any existing detection (e.g. when transitioning between activities)
stopShakeDetection();
shakeDetector.start(
activity,
() -> {
final Activity active = currentActivity;
final Boolean inBackground = AppState.getInstance().isInBackground();
if (active != null
&& options != null
&& !isDialogShowing
&& !Boolean.TRUE.equals(inBackground)) {
active.runOnUiThread(
() -> {
if (isDialogShowing) {
return;
}
try {
isDialogShowing = true;
final Runnable captured = options.getFeedbackOptions().getOnFormClose();
previousOnFormClose = captured;
options
.getFeedbackOptions()
.setOnFormClose(
() -> {
isDialogShowing = false;
options.getFeedbackOptions().setOnFormClose(captured);
if (captured != null) {
captured.run();
}
previousOnFormClose = null;
});
new SentryUserFeedbackDialog.Builder(active).create().show();
} catch (Throwable e) {
isDialogShowing = false;
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
previousOnFormClose = null;
options
.getLogger()
.log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e);
}
});
}
});
}

private void stopShakeDetection() {
shakeDetector.stop();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ final class ManifestMetadataReader {

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

static final String FEEDBACK_USE_SHAKE_GESTURE = "io.sentry.feedback.use-shake-gesture";

static final String SPOTLIGHT_ENABLE = "io.sentry.spotlight.enable";

static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url";
Expand Down Expand Up @@ -661,6 +663,9 @@ static void applyMetadata(
metadata, logger, FEEDBACK_USE_SENTRY_USER, feedbackOptions.isUseSentryUser()));
feedbackOptions.setShowBranding(
readBool(metadata, logger, FEEDBACK_SHOW_BRANDING, feedbackOptions.isShowBranding()));
feedbackOptions.setUseShakeGesture(
readBool(
metadata, logger, FEEDBACK_USE_SHAKE_GESTURE, feedbackOptions.isUseShakeGesture()));

options.setEnableSpotlight(
readBool(metadata, logger, SPOTLIGHT_ENABLE, options.isEnableSpotlight()));
Expand Down
Loading
Loading