Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9bc0c17
feat(feedback): Show feedback widget on device shake
antonis Feb 26, 2026
c5d398e
Add to sample app
antonis Feb 26, 2026
3cb51f5
Merge branch 'main' into antonis/feedback-shake
antonis Feb 26, 2026
3e37fe4
Merge branch 'main' into antonis/feedback-shake
antonis Feb 27, 2026
5271319
Merge branch 'main' into antonis/feedback-shake
antonis Feb 27, 2026
96664e8
fix(ios): fix shake detection in iOS simulator by swizzling UIApplica…
antonis Feb 27, 2026
58b9f2b
Merge branch 'main' into antonis/feedback-shake
antonis Feb 27, 2026
a027843
fix(ios): switch shake detection to UIWindow.motionEnded:withEvent: s…
antonis Feb 27, 2026
6054e19
test(sample): add FeedbackWidgetProvider to React Native sample app
antonis Feb 27, 2026
6d13f6a
Revert "test(sample): add FeedbackWidgetProvider to React Native samp…
antonis Feb 27, 2026
bf89f61
fix(ios): explicitly enable shake detection in addListener like Android
antonis Feb 27, 2026
128bd3e
debug(ios): add NSLog tracing to shake detection chain
antonis Feb 27, 2026
db0e082
Merge branch 'main' into antonis/feedback-shake
antonis Mar 2, 2026
ea484a2
Merge branch 'main' into antonis/feedback-shake
antonis Mar 2, 2026
5cb9033
fix(ios): add @import Sentry so SENTRY_HAS_UIKIT is defined
antonis Mar 2, 2026
023bab7
fix(ios): use TARGET_OS_IOS instead of SENTRY_HAS_UIKIT
antonis Mar 2, 2026
3e473ae
Merge branch 'main' into antonis/feedback-shake
antonis Mar 3, 2026
e844405
fix(ios): use explicit enableShakeDetection method for iOS shake-to-r…
antonis Mar 3, 2026
d725799
fix(ios): fix shake detection crash and swizzle safety
antonis Mar 3, 2026
5c048e0
Merge branch 'main' into antonis/feedback-shake
antonis Mar 3, 2026
27867aa
refactor: replace RNSentryShakeDetector with SentryShakeDetector from…
antonis Mar 3, 2026
daf949d
Merge branch 'main' into antonis/feedback-shake-native
antonis Mar 4, 2026
59d3752
Merge branch 'main' into antonis/feedback-shake-native
antonis Mar 5, 2026
725ceaf
fix(ios): adapt to SentryShakeDetector refactored to Swift in sentry-…
antonis Mar 5, 2026
04c1bd4
Merge branch 'main' into antonis/feedback-shake-native
antonis Mar 19, 2026
8c44ee5
Reverse unneeded objc changes
antonis Mar 19, 2026
fcdc83c
Reverse obj change
antonis Mar 19, 2026
cfcb5c7
Rename methods for clarity
antonis Mar 19, 2026
33a5918
fix(android,ios): Fix shake detection lifecycle bugs
antonis Mar 19, 2026
a7b836f
Update changelog
antonis Mar 19, 2026
259687a
fix(ios): Use dedicated flag for shake event emission
antonis Mar 19, 2026
6a1db5e
fix(feedback): Guard against shake listener crashes and asymmetric li…
antonis Mar 19, 2026
f88f95c
fix(feedback): Remove subscription on shake listener start failure
antonis Mar 19, 2026
ca15aff
fix(android): Wrap shake detection in try/catch to prevent host app c…
antonis Mar 19, 2026
bc93b34
fix(feedback): Prevent provider from stopping externally-owned shake …
antonis Mar 19, 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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
- Add `expoUpdatesListenerIntegration` that records breadcrumbs for Expo Updates lifecycle events ([#5795](https://github.com/getsentry/sentry-react-native/pull/5795))
- Tracks update checks, downloads, errors, rollbacks, and restarts as `expo.updates` breadcrumbs
- Enabled by default in Expo apps (requires `expo-updates` to be installed)
-
- Show feedback widget on device shake ([#5754](https://github.com/getsentry/sentry-react-native/pull/5754))
- Use `Sentry.enableFeedbackOnShake()` / `Sentry.disableFeedbackOnShake()` or set `feedbackIntegration({ enableShakeToReport: true })`

### Fixes

- Fix native frames measurements being dropped due to race condition ([#5813](https://github.com/getsentry/sentry-react-native/pull/5813))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import io.sentry.android.core.InternalSentrySdk;
import io.sentry.android.core.SentryAndroidDateProvider;
import io.sentry.android.core.SentryAndroidOptions;
import io.sentry.android.core.SentryShakeDetector;
import io.sentry.android.core.ViewHierarchyEventProcessor;
import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader;
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
Expand Down Expand Up @@ -122,6 +123,9 @@ public class RNSentryModuleImpl {

private final @NotNull Runnable emitNewFrameEvent;

private static final String ON_SHAKE_EVENT = "rn_sentry_on_shake";
private @Nullable SentryShakeDetector shakeDetector;

/** Max trace file size in bytes. */
private long maxTraceFileSize = 5 * 1024 * 1024;

Expand Down Expand Up @@ -208,10 +212,54 @@ public void addListener(String eventType) {
}

public void removeListeners(double id) {
// Is must be defined otherwise the generated interface from TS won't be
// fulfilled
logger.log(
SentryLevel.ERROR, "removeListeners of NativeEventEmitter can't be used on Android!");
// removeListeners does not carry event-type information, so it cannot be used
// to track shake listeners selectively. Shake detection is managed exclusively
// via enableShakeDetection / disableShakeDetection.
}

private void startShakeDetection() {
if (shakeDetector != null) {
return;
}

try { // NOPMD - We don't want to crash in any case
final ReactApplicationContext context = getReactApplicationContext();
shakeDetector = new SentryShakeDetector(logger);
shakeDetector.start(
context,
() -> {
final ReactApplicationContext ctx = getReactApplicationContext();
if (ctx.hasActiveReactInstance()) {
ctx.getJSModule(
com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
.class)
.emit(ON_SHAKE_EVENT, null);
}
});
} catch (Throwable e) { // NOPMD - We don't want to crash in any case
logger.log(SentryLevel.WARNING, "Failed to start shake detection.", e);
shakeDetector = null;
}
}

private void stopShakeDetection() {
try { // NOPMD - We don't want to crash in any case
if (shakeDetector != null) {
shakeDetector.stop();
shakeDetector = null;
}
} catch (Throwable e) { // NOPMD - We don't want to crash in any case
logger.log(SentryLevel.WARNING, "Failed to stop shake detection.", e);
shakeDetector = null;
}
}

public void enableShakeDetection() {
startShakeDetection();
}

public void disableShakeDetection() {
stopShakeDetection();
}

public void fetchModules(Promise promise) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,14 @@ public void popTimeToDisplayFor(String key, Promise promise) {
public boolean setActiveSpanId(String spanId) {
return this.impl.setActiveSpanId(spanId);
}

@Override
public void enableShakeDetection() {
this.impl.enableShakeDetection();
}

@Override
public void disableShakeDetection() {
this.impl.disableShakeDetection();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,14 @@ public void popTimeToDisplayFor(String key, Promise promise) {
public boolean setActiveSpanId(String spanId) {
return this.impl.setActiveSpanId(spanId);
}

@ReactMethod
public void enableShakeDetection() {
this.impl.enableShakeDetection();
}

@ReactMethod
public void disableShakeDetection() {
this.impl.disableShakeDetection();
}
}
48 changes: 47 additions & 1 deletion packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@

@implementation RNSentry {
bool hasListeners;
bool _shakeDetectionEnabled;
RNSentryTimeToDisplay *_timeToDisplay;
NSArray<NSString *> *_ignoreErrorPatternsStr;
NSArray<NSRegularExpression *> *_ignoreErrorPatternsRegex;
Expand Down Expand Up @@ -295,9 +296,54 @@ - (void)stopObserving
[[RNSentryNativeLogsForwarder shared] stopForwarding];
}

- (void)handleShakeDetected
{
if (_shakeDetectionEnabled) {
[self sendEventWithName:RNSentryOnShakeEvent body:@{}];
}
}

// SentryShakeDetector is a Swift class; its notification name and methods are accessed
// via the raw string / NSClassFromString to avoid requiring @import Sentry in this .mm file.
static NSNotificationName const RNSentryShakeNotification = @"SentryShakeDetected";

RCT_EXPORT_METHOD(enableShakeDetection)
{
[[NSNotificationCenter defaultCenter] removeObserver:self
name:RNSentryShakeNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleShakeDetected)
name:RNSentryShakeNotification
object:nil];
Class shakeDetector = NSClassFromString(@"SentryShakeDetector");
if (shakeDetector) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[shakeDetector performSelector:@selector(enable)];
#pragma clang diagnostic pop
}
_shakeDetectionEnabled = YES;
}

RCT_EXPORT_METHOD(disableShakeDetection)
{
_shakeDetectionEnabled = NO;
Class shakeDetector = NSClassFromString(@"SentryShakeDetector");
if (shakeDetector) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[shakeDetector performSelector:@selector(disable)];
#pragma clang diagnostic pop
}
[[NSNotificationCenter defaultCenter] removeObserver:self
name:RNSentryShakeNotification
object:nil];
}

- (NSArray<NSString *> *)supportedEvents
{
return @[ RNSentryNewFrameEvent, RNSentryNativeLogEvent ];
return @[ RNSentryNewFrameEvent, RNSentryNativeLogEvent, RNSentryOnShakeEvent ];
}

RCT_EXPORT_METHOD(
Expand Down
1 change: 1 addition & 0 deletions packages/core/ios/RNSentryEvents.h
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#import <Foundation/Foundation.h>

extern NSString *const RNSentryNewFrameEvent;
extern NSString *const RNSentryOnShakeEvent;
extern NSString *const RNSentryNativeLogEvent;
1 change: 1 addition & 0 deletions packages/core/ios/RNSentryEvents.m
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#import "RNSentryEvents.h"

NSString *const RNSentryNewFrameEvent = @"rn_sentry_new_frame";
NSString *const RNSentryOnShakeEvent = @"rn_sentry_on_shake";
NSString *const RNSentryNativeLogEvent = @"SentryNativeLog";
2 changes: 2 additions & 0 deletions packages/core/src/js/NativeRNSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface Spec extends TurboModule {
popTimeToDisplayFor(key: string): Promise<number | undefined | null>;
setActiveSpanId(spanId: string): boolean;
encodeToBase64(data: number[]): Promise<string | undefined | null>;
enableShakeDetection(): void;
disableShakeDetection(): void;
}

export type NativeStackFrame = {
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/js/feedback/FeedbackWidgetManager.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { debug } from '@sentry/core';
import { isWeb } from '../utils/environment';
import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy';
import { startShakeListener, stopShakeListener } from './ShakeToReportBug';

export const PULL_DOWN_CLOSE_THRESHOLD = 200;
export const SLIDE_ANIMATION_DURATION = 200;
Expand Down Expand Up @@ -132,4 +133,13 @@ const resetScreenshotButtonManager = (): void => {
ScreenshotButtonManager.reset();
};

export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };
const enableFeedbackOnShake = (): void => {
lazyLoadAutoInjectFeedbackIntegration();
startShakeListener(showFeedbackWidget);
};

const disableFeedbackOnShake = (): void => {
stopShakeListener();
};

export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, enableFeedbackOnShake, disableFeedbackOnShake, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };
17 changes: 14 additions & 3 deletions packages/core/src/js/feedback/FeedbackWidgetProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import {
FeedbackWidgetManager,
PULL_DOWN_CLOSE_THRESHOLD,
ScreenshotButtonManager,
showFeedbackWidget,
SLIDE_ANIMATION_DURATION,
} from './FeedbackWidgetManager';
import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration';
import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions, isShakeToReportEnabled } from './integration';
import { ScreenshotButton } from './ScreenshotButton';
import { startShakeListener, stopShakeListener } from './ShakeToReportBug';
import { isModalSupported, isNativeDriverSupportedForColorAnimations } from './utils';

const useNativeDriverForColorAnimations = isNativeDriverSupportedForColorAnimations();
Expand Down Expand Up @@ -51,6 +53,7 @@ export class FeedbackWidgetProvider extends React.Component<FeedbackWidgetProvid
};

private _themeListener: NativeEventSubscription | undefined;
private _startedShakeListener: boolean = false;

private _panResponder = PanResponder.create({
onStartShouldSetPanResponder: (_, gestureState) => {
Expand Down Expand Up @@ -92,21 +95,29 @@ export class FeedbackWidgetProvider extends React.Component<FeedbackWidgetProvid
}

/**
* Add a listener to the theme change event.
* Add a listener to the theme change event and start shake detection if configured.
*/
public componentDidMount(): void {
this._themeListener = Appearance.addChangeListener(() => {
this.forceUpdate();
});

if (isShakeToReportEnabled()) {
this._startedShakeListener = startShakeListener(showFeedbackWidget);
}
}

/**
* Clean up the theme listener.
* Clean up the theme listener and stop shake detection.
*/
public componentWillUnmount(): void {
if (this._themeListener) {
this._themeListener.remove();
}

if (this._startedShakeListener) {
stopShakeListener();
}
}

/**
Expand Down
87 changes: 87 additions & 0 deletions packages/core/src/js/feedback/ShakeToReportBug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { debug } from '@sentry/core';
import type { EmitterSubscription, NativeModule } from 'react-native';
import { NativeEventEmitter } from 'react-native';
import { isWeb } from '../utils/environment';
import { getRNSentryModule } from '../wrapper';

export const OnShakeEventName = 'rn_sentry_on_shake';

let _shakeSubscription: EmitterSubscription | null = null;

/**
* Creates a NativeEventEmitter for the given module.
* Can be overridden in tests via the `createEmitter` parameter.
*/
type EmitterFactory = (nativeModule: NativeModule) => NativeEventEmitter;

const defaultEmitterFactory: EmitterFactory = nativeModule => new NativeEventEmitter(nativeModule);

/**
* Starts listening for device shake events and invokes the provided callback when a shake is detected.
*
* This starts native shake detection:
* - iOS: Uses UIKit's motion event detection (no permissions required)
* - Android: Uses the accelerometer sensor (no permissions required)
*/
export function startShakeListener(onShake: () => void, createEmitter: EmitterFactory = defaultEmitterFactory): boolean {
if (_shakeSubscription) {
return false;
}

if (isWeb()) {
debug.warn('Shake detection is not supported on Web.');
return false;
}

const nativeModule = getRNSentryModule() as NativeModule | undefined;
if (!nativeModule) {
debug.warn('Native module is not available. Shake detection will not work.');
return false;
}

try {
const emitter = createEmitter(nativeModule);
_shakeSubscription = emitter.addListener(OnShakeEventName, () => {
onShake();
});

// Explicitly enable native shake detection. On iOS with New Architecture (TurboModules),
// NativeEventEmitter.addListener does not dispatch to native addListener:, so the
// native shake listener would never start without this explicit call.
const module = nativeModule as { enableShakeDetection?: () => void };
if (module.enableShakeDetection) {
module.enableShakeDetection();
} else {
debug.warn('enableShakeDetection is not available on the native module.');
}
return true;
} catch (e) {
debug.warn('Failed to start shake listener:', e);
if (_shakeSubscription) {
_shakeSubscription.remove();
_shakeSubscription = null;
}
return false;
}
}

/**
* Stops listening for device shake events.
*/
export function stopShakeListener(): void {
if (_shakeSubscription) {
_shakeSubscription.remove();
_shakeSubscription = null;

const nativeModule = getRNSentryModule() as { disableShakeDetection?: () => void } | undefined;
nativeModule?.disableShakeDetection?.();
}
}

/**
* Returns whether the shake listener is currently active.
* Exported for testing purposes.
*/
export function isShakeListenerActive(): boolean {
return _shakeSubscription !== null;
}
Loading
Loading