Skip to content

Commit 7400992

Browse files
antonisclaude
andcommitted
feat(feedback): Add shake-to-report feedback support
Implement device shake detection to trigger the feedback widget. No permissions are required on either platform: - iOS: Uses UIKit's motionEnded:withEvent: via UIWindow swizzle - Android: Uses SensorManager accelerometer (TYPE_ACCELEROMETER) Public API: - showFeedbackOnShake() / hideFeedbackOnShake() imperative APIs - feedbackIntegration({ enableShakeToReport: true }) declarative option Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5a14e8e commit 7400992

13 files changed

Lines changed: 593 additions & 10 deletions

packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ public class RNSentryModuleImpl {
122122

123123
private final @NotNull Runnable emitNewFrameEvent;
124124

125+
private static final String ON_SHAKE_EVENT = "rn_sentry_on_shake";
126+
private @Nullable RNSentryShakeDetector shakeDetector;
127+
private int shakeListenerCount = 0;
128+
125129
/** Max trace file size in bytes. */
126130
private long maxTraceFileSize = 5 * 1024 * 1024;
127131

@@ -192,16 +196,50 @@ public void crash() {
192196
}
193197

194198
public void addListener(String eventType) {
199+
if (ON_SHAKE_EVENT.equals(eventType)) {
200+
shakeListenerCount++;
201+
if (shakeListenerCount == 1) {
202+
startShakeDetection();
203+
}
204+
return;
205+
}
195206
// Is must be defined otherwise the generated interface from TS won't be
196207
// fulfilled
197208
logger.log(SentryLevel.ERROR, "addListener of NativeEventEmitter can't be used on Android!");
198209
}
199210

200211
public void removeListeners(double id) {
201-
// Is must be defined otherwise the generated interface from TS won't be
202-
// fulfilled
203-
logger.log(
204-
SentryLevel.ERROR, "removeListeners of NativeEventEmitter can't be used on Android!");
212+
shakeListenerCount = Math.max(0, shakeListenerCount - (int) id);
213+
if (shakeListenerCount == 0) {
214+
stopShakeDetection();
215+
}
216+
}
217+
218+
private void startShakeDetection() {
219+
if (shakeDetector != null) {
220+
return;
221+
}
222+
223+
final ReactApplicationContext context = getReactApplicationContext();
224+
shakeDetector = new RNSentryShakeDetector(logger);
225+
shakeDetector.start(
226+
context,
227+
() -> {
228+
final ReactApplicationContext ctx = getReactApplicationContext();
229+
if (ctx.hasActiveReactInstance()) {
230+
ctx.getJSModule(
231+
com.facebook.react.modules.core.DeviceEventManagerModule
232+
.RCTDeviceEventEmitter.class)
233+
.emit(ON_SHAKE_EVENT, null);
234+
}
235+
});
236+
}
237+
238+
private void stopShakeDetection() {
239+
if (shakeDetector != null) {
240+
shakeDetector.stop();
241+
shakeDetector = null;
242+
}
205243
}
206244

207245
public void fetchModules(Promise promise) {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package io.sentry.react;
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 class RNSentryShakeDetector 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 ShakeListener listener;
27+
private final @NotNull ILogger logger;
28+
29+
public interface ShakeListener {
30+
void onShake();
31+
}
32+
33+
public RNSentryShakeDetector(@NotNull ILogger logger) {
34+
this.logger = logger;
35+
}
36+
37+
public void start(@NotNull Context context, @NotNull ShakeListener 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+
45+
Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
46+
if (accelerometer == null) {
47+
logger.log(
48+
SentryLevel.WARNING, "Accelerometer sensor not available. Shake detection disabled.");
49+
return;
50+
}
51+
52+
sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI);
53+
logger.log(SentryLevel.DEBUG, "Shake detection started.");
54+
}
55+
56+
public void stop() {
57+
if (sensorManager != null) {
58+
sensorManager.unregisterListener(this);
59+
logger.log(SentryLevel.DEBUG, "Shake detection stopped.");
60+
}
61+
listener = null;
62+
sensorManager = null;
63+
}
64+
65+
@Override
66+
public void onSensorChanged(SensorEvent event) {
67+
if (event.sensor.getType() != Sensor.TYPE_ACCELEROMETER) {
68+
return;
69+
}
70+
71+
float gX = event.values[0] / SensorManager.GRAVITY_EARTH;
72+
float gY = event.values[1] / SensorManager.GRAVITY_EARTH;
73+
float gZ = event.values[2] / SensorManager.GRAVITY_EARTH;
74+
75+
double gForce = Math.sqrt(gX * gX + gY * gY + gZ * gZ);
76+
77+
if (gForce > SHAKE_THRESHOLD_GRAVITY) {
78+
long now = System.currentTimeMillis();
79+
if (now - lastShakeTimestamp > SHAKE_COOLDOWN_MS) {
80+
lastShakeTimestamp = now;
81+
if (listener != null) {
82+
listener.onShake();
83+
}
84+
}
85+
}
86+
}
87+
88+
@Override
89+
public void onAccuracyChanged(Sensor sensor, int accuracy) {
90+
// Not needed for shake detection
91+
}
92+
}

