Skip to content

feat(feedback): implement shake gesture detection#7579

Open
antonis wants to merge 16 commits intomainfrom
antonis/feedback-shake
Open

feat(feedback): implement shake gesture detection#7579
antonis wants to merge 16 commits intomainfrom
antonis/feedback-shake

Conversation

@antonis
Copy link
Contributor

@antonis antonis commented Mar 3, 2026

📜 Description

Implements shake gesture detection for the user feedback form. When useShakeGesture is enabled in SentryUserFeedbackConfiguration, shaking the device opens the feedback form.

The implementation swizzles UIWindow.motionEnded:withEvent: using class_addMethod + method_setImplementation to safely intercept shake events without modifying UIResponder's inherited implementation. A cooldown of 1 second prevents duplicate triggers.

Note: this is exposed to be used on React Native too getsentry/sentry-react-native#5754

💡 Motivation and Context

The useShakeGesture property already existed in SentryUserFeedbackConfiguration but was never wired up. This PR implements the feature. The implementation is placed here (sentry-cocoa) rather than in each SDK separately so it can be reused by all SDKs that embed the feedback UI (React Native, Flutter, .NET MAUI, Unity).

💚 How did you test it?

  • Tested in a sample iOS app (release build) — shaking the device opens the feedback form
  • Debug builds: shake triggers the React Native dev menu (expected; RCTDevMenu also swizzles the same method)
  • Unit tests added for SentryShakeDetector

📝 Checklist

  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed. docs(apple): Add shake-to-report section and note iOS-only support sentry-docs#16791
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

Closes #7597

The useShakeGesture configuration property existed but was not implemented.
This adds SentryShakeDetector which swizzles UIWindow.motionEnded:withEvent:
to detect shake gestures and wires it into SentryUserFeedbackIntegrationDriver.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

  • (feedback) Implement shake gesture detection by antonis in #7579
  • (SPM) Add package traits for UI framework opt-out and macOS CLI sample by philprime in #7578
  • Use full flamegraph for metrickit app hangs by noahsmartin in #7185

Bug Fixes 🐛

  • Capture transactions during launch profiling window by philipphofmann in #7602

Internal Changes 🔧

Deps

  • Bump actions/setup-node from 6.2.0 to 6.3.0 by dependabot in #7627
  • Bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.23.0 to 2.23.2 by dependabot in #7628
  • Bump getsentry/craft from 2.23.0 to 2.23.2 by dependabot in #7629
  • Bump ruby/setup-ruby from 1.288.0 to 1.290.0 by dependabot in #7631

Samples

  • Restructure watchOS-Swift sample by philprime in #7614
  • Restructure visionOS-Swift sample by philprime in #7616
  • Restructure visionOS-SwiftUI-SPM sample by philprime in #7615

Other

  • (test-samples) Restructure SwiftUITestSample and SwiftUICrashTest by philprime in #7612

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 9044b8b

@codecov
Copy link

codecov bot commented Mar 3, 2026

Codecov Report

❌ Patch coverage is 78.04878% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.072%. Comparing base (313b966) to head (9044b8b).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...Feedback/SentryUserFeedbackIntegrationDriver.swift 41.666% 7 Missing ⚠️
...ntegrations/UserFeedback/SentryShakeDetector.swift 93.103% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@              Coverage Diff              @@
##              main     #7579       +/-   ##
=============================================
- Coverage   85.325%   85.072%   -0.254%     
=============================================
  Files          483       484        +1     
  Lines        28785     28826       +41     
  Branches     12506     12507        +1     
=============================================
- Hits         24561     24523       -38     
- Misses        4177      4256       +79     
  Partials        47        47               
Files with missing lines Coverage Δ
...ntegrations/UserFeedback/SentryShakeDetector.swift 93.103% <93.103%> (ø)
...Feedback/SentryUserFeedbackIntegrationDriver.swift 18.269% <41.666%> (+3.051%) ⬆️

