Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
40 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
a4b5a97
Merge branch 'main' into antonis/feedback-shake
antonis Mar 12, 2026
ed0dc61
Merge branch 'main' into antonis/feedback-shake
antonis Mar 16, 2026
8197f93
fix(feedback): check isFinishing/isDestroyed before showing dialog
antonis Mar 16, 2026
fedd3a2
Format code
getsentry-bot Mar 16, 2026
6e02d21
Merge branch 'main' into antonis/feedback-shake
antonis Mar 17, 2026
bc775db
Enable the feature on the demo app for easier testing
antonis Mar 17, 2026
fb1120c
Use a weak reference for activity
antonis Mar 17, 2026
f25fb8b
Reuse sentry-shake handler thread
antonis Mar 17, 2026
e89af4f
Add close to the API
antonis Mar 17, 2026
8456a56
fix(feedback): use instanceof check for SentryAndroidOptions cast
antonis Mar 17, 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)`
- 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));
Comment thread
antonis marked this conversation as resolved.
Outdated
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;
Comment thread
antonis marked this conversation as resolved.

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);
Comment thread
antonis marked this conversation as resolved.
Outdated
if (sensorManager == null) {
logger.log(SentryLevel.WARNING, "SensorManager is not available. Shake detection disabled.");
return;
}
Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
Comment thread
antonis marked this conversation as resolved.
Outdated
if (accelerometer == null) {
logger.log(
SentryLevel.WARNING, "Accelerometer sensor not available. Shake detection disabled.");
return;
}
sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL);
Comment thread
antonis marked this conversation as resolved.
Outdated
}
Comment thread
antonis marked this conversation as resolved.
Comment thread
antonis marked this conversation as resolved.

public void stop() {
if (sensorManager != null) {
sensorManager.unregisterListener(this);
sensorManager = null;
}
listener = null;
Comment thread
antonis marked this conversation as resolved.
Outdated
}

@Override
public void onSensorChanged(final @NotNull SensorEvent event) {
Comment thread
sentry[bot] marked this conversation as resolved.
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 thread
antonis marked this conversation as resolved.
Outdated
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,126 @@
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
* 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;

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");
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) {}

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) {
active.runOnUiThread(
() -> {
try {
options.getFeedbackOptions().getDialogHandler().showDialog(null, null);
} catch (Throwable e) {
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