Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
971f5a5
feat(feedback): implement shake gesture detection for user feedback form
antonis Mar 3, 2026
fb973ee
fix(feedback): guard UIKit import inside TARGET_OS_IOS to fix non-UIK…
antonis Mar 4, 2026
cc676fe
fix(feedback): expose SentryShakeDetector on all platforms; no-op on …
antonis Mar 4, 2026
c83f0f0
Merge branch 'main' into antonis/feedback-shake
antonis Mar 4, 2026
3575377
impr(feedback): harden shake detection
antonis Mar 4, 2026
67a100d
chore: update public API snapshot for SentryShakeDetector
antonis Mar 4, 2026
bcda268
test(feedback): add unit tests for SentryShakeDetector
antonis Mar 4, 2026
27ff27e
fix(feedback): prevent deinit race on shake detector
antonis Mar 4, 2026
3e4b593
fix(feedback): set up shake detection before SwiftUI early return
antonis Mar 4, 2026
3c24378
ref(feedback): rewrite SentryShakeDetector in Swift
antonis Mar 5, 2026
a447914
Merge branch 'main' into antonis/feedback-shake
antonis Mar 5, 2026
0f86f9f
Update changelog
antonis Mar 5, 2026
6a17a68
chore: update API snapshot for Swift shake detector
antonis Mar 5, 2026
f0cd795
ref(feedback): make SentryShakeDetector final
antonis Mar 5, 2026
e47ec7b
ref(feedback): simplify deinit shake cleanup
antonis Mar 9, 2026
9044b8b
Merge branch 'main' into antonis/feedback-shake
antonis Mar 9, 2026
36137d7
Merge branch 'main' into antonis/feedback-shake
antonis Mar 9, 2026
81be891
impr(feedback): add debug logging to shake detection
antonis Mar 9, 2026
1ebfe2f
fix(feedback): prevent displayingForm stuck when presenter is nil
antonis Mar 9, 2026
725eba9
Emit a warning log when Shake detector could not add motionEnded to U…
antonis Mar 9, 2026
f5eacb9
Merge branch 'main' into antonis/feedback-shake
antonis Mar 9, 2026
1a04fd8
fix(feedback): remove screenshot observer from SwiftUI early-return
antonis Mar 9, 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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Unreleased

### Features