packages/core/ios/RNSentry.mm

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
#import "RNSentryDependencyContainer.h"
4141
#import "RNSentryEvents.h"
42+
#import "RNSentryShakeDetector.h"
4243

4344
#if SENTRY_TARGET_REPLAY_SUPPORTED
4445
# import "RNSentryReplay.h"
@@ -284,17 +285,33 @@ - (void)initFramesTracking
284285
- (void)startObserving
285286
{
286287
hasListeners = YES;
288+
[[NSNotificationCenter defaultCenter] addObserver:self
289+
selector:@selector(handleShakeDetected)
290+
name:RNSentryShakeDetectedNotification
291+
object:nil];
292+
[RNSentryShakeDetector enable];
287293
}
288294

289295
// Will be called when this module's last listener is removed, or on dealloc.
290296
- (void)stopObserving
291297
{
292298
hasListeners = NO;
299+
[RNSentryShakeDetector disable];
300+
[[NSNotificationCenter defaultCenter] removeObserver:self
301+
name:RNSentryShakeDetectedNotification
302+
object:nil];
303+
}
304+
305+
- (void)handleShakeDetected
306+
{
307+
if (hasListeners) {
308+
[self sendEventWithName:RNSentryOnShakeEvent body:@{}];
309+
}
293310
}
294311

295312
- (NSArray<NSString *> *)supportedEvents
296313
{
297-
return @[ RNSentryNewFrameEvent ];
314+
return @[ RNSentryNewFrameEvent, RNSentryOnShakeEvent ];
298315
}
299316

300317
RCT_EXPORT_METHOD(

packages/core/ios/RNSentryEvents.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
#import <Foundation/Foundation.h>
22

33
extern NSString *const RNSentryNewFrameEvent;
4+
extern NSString *const RNSentryOnShakeEvent;

packages/core/ios/RNSentryEvents.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
#import "RNSentryEvents.h"
22

33
NSString *const RNSentryNewFrameEvent = @"rn_sentry_new_frame";
4+
NSString *const RNSentryOnShakeEvent = @"rn_sentry_on_shake";
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#import <Foundation/Foundation.h>
2+
3+
NS_ASSUME_NONNULL_BEGIN
4+
5+
extern NSNotificationName const RNSentryShakeDetectedNotification;
6+
7+
/**
8+
* Detects shake gestures by swizzling UIWindow's motionEnded:withEvent: method.
9+
*
10+
* This approach uses UIKit's built-in shake detection via the responder chain,
11+
* which does NOT require NSMotionUsageDescription or any other permissions.
12+
* (NSMotionUsageDescription is only needed for Core Motion / CMMotionManager.)
13+
*/
14+
@interface RNSentryShakeDetector : NSObject
15+
16+
+ (void)enable;
17+
+ (void)disable;
18+
+ (BOOL)isEnabled;
19+
20+
@end
21+
22+
NS_ASSUME_NONNULL_END
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#import "RNSentryShakeDetector.h"
2+
3+
#if SENTRY_HAS_UIKIT
4+
5+
#import <UIKit/UIKit.h>
6+
#import <objc/runtime.h>
7+
8+
NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected";
9+
10+
static BOOL _shakeDetectionEnabled = NO;
11+
static IMP _originalMotionEndedIMP = NULL;
12+
static BOOL _swizzled = NO;
13+
14+
static void sentry_motionEnded(id self, SEL _cmd, UIEventSubtype motion, UIEvent *event)
15+
{
16+
if (_shakeDetectionEnabled && motion == UIEventSubtypeMotionShake) {
17+
[[NSNotificationCenter defaultCenter] postNotificationName:RNSentryShakeDetectedNotification
18+
object:nil];
19+
}
20+
21+
if (_originalMotionEndedIMP) {
22+
((void (*)(id, SEL, UIEventSubtype, UIEvent *))_originalMotionEndedIMP)(self, _cmd, motion,
23+
event);
24+
}
25+
}
26+
27+
@implementation RNSentryShakeDetector
28+
29+
+ (void)enable
30+
{
31+
@synchronized(self) {
32+
if (!_swizzled) {
33+
Method originalMethod =
34+
class_getInstanceMethod([UIWindow class], @selector(motionEnded:withEvent:));
35+
if (originalMethod) {
36+
_originalMotionEndedIMP = method_getImplementation(originalMethod);
37+
method_setImplementation(originalMethod, (IMP)sentry_motionEnded);
38+
_swizzled = YES;
39+
}
40+
}
41+
_shakeDetectionEnabled = YES;
42+
}
43+
}
44+
45+
+ (void)disable
46+
{
47+
@synchronized(self) {
48+
_shakeDetectionEnabled = NO;
49+
}
50+
}
51+
52+
+ (BOOL)isEnabled
53+
{
54+
return _shakeDetectionEnabled;
55+
}
56+
57+
@end
58+
59+
#else
60+
61+
NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected";
62+
63+
@implementation RNSentryShakeDetector
64+
65+
+ (void)enable
66+
{
67+
// No-op on non-UIKit platforms (macOS, tvOS)
68+
}
69+
70+
+ (void)disable
71+
{
72+
// No-op
73+
}
74+
75+
+ (BOOL)isEnabled
76+
{
77+
return NO;
78+
}
79+
80+
@end
81+
82+
#endif

packages/core/src/js/feedback/FeedbackWidgetManager.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { debug } from '@sentry/core';
22
import { isWeb } from '../utils/environment';
33
import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy';
4+
import { startShakeListener, stopShakeListener } from './ShakeToReportBug';
45

56
export const PULL_DOWN_CLOSE_THRESHOLD = 200;
67
export const SLIDE_ANIMATION_DURATION = 200;
@@ -132,4 +133,13 @@ const resetScreenshotButtonManager = (): void => {
132133
ScreenshotButtonManager.reset();
133134
};
134135

135-
export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };
136+
const showFeedbackOnShake = (): void => {
137+
lazyLoadAutoInjectFeedbackIntegration();
138+
startShakeListener();
139+
};
140+
141+
const hideFeedbackOnShake = (): void => {
142+
stopShakeListener();
143+
};
144+
145+
export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showFeedbackOnShake, hideFeedbackOnShake, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };

packages/core/src/js/feedback/FeedbackWidgetProvider.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import {
1515
ScreenshotButtonManager,
1616
SLIDE_ANIMATION_DURATION,
1717
} from './FeedbackWidgetManager';
18-
import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration';
18+
import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions, isShakeToReportEnabled } from './integration';
1919
import { ScreenshotButton } from './ScreenshotButton';
20+
import { startShakeListener, stopShakeListener } from './ShakeToReportBug';
2021
import { isModalSupported, isNativeDriverSupportedForColorAnimations } from './utils';
2122

2223
const useNativeDriverForColorAnimations = isNativeDriverSupportedForColorAnimations();
@@ -92,21 +93,27 @@ export class FeedbackWidgetProvider extends React.Component<FeedbackWidgetProvid
9293
}
9394

9495
/**
95-
* Add a listener to the theme change event.
96+
* Add a listener to the theme change event and start shake detection if configured.
9697
*/
9798
public componentDidMount(): void {
9899
this._themeListener = Appearance.addChangeListener(() => {
99100
this.forceUpdate();
100101
});
102+
103+
if (isShakeToReportEnabled()) {
104+
startShakeListener();
105+
}
101106
}
102107

103108
/**
104-
* Clean up the theme listener.
109+
* Clean up the theme listener and stop shake detection.
105110
*/
106111
public componentWillUnmount(): void {
107112
if (this._themeListener) {
108113
this._themeListener.remove();
109114
}
115+
116+
stopShakeListener();
110117
}
111118

112119
/**

0 commit comments

Comments
 (0)