Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

## Unreleased

### Features

- Show feedback widget on device shake ([#5729](https://github.com/getsentry/sentry-react-native/pull/5729))
- Use `Sentry.showFeedbackOnShake()` / `Sentry.hideFeedbackOnShake()` or set `feedbackIntegration({ enableShakeToReport: true })`

### Dependencies

- Bump JavaScript SDK from v10.39.0 to v10.40.0 ([#5715](https://github.com/getsentry/sentry-react-native/pull/5715))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ public class RNSentryModuleImpl {

private final @NotNull Runnable emitNewFrameEvent;

private static final String ON_SHAKE_EVENT = "rn_sentry_on_shake";
private @Nullable RNSentryShakeDetector shakeDetector;
private int shakeListenerCount = 0;

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

Expand Down Expand Up @@ -192,16 +196,50 @@ public void crash() {
}

public void addListener(String eventType) {
if (ON_SHAKE_EVENT.equals(eventType)) {
shakeListenerCount++;
if (shakeListenerCount == 1) {
startShakeDetection();
}
return;
}
// Is must be defined otherwise the generated interface from TS won't be
// fulfilled
logger.log(SentryLevel.ERROR, "addListener of NativeEventEmitter can't be used on Android!");
}

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!");
shakeListenerCount = Math.max(0, shakeListenerCount - (int) id);
if (shakeListenerCount == 0) {
stopShakeDetection();
}
}

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

final ReactApplicationContext context = getReactApplicationContext();
shakeDetector = new RNSentryShakeDetector(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);
}
});
}

private void stopShakeDetection() {
if (shakeDetector != null) {
shakeDetector.stop();
shakeDetector = null;
}
}

public void fetchModules(Promise promise) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package io.sentry.react;

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import io.sentry.ILogger;
import io.sentry.SentryLevel;
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.
*/
public class RNSentryShakeDetector implements SensorEventListener {

private static final float SHAKE_THRESHOLD_GRAVITY = 2.7f;
private static final int SHAKE_COOLDOWN_MS = 1000;

private @Nullable SensorManager sensorManager;
private long lastShakeTimestamp = 0;
private @Nullable ShakeListener listener;
private final @NotNull ILogger logger;

public interface ShakeListener {
void onShake();
}

public RNSentryShakeDetector(@NotNull ILogger logger) {
this.logger = logger;
}

public void start(@NotNull Context context, @NotNull ShakeListener shakeListener) {
this.listener = shakeListener;
sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
if (sensorManager == null) {
logger.log(SentryLevel.WARNING, "SensorManager is not available. Shake detection disabled.");
return;
}

Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
if (accelerometer == null) {
logger.log(
SentryLevel.WARNING, "Accelerometer sensor not available. Shake detection disabled.");
return;
}

sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI);
logger.log(SentryLevel.DEBUG, "Shake detection started.");
}

public void stop() {
if (sensorManager != null) {
sensorManager.unregisterListener(this);
logger.log(SentryLevel.DEBUG, "Shake detection stopped.");
}
listener = null;
sensorManager = null;
}

@Override
public void onSensorChanged(SensorEvent event) {
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) {
long now = System.currentTimeMillis();
if (now - lastShakeTimestamp > SHAKE_COOLDOWN_MS) {
lastShakeTimestamp = now;
if (listener != null) {
listener.onShake();
}
}
}
}

@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// Not needed for shake detection
}
}
19 changes: 18 additions & 1 deletion packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

#import "RNSentryDependencyContainer.h"
#import "RNSentryEvents.h"
#import "RNSentryShakeDetector.h"

#if SENTRY_TARGET_REPLAY_SUPPORTED
# import "RNSentryReplay.h"
Expand Down Expand Up @@ -284,17 +285,33 @@ - (void)initFramesTracking
- (void)startObserving
{
hasListeners = YES;
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleShakeDetected)
name:RNSentryShakeDetectedNotification
object:nil];
[RNSentryShakeDetector enable];
}

// Will be called when this module's last listener is removed, or on dealloc.
- (void)stopObserving
{
hasListeners = NO;
[RNSentryShakeDetector disable];
[[NSNotificationCenter defaultCenter] removeObserver:self
name:RNSentryShakeDetectedNotification
object:nil];
}

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

- (NSArray<NSString *> *)supportedEvents
{
return @[ RNSentryNewFrameEvent ];
return @[ RNSentryNewFrameEvent, 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,3 +1,4 @@
#import <Foundation/Foundation.h>

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

NSString *const RNSentryNewFrameEvent = @"rn_sentry_new_frame";
NSString *const RNSentryOnShakeEvent = @"rn_sentry_on_shake";
22 changes: 22 additions & 0 deletions packages/core/ios/RNSentryShakeDetector.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

extern NSNotificationName const RNSentryShakeDetectedNotification;

/**
* Detects shake gestures by swizzling UIWindow's motionEnded:withEvent: method.
*
* This approach uses UIKit's built-in shake detection via the responder chain,
* which does NOT require NSMotionUsageDescription or any other permissions.
* (NSMotionUsageDescription is only needed for Core Motion / CMMotionManager.)
*/
@interface RNSentryShakeDetector : NSObject

+ (void)enable;
+ (void)disable;
+ (BOOL)isEnabled;

@end

NS_ASSUME_NONNULL_END
83 changes: 83 additions & 0 deletions packages/core/ios/RNSentryShakeDetector.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#import "RNSentryShakeDetector.h"

#if SENTRY_HAS_UIKIT

# import <UIKit/UIKit.h>
# import <objc/runtime.h>

NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected";

static BOOL _shakeDetectionEnabled = NO;
static IMP _originalMotionEndedIMP = NULL;
static BOOL _swizzled = NO;

static void
sentry_motionEnded(id self, SEL _cmd, UIEventSubtype motion, UIEvent *event)
{
if (_shakeDetectionEnabled && motion == UIEventSubtypeMotionShake) {
[[NSNotificationCenter defaultCenter] postNotificationName:RNSentryShakeDetectedNotification
object:nil];
}

if (_originalMotionEndedIMP) {
((void (*)(id, SEL, UIEventSubtype, UIEvent *))_originalMotionEndedIMP)(
self, _cmd, motion, event);
}
}

@implementation RNSentryShakeDetector

+ (void)enable
{
@synchronized(self) {
if (!_swizzled) {
Method originalMethod
= class_getInstanceMethod([UIWindow class], @selector(motionEnded:withEvent:));
if (originalMethod) {
_originalMotionEndedIMP = method_getImplementation(originalMethod);
method_setImplementation(originalMethod, (IMP)sentry_motionEnded);
_swizzled = YES;
}
}
_shakeDetectionEnabled = YES;
}
}

+ (void)disable
{
@synchronized(self) {
_shakeDetectionEnabled = NO;
}
}

+ (BOOL)isEnabled
{
return _shakeDetectionEnabled;
}

@end

#else

NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected";

@implementation RNSentryShakeDetector

+ (void)enable
{
// No-op on non-UIKit platforms (macOS, tvOS)
}

+ (void)disable
{
// No-op
}

+ (BOOL)isEnabled
{
return NO;
}

@end

#endif
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 showFeedbackOnShake = (): void => {
lazyLoadAutoInjectFeedbackIntegration();
startShakeListener(showFeedbackWidget);
};

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

export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showFeedbackOnShake, hideFeedbackOnShake, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };
14 changes: 11 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 @@ -92,21 +94,27 @@ 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()) {
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();
}

stopShakeListener();
}

/**
Expand Down
Loading
Loading