- Show feedback form on device shake (#7579)
- Enable via `config.useShakeGesture = true` in `SentryUserFeedbackConfiguration`
- Uses UIKit's built-in shake detection β€” no special permissions required

## 9.6.0

### Features
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Foundation
#if os(iOS) && !SENTRY_NO_UI_FRAMEWORK
import ObjectiveC
import QuartzCore
import UIKit
#endif

/// Extension providing the Sentry shake detection notification name.
public extension NSNotification.Name {
/// Notification posted when the device detects a shake gesture on iOS/iPadOS.
/// On non-iOS platforms this notification is never posted.
static let SentryShakeDetected = NSNotification.Name("SentryShakeDetected")
}

/// Detects shake gestures by swizzling `UIWindow.motionEnded(_:with:)` on iOS/iPadOS.
/// When a shake gesture is detected, posts a `.SentryShakeDetected` notification.
///
/// Use `enable()` to start detection and `disable()` to stop it.
/// Swizzling is performed at most once regardless of how many times `enable()` is called.
/// On non-iOS platforms (macOS, tvOS, watchOS), these methods are no-ops.
@objc(SentryShakeDetector)
@objcMembers
public final class SentryShakeDetector: NSObject {

/// The notification name posted on shake, exposed for ObjC consumers.
/// In Swift, prefer using `.SentryShakeDetected` on `NSNotification.Name` directly.
@objc public static let shakeDetectedNotification = NSNotification.Name.SentryShakeDetected

#if os(iOS) && !SENTRY_NO_UI_FRAMEWORK
// Both motionEnded (main thread) and enable/disable (main thread in practice)
// access this flag. UIKit's motionEnded is always dispatched on the main thread,
// and the SDK calls enable/disable from main-thread integration lifecycle.
private static var enabled = false

private static var swizzled = false
private static var originalIMP: IMP?
private static var lastShakeTimestamp: CFTimeInterval = 0
private static let cooldownSeconds: CFTimeInterval = 1.0
private static let lock = NSLock()

/// 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() {
lock.lock()
defer { lock.unlock() }

if !swizzled {
let windowClass: AnyClass = UIWindow.self
let selector = #selector(UIResponder.motionEnded(_:with:))

guard let inheritedMethod = class_getInstanceMethod(windowClass, selector) else {
return
}

let inheritedIMP = method_getImplementation(inheritedMethod)
let types = method_getTypeEncoding(inheritedMethod)
class_addMethod(windowClass, selector, inheritedIMP, types)

guard let ownMethod = class_getInstanceMethod(windowClass, selector) else {
return
}

let replacementIMP = imp_implementationWithBlock({ (self: UIWindow, motion: UIEvent.EventSubtype, event: UIEvent?) in
if SentryShakeDetector.enabled && motion == .motionShake {
let now = CACurrentMediaTime()
if now - SentryShakeDetector.lastShakeTimestamp > SentryShakeDetector.cooldownSeconds {
SentryShakeDetector.lastShakeTimestamp = now
NotificationCenter.default.post(name: .SentryShakeDetected, object: nil)
}
}

if let original = SentryShakeDetector.originalIMP {
typealias MotionEndedFunc = @convention(c) (Any, Selector, UIEvent.EventSubtype, UIEvent?) -> Void
let originalFunc = unsafeBitCast(original, to: MotionEndedFunc.self)
originalFunc(self, selector, motion, event)
}
} as @convention(block) (UIWindow, UIEvent.EventSubtype, UIEvent?) -> Void)

originalIMP = method_setImplementation(ownMethod, replacementIMP)
swizzled = true
}

enabled = true
}

/// 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?

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 explored implementing unswizzling in disable() by restoring the original IMP like:

  public static func disable() {
      lock.lock()
      defer { lock.unlock() }
      enabled = false
      if swizzled, let original = originalIMP {
          let windowClass: AnyClass = UIWindow.self
          let selector = #selector(UIResponder.motionEnded(_:with:))
          if let method = class_getInstanceMethod(windowClass, selector) {
              method_setImplementation(method, original)
          }
          originalIMP = nil
          swizzled = false
      }
  }

I think there is a risk of breakage if another library swizzles motionEnded after us. Wdyt?

Copy link
Member

Choose a reason for hiding this comment

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

I agree that this could break multi-nested swizzling. Maybe we should defer this and create a proper solution for all swizzling in the future

}
#else
/// No-op on non-iOS platforms.
@objc public static func enable() {}
/// No-op on non-iOS platforms.
@objc public static func disable() {}
#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class SentryUserFeedbackIntegrationDriver: NSObject {
fileprivate let callback: (SentryFeedback) -> Void
let screenshotSource: SentryScreenshotSource
weak var customButton: UIButton?
private var didEnableShakeDetection = false