... and 11 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 313b966...9044b8b. Read the comment docs.

antonis and others added 5 commits March 4, 2026 11:27
…it targets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…non-iOS

The @interface declaration was wrapped in #if TARGET_OS_IOS, causing a
compile error on macOS/tvOS/watchOS where the @implementation in the
#else block could not find the interface. The class is now declared on
all platforms with the methods documented as no-ops on non-iOS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use monotonic clock (CACurrentMediaTime) instead of NSDate,
atomic_bool for thread-safe enabled flag, bridged notification
constant, and unconditional cleanup in deinit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@antonis antonis marked this pull request as ready for review March 4, 2026 13:12
@antonis antonis requested a review from alwx March 4, 2026 13:15
antonis added 2 commits March 4, 2026 14:26
Track whether this driver instance enabled shake detection and
only call disable in deinit if it did. Prevents an old driver's
deallocation from disabling shake detection that a new driver
already re-enabled during SDK restart.
The init early-returns when there are no connected scenes (SwiftUI
apps). Move observeScreenshots and observeShakeGesture calls before
the return so shake detection works in SwiftUI apps.
static void
sentry_motionEnded(UIWindow *self, SEL _cmd, UIEventSubtype motion, UIEvent *event)
{
if (atomic_load_explicit(&_shakeDetectionEnabled, memory_order_acquire)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: does this run on every shake after calling disable?

Copy link
Contributor Author

@antonis antonis Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, after disable, sentry_motionEnded still runs on every motionEnded:withEvent:, but it's a single atomic bool check that returns immediately. The swizzle is intentionally never removed (un-swizzling is unsafe with multiple swizzlers).

antonis added a commit to getsentry/sentry-docs that referenced this pull request Mar 5, 2026
Add a "Shake to Report" section to the user feedback setup page and update
the `useShakeGesture` config description to note it's iOS-only.

Ref: getsentry/sentry-cocoa#7579

Co-Authored-By: Claude <noreply@anthropic.com>
* Swizzling is performed at most once regardless of how many times @c +enable is called.
* On non-iOS platforms (macOS, tvOS, watchOS), these methods are no-ops.
*/
@interface SentryShakeDetector : NSObject
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: Any reason in particular to make it in ObjC?

I don't see anything that couldn't be done in Swift here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really other than initially implementing on the RN side and moving it here 😓 I'll convert to draft and move this to Swift. thank you for the feedback 🙇

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored with 3c24378


#else

NSNotificationName const SentryShakeDetectedNotification = @"SentryShakeDetected";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: You can place this outside the #if TARGET_OS_IOS preprocessor directive to avoid declaring it twice

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tackled with the refactoring 3c24378

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

#if TARGET_OS_IOS
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: iPad also supports shake gestures too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tackled with the refactoring 3c24378

}

@objc func handleShakeGesture() {
guard !displayingForm else { return }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: on iPad there may be several windows, and I am not sure if all of them get the shake gesture or not.
Will this present the feedback form on all of them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tackled with the refactoring 3c24378

@antonis antonis marked this pull request as draft March 5, 2026 12:49
antonis added 3 commits March 5, 2026 14:35
Replaces the ObjC implementation with a Swift class while
maintaining full ObjC compatibility via @objc annotations.
The class name remains SentryShakeDetector in ObjC, preserving
compatibility with sentry-react-native.
antonis added 2 commits March 5, 2026 14:58
Make SentryShakeDetector an open class to match the ObjC API
and regenerate sdk_api.json with Xcode 16.4.
The class is a static utility not meant to be subclassed.
Regenerate API snapshot accordingly.
@antonis antonis marked this pull request as ready for review March 5, 2026 14:18
@antonis antonis requested a review from itaybre March 5, 2026 14:18
deinit {
customButton?.removeTarget(self, action: #selector(showForm(sender:)), for: .touchUpInside)
if didEnableShakeDetection {
SentryShakeDetector.disable()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: I think if it safe to always call disable

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Simplified with e47ec7b

antonis added 2 commits March 9, 2026 09:49
Always call SentryShakeDetector.disable() in deinit instead
of tracking whether this instance enabled it.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Shake in early-return path permanently blocks future shakes
    • showForm(screenshot:) now early-returns when no presenter exists, preventing displayingForm from being set true when presentation cannot occur.

Create PR

Or push these changes by commenting:

@cursor push 3846cd567d
Preview (3846cd567d)
diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift
--- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift
+++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift
@@ -120,11 +120,14 @@
 @available(iOSApplicationExtension, unavailable)
 private extension SentryUserFeedbackIntegrationDriver {
     func showForm(screenshot: UIImage?) {
+        guard let presenter else {
+            return
+        }
         let form = SentryUserFeedbackFormController(config: configuration, delegate: self, screenshot: screenshot)
         form.presentationController?.delegate = self
         widget?.rootVC.setWidget(visible: false, animated: configuration.animations)
         displayingForm = true
-        presenter?.present(form, animated: configuration.animations) {
+        presenter.present(form, animated: configuration.animations) {
             self.configuration.onFormOpen?()
         }
     }
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

*/
if UIApplication.shared.connectedScenes.isEmpty && UIApplication.shared.delegate == nil {
observeScreenshots()
observeShakeGesture()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shake in early-return path permanently blocks future shakes

Medium Severity

In the early-return path (SwiftUI apps with no connected scenes and no delegate), observeShakeGesture() registers for shake notifications but widget is nil and there's no customButton, so presenter is nil. When a shake triggers handleShakeGesture, it calls showForm(screenshot:) which unconditionally sets displayingForm = true before calling presenter?.present(...). Since presenter is nil, the form is never presented, so displayingForm is never reset to false. All subsequent shakes are permanently blocked by the guard !displayingForm check. Additionally, if showWidget() is later called, the widget window's hitTest sees displayingForm == true and intercepts all touches instead of passing through non-button taps.

Additional Locations (2)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a pre-existing edge case. The same behavior applies to the screenshot trigger on this path.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@antonis Do you think we should fix it for both cases? at least for this PR we do it correctly?

@antonis antonis requested a review from itaybre March 9, 2026 09:19
/// Enables shake gesture detection. On iOS/iPadOS, swizzles `UIWindow.motionEnded(_:with:)`
/// the first time it is called, and from then on posts `.SentryShakeDetected`
/// whenever a shake is detected. No-op on non-iOS platforms.
public static func enable() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: Please consider adding more SDK debug logging to all the code branches in this method, as swizzling can be tricky and have side-effects

*/
if UIApplication.shared.connectedScenes.isEmpty && UIApplication.shared.delegate == nil {
observeScreenshots()
observeShakeGesture()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@antonis Do you think we should fix it for both cases? at least for this PR we do it correctly?

Comment on lines +158 to +161
if configuration.useShakeGesture {
SentryShakeDetector.enable()
NotificationCenter.default.addObserver(self, selector: #selector(handleShakeGesture), name: .SentryShakeDetected, object: nil)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Debug logs could be valuable

Suggested change
if configuration.useShakeGesture {
SentryShakeDetector.enable()
NotificationCenter.default.addObserver(self, selector: #selector(handleShakeGesture), name: .SentryShakeDetected, object: nil)
}
guard configuration.useShakeGesture else {
SentrySDKLog.debug("Received shake gesture while disabled)
}
SentryShakeDetector.enable()
NotificationCenter.default.addObserver(self, selector: #selector(handleShakeGesture), name: .SentryShakeDetected, object: nil)

/// Disables shake gesture detection. Does not un-swizzle `UIWindow`; it only suppresses
/// the notification so the overhead is negligible. No-op on non-iOS platforms.
public static func disable() {
enabled = false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: Can we implement a unswizzling logic here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(feedback): implement shake gesture detection

4 participants