Skip to content

Commit 3a14925

Browse files
authored
Merge 87d508a into 83884a0
2 parents 83884a0 + 87d508a commit 3a14925

File tree

10 files changed

+596
-1
lines changed

10 files changed

+596
-1
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## Unreleased
4+
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)`
9+
- Uses the device's accelerometer — no special permissions required
10+
311
## 8.34.0
412

513
### Features

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

Lines changed: 25 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;
@@ -457,6 +470,18 @@ public final class io/sentry/android/core/SentryScreenshotOptions : io/sentry/Se
457470
public fun trackCustomMasking ()V
458471
}
459472

473+
public final class io/sentry/android/core/SentryShakeDetector : android/hardware/SensorEventListener {
474+
public fun <init> (Lio/sentry/ILogger;)V
475+
public fun onAccuracyChanged (Landroid/hardware/Sensor;I)V
476+
public fun onSensorChanged (Landroid/hardware/SensorEvent;)V
477+
public fun start (Landroid/content/Context;Lio/sentry/android/core/SentryShakeDetector$Listener;)V
478+
public fun stop ()V
479+
}
480+
481+
public abstract interface class io/sentry/android/core/SentryShakeDetector$Listener {
482+
public abstract fun onShake ()V
483+
}
484+
460485
public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Button {
461486
public fun <init> (Landroid/content/Context;)V
462487
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
@@ -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 FeedbackShakeIntegration((Application) context));
407408
if (isFragmentAvailable) {
408409
options.addIntegration(new FragmentLifecycleIntegration((Application) context, true, true));
409410
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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 FeedbackShakeIntegration
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 volatile @Nullable Activity currentActivity;
29+
private volatile boolean isDialogShowing = false;
30+
private volatile @Nullable Activity dialogActivity;
31+
private volatile @Nullable Runnable previousOnFormClose;
32+
33+
public FeedbackShakeIntegration(final @NotNull Application application) {
34+
this.application = Objects.requireNonNull(application, "Application is required");
35+
}
36+
37+
@Override
38+
public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions sentryOptions) {
39+
this.options = (SentryAndroidOptions) sentryOptions;
40+
41+
if (!this.options.getFeedbackOptions().isUseShakeGesture()) {
42+
return;
43+
}
44+
45+
addIntegrationToSdkVersion("FeedbackShake");
46+
application.registerActivityLifecycleCallbacks(this);
47+
options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration installed.");
48+
49+
// In case of a deferred init, hook into any already-resumed activity
50+
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
51+
if (activity != null) {
52+
currentActivity = activity;
53+
startShakeDetection(activity);
54+
}
55+
}
56+
57+
@Override
58+
public void close() throws IOException {
59+
application.unregisterActivityLifecycleCallbacks(this);
60+
stopShakeDetection();
61+
}
62+
63+
@Override
64+
public void onActivityResumed(final @NotNull Activity activity) {
65+
currentActivity = activity;
66+
startShakeDetection(activity);
67+
}
68+
69+
@Override
70+
public void onActivityPaused(final @NotNull Activity activity) {
71+
// Only stop if this is the activity we're tracking. When transitioning between
72+
// activities, B.onResume may fire before A.onPause — stopping unconditionally
73+
// would kill shake detection for the new activity.
74+
if (activity == currentActivity) {
75+
stopShakeDetection();
76+
currentActivity = null;
77+
}
78+
}
79+
80+
@Override
81+
public void onActivityCreated(
82+
final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {}
83+
84+
@Override
85+
public void onActivityStarted(final @NotNull Activity activity) {}
86+
87+
@Override
88+
public void onActivityStopped(final @NotNull Activity activity) {}
89+
90+
@Override
91+
public void onActivitySaveInstanceState(
92+
final @NotNull Activity activity, final @NotNull Bundle outState) {}
93+
94+
@Override
95+
public void onActivityDestroyed(final @NotNull Activity activity) {
96+
// Only reset if this is the activity that hosts the dialog — the dialog cannot
97+
// outlive its host activity being destroyed.
98+
if (activity == dialogActivity) {
99+
isDialogShowing = false;
100+
dialogActivity = null;
101+
if (options != null && previousOnFormClose != null) {
102+
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
103+
}
104+
previousOnFormClose = null;
105+
}
106+
}
107+
108+
private void startShakeDetection(final @NotNull Activity activity) {
109+
if (options == null) {
110+
return;
111+
}
112+
// Stop any existing detector (e.g. when transitioning between activities)
113+
stopShakeDetection();
114+
shakeDetector = new SentryShakeDetector(options.getLogger());
115+
shakeDetector.start(
116+
activity,
117+
() -> {
118+
final Activity active = currentActivity;
119+
if (active != null && options != null && !isDialogShowing) {
120+
active.runOnUiThread(
121+
() -> {
122+
if (isDialogShowing) {
123+
return;
124+
}
125+
try {
126+
isDialogShowing = true;
127+
dialogActivity = active;
128+
previousOnFormClose = options.getFeedbackOptions().getOnFormClose();
129+
options
130+
.getFeedbackOptions()
131+
.setOnFormClose(
132+
() -> {
133+
isDialogShowing = false;
134+
dialogActivity = null;
135+
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
136+
if (previousOnFormClose != null) {
137+
previousOnFormClose.run();
138+
}
139+
previousOnFormClose = null;
140+
});
141+
options.getFeedbackOptions().getDialogHandler().showDialog(null, null);
142+
} catch (Throwable e) {
143+
isDialogShowing = false;
144+
dialogActivity = null;
145+
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
146+
previousOnFormClose = null;
147+
options
148+
.getLogger()
149+
.log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e);
150+
}
151+
});
152+
}
153+
});
154+
}
155+
156+
private void stopShakeDetection() {
157+
if (shakeDetector != null) {
158+
shakeDetector.stop();
159+
shakeDetector = null;
160+
}
161+
}
162+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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 android.os.SystemClock;
9+
import io.sentry.ILogger;
10+
import io.sentry.SentryLevel;
11+
import java.util.concurrent.atomic.AtomicLong;
12+
import org.jetbrains.annotations.ApiStatus;
13+
import org.jetbrains.annotations.NotNull;
14+
import org.jetbrains.annotations.Nullable;
15+
16+
/**
17+
* Detects shake gestures using the device's accelerometer.
18+
*
19+
* <p>The accelerometer sensor (TYPE_ACCELEROMETER) does NOT require any special permissions on
20+
* Android. The BODY_SENSORS permission is only needed for heart rate and similar body sensors.
21+
*
22+
* <p>Requires at least {@link #SHAKE_COUNT_THRESHOLD} accelerometer readings above {@link
23+
* #SHAKE_THRESHOLD_GRAVITY} within {@link #SHAKE_WINDOW_MS} to trigger a shake event.
24+
*/
25+
@ApiStatus.Internal
26+
public final class SentryShakeDetector implements SensorEventListener {
27+
28+
private static final float SHAKE_THRESHOLD_GRAVITY = 2.7f;
29+
private static final int SHAKE_WINDOW_MS = 1500;
30+
private static final int SHAKE_COUNT_THRESHOLD = 2;
31+
private static final int SHAKE_COOLDOWN_MS = 1000;
32+
33+
private @Nullable SensorManager sensorManager;
34+
private final @NotNull AtomicLong lastShakeTimestamp = new AtomicLong(0);
35+
private volatile @Nullable Listener listener;
36+
private final @NotNull ILogger logger;
37+
38+
private int shakeCount = 0;
39+
private long firstShakeTimestamp = 0;
40+
41+
public interface Listener {
42+
void onShake();
43+
}
44+
45+
public SentryShakeDetector(final @NotNull ILogger logger) {
46+
this.logger = logger;
47+
}
48+
49+
public void start(final @NotNull Context context, final @NotNull Listener shakeListener) {
50+
this.listener = shakeListener;
51+
sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
52+
if (sensorManager == null) {
53+
logger.log(SentryLevel.WARNING, "SensorManager is not available. Shake detection disabled.");
54+
return;
55+
}
56+
Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
57+
if (accelerometer == null) {
58+
logger.log(
59+
SentryLevel.WARNING, "Accelerometer sensor not available. Shake detection disabled.");
60+
return;
61+
}
62+
sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL);
63+
}
64+
65+
public void stop() {
66+
listener = null;
67+
if (sensorManager != null) {
68+
sensorManager.unregisterListener(this);
69+
sensorManager = null;
70+
}
71+
}
72+
73+
@Override
74+
public void onSensorChanged(final @NotNull SensorEvent event) {
75+
if (event.sensor.getType() != Sensor.TYPE_ACCELEROMETER) {
76+
return;
77+
}
78+
float gX = event.values[0] / SensorManager.GRAVITY_EARTH;
79+
float gY = event.values[1] / SensorManager.GRAVITY_EARTH;
80+
float gZ = event.values[2] / SensorManager.GRAVITY_EARTH;
81+
double gForceSquared = gX * gX + gY * gY + gZ * gZ;
82+
if (gForceSquared > SHAKE_THRESHOLD_GRAVITY * SHAKE_THRESHOLD_GRAVITY) {
83+
long now = SystemClock.elapsedRealtime();
84+
85+
// Reset counter if outside the detection window
86+
if (now - firstShakeTimestamp > SHAKE_WINDOW_MS) {
87+
shakeCount = 0;
88+
firstShakeTimestamp = now;
89+
}
90+
91+
shakeCount++;
92+
93+
if (shakeCount >= SHAKE_COUNT_THRESHOLD) {
94+
// Enforce cooldown so we don't fire repeatedly
95+
long lastShake = lastShakeTimestamp.get();
96+
if (now - lastShake > SHAKE_COOLDOWN_MS) {
97+
lastShakeTimestamp.set(now);
98+
shakeCount = 0;
99+
final @Nullable Listener currentListener = listener;
100+
if (currentListener != null) {
101+
currentListener.onShake();
102+
}
103+
}
104+
}
105+
}
106+
}
107+
108+
@Override
109+
public void onAccuracyChanged(final @NotNull Sensor sensor, final int accuracy) {
110+
// Not needed for shake detection.
111+
}
112+
}

0 commit comments

Comments
 (0)