init(configuration: SentryUserFeedbackConfiguration, screenshotSource: SentryScreenshotSource, callback: @escaping (SentryFeedback) -> Void) {
self.configuration = configuration
Expand Down Expand Up @@ -44,6 +45,8 @@ final class SentryUserFeedbackIntegrationDriver: NSObject {
* At the time this integration is being installed, if there is no UIApplicationDelegate and no connected UIScene, it is very likely we are in a SwiftUI app, but it's possible we could instead be in a UIKit app that has some nonstandard launch procedure or doesn't call SentrySDK.start in a place we expect/recommend, in which case they will need to manually display the widget when they're ready by calling SentrySDK.feedback.showWidget.
*/
if UIApplication.shared.connectedScenes.isEmpty && UIApplication.shared.delegate == nil {
observeScreenshots()
observeShakeGesture()
return
}

Expand All @@ -53,10 +56,15 @@ final class SentryUserFeedbackIntegrationDriver: NSObject {
}

observeScreenshots()
observeShakeGesture()
}

deinit {
customButton?.removeTarget(self, action: #selector(showForm(sender:)), for: .touchUpInside)
Comment on lines 58 to 61
Copy link

Choose a reason for hiding this comment

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

Bug: The integration's deinitializer unconditionally disables the shared, global SentryShakeDetector, which can break other consumers. The disable method also has a race condition.
Severity: HIGH

Suggested Fix

Implement reference counting for the SentryShakeDetector so that disable() is only called when the last consumer is deinitialized. Alternatively, avoid calling disable() from deinit altogether. The disable() method should also acquire the existing lock before modifying the enabled property to ensure thread safety.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift#L58-L61

Potential issue: The `deinit` method in `SentryUserFeedbackIntegrationDriver`
unconditionally calls `SentryShakeDetector.disable()`, which modifies a global static
state. This is problematic because `SentryShakeDetector` is a shared resource intended
for use by other components, such as the React Native SDK. When the integration is
deinitialized (e.g., during `SentrySDK.close()`), it will globally disable shake
detection for all consumers, not just for this specific integration instance. This
breaks the feature for any other part of the application that has enabled it.
Additionally, the `disable` method itself is not thread-safe; it modifies the `enabled`
flag without acquiring a lock, creating a potential race condition since `deinit` can be
called from any thread.

Did we get this right? πŸ‘ / πŸ‘Ž to inform future reviews.

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

}
NotificationCenter.default.removeObserver(self)
}

func showWidget() {
Expand Down Expand Up @@ -149,6 +157,19 @@ private extension SentryUserFeedbackIntegrationDriver {
}
}

func observeShakeGesture() {
if configuration.useShakeGesture {
SentryShakeDetector.enable()
didEnableShakeDetection = true
NotificationCenter.default.addObserver(self, selector: #selector(handleShakeGesture), name: .SentryShakeDetected, object: nil)
}
}

@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

showForm(screenshot: nil)
}

@objc func userCapturedScreenshot() {
stopObservingScreenshots()
showForm(screenshot: screenshotSource.appScreenshots().first)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
@testable import Sentry
import XCTest

#if os(iOS)
import UIKit

final class SentryShakeDetectorTests: XCTestCase {

override func tearDown() {
super.tearDown()
SentryShakeDetector.disable()
}

func testEnable_whenShakeOccurs_shouldPostNotification() {
let expectation = expectation(forNotification: .SentryShakeDetected, object: nil)

SentryShakeDetector.enable()

let window = UIWindow()
window.motionEnded(.motionShake, with: nil)

wait(for: [expectation], timeout: 1.0)
}

func testDisable_whenShakeOccurs_shouldNotPostNotification() {
SentryShakeDetector.enable()
SentryShakeDetector.disable()

let expectation = expectation(forNotification: .SentryShakeDetected, object: nil)
expectation.isInverted = true

let window = UIWindow()
window.motionEnded(.motionShake, with: nil)

wait(for: [expectation], timeout: 0.5)
}

func testEnable_whenNonShakeMotion_shouldNotPostNotification() {
SentryShakeDetector.enable()

let expectation = expectation(forNotification: .SentryShakeDetected, object: nil)
expectation.isInverted = true

let window = UIWindow()
window.motionEnded(.none, with: nil)

wait(for: [expectation], timeout: 0.5)
}

func testEnable_calledMultipleTimes_shouldNotCrash() {
SentryShakeDetector.enable()
SentryShakeDetector.enable()
SentryShakeDetector.enable()

// Just verify no crash; the swizzle-once guard handles repeated calls
let window = UIWindow()
window.motionEnded(.motionShake, with: nil)
}

func testDisable_withoutEnable_shouldNotCrash() {
SentryShakeDetector.disable()
}

func testCooldown_whenShakesTooFast_shouldPostOnlyOnce() {
SentryShakeDetector.enable()

var notificationCount = 0
let observer = NotificationCenter.default.addObserver(
forName: .SentryShakeDetected, object: nil, queue: nil
) { _ in
notificationCount += 1
}

let window = UIWindow()
// Rapid shakes within the 1s cooldown
window.motionEnded(.motionShake, with: nil)
window.motionEnded(.motionShake, with: nil)
window.motionEnded(.motionShake, with: nil)

XCTAssertEqual(notificationCount, 1)

NotificationCenter.default.removeObserver(observer)
}
Copy link

Choose a reason for hiding this comment

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

Shared static state causes flaky cooldown test

Medium Severity

lastShakeTimestamp is a private static var that persists across tests, but disable() (called in tearDown) only resets enabled β€” it never resets lastShakeTimestamp. If testCooldown_whenShakesTooFast_shouldPostOnlyOnce runs after any test that triggers a shake (e.g., testEnable_whenShakeOccurs_shouldPostNotification) within the 1-second cooldown window, its first shake will be suppressed and notificationCount stays 0, failing the XCTAssertEqual(notificationCount, 1) assertion. This makes the test order-dependent and flaky under randomized execution.

Additional Locations (1)

Fix in CursorΒ Fix in Web


func testOriginalImplementation_shouldStillBeCalled() {
SentryShakeDetector.enable()

// motionEnded should not crash β€” the original UIResponder implementation
// is called after our interceptor
let window = UIWindow()
window.motionEnded(.motionShake, with: nil)
window.motionEnded(.remoteControlBeginSeekingBackward, with: nil)
}
}

#endif
Loading
Loading