Skip to content

Commit 9bc0c17

Browse files
antonisclaude
andcommitted
feat(feedback): Show feedback widget on device shake
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 9bc0c17

File tree

14 files changed

+591
-10
lines changed

14 files changed

+591
-10
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
77
<!-- prettier-ignore-end -->
88
9+
## Unreleased
10+
11+
### Features
12+
13+
- Show feedback widget on device shake ([#5729](https://github.com/getsentry/sentry-react-native/pull/5729))
14+
- Use `Sentry.showFeedbackOnShake()` / `Sentry.hideFeedbackOnShake()` or set `feedbackIntegration({ enableShakeToReport: true })`
15+
916
## 8.2.0
1017

1118
### Fixes

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.RCTDeviceEventEmitter
232+
.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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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
15+
sentry_motionEnded(id self, SEL _cmd, UIEventSubtype motion, UIEvent *event)
16+
{
17+
if (_shakeDetectionEnabled && motion == UIEventSubtypeMotionShake) {
18+
[[NSNotificationCenter defaultCenter] postNotificationName:RNSentryShakeDetectedNotification
19+
object:nil];
20+
}
21+
22+
if (_originalMotionEndedIMP) {
23+
((void (*)(id, SEL, UIEventSubtype, UIEvent *))_originalMotionEndedIMP)(
24+
self, _cmd, motion, event);
25+
}
26+
}
27+
28+
@implementation RNSentryShakeDetector
29+
30+
+ (void)enable
31+
{
32+
@synchronized(self) {
33+
if (!_swizzled) {
34+
Method originalMethod
35+
= class_getInstanceMethod([UIWindow class], @selector(motionEnded:withEvent:));
36+
if (originalMethod) {
37+
_originalMotionEndedIMP = method_getImplementation(originalMethod);
38+
method_setImplementation(originalMethod, (IMP)sentry_motionEnded);
39+
_swizzled = YES;
40+
}
41+
}
42+
_shakeDetectionEnabled = YES;
43+
}
44+
}
45+
46+
+ (void)disable
47+
{
48+
@synchronized(self) {
49+
_shakeDetectionEnabled = NO;
50+
}
51+
}
52+
53+
+ (BOOL)isEnabled
54+
{
55+
return _shakeDetectionEnabled;
56+
}
57+
58+
@end
59+
60+
#else
61+
62+
NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected";
63+
64+
@implementation RNSentryShakeDetector
65+
66+
+ (void)enable
67+
{
68+
// No-op on non-UIKit platforms (macOS, tvOS)
69+
}
70+
71+
+ (void)disable
72+
{
73+
// No-op
74+
}
75+
76+
+ (BOOL)isEnabled
77+
{
78+
return NO;
79+
}
80+
81+
@end
82+
83+
#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(showFeedbackWidget);
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: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import {
1313
FeedbackWidgetManager,
1414
PULL_DOWN_CLOSE_THRESHOLD,
1515
ScreenshotButtonManager,
16+
showFeedbackWidget,
1617
SLIDE_ANIMATION_DURATION,
1718
} from './FeedbackWidgetManager';
18-
import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration';
19+
import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions, isShakeToReportEnabled } from './integration';
1920
import { ScreenshotButton } from './ScreenshotButton';
21+
import { startShakeListener, stopShakeListener } from './ShakeToReportBug';
2022
import { isModalSupported, isNativeDriverSupportedForColorAnimations } from './utils';
2123

2224
const useNativeDriverForColorAnimations = isNativeDriverSupportedForColorAnimations();
@@ -92,21 +94,27 @@ export class FeedbackWidgetProvider extends React.Component<FeedbackWidgetProvid
9294
}
9395

9496
/**
95-
* Add a listener to the theme change event.
97+
* Add a listener to the theme change event and start shake detection if configured.
9698
*/
9799
public componentDidMount(): void {
98100
this._themeListener = Appearance.addChangeListener(() => {
99101
this.forceUpdate();
100102
});
103+
104+
if (isShakeToReportEnabled()) {
105+
startShakeListener(showFeedbackWidget);
106+
}
101107
}
102108

103109
/**
104-
* Clean up the theme listener.
110+
* Clean up the theme listener and stop shake detection.
105111
*/
106112
public componentWillUnmount(): void {
107113
if (this._themeListener) {
108114
this._themeListener.remove();
109115
}
116+
117+
stopShakeListener();
110118
}
111119

112120
/**

0 commit comments

Comments
 (0)