Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
2934db5
Merge branch 'main' into antonis/feedback-shake
antonis Mar 11, 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### 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
- Add package traits for UI framework opt-out (#7578).
When building from source with Swift 6.1+ (using `Package@swift-6.1.swift`), you can enable the `NoUIFramework` trait to avoid linking UIKit or AppKit. Use this for command-line tools, headless server contexts, or other environments where UI frameworks are unavailable.
In Xcode 26.4 and later, add the Sentry package as a dependency and the `SentrySPM` product, then enable the `NoUIFramework` trait on the package reference (Package Dependencies β†’ select Sentry β†’ Traits).
Expand Down
103 changes: 103 additions & 0 deletions Sources/Swift/Integrations/UserFeedback/SentryShakeDetector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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 {
SentrySDKLog.debug("Shake detector: could not find motionEnded(_:with:) on UIWindow")
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 {
SentrySDKLog.warning("Shake detector: could not add motionEnded(_:with:) to UIWindow")
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
SentrySDKLog.debug("Shake detector: swizzled UIWindow.motionEnded(_:with:)")
}

enabled = true
SentrySDKLog.debug("Shake detector: enabled")
}

/// 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
SentrySDKLog.debug("Shake detector: disabled")
}
#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 @@ -44,6 +44,7 @@ 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 {
observeShakeGesture()
return
}

Expand All @@ -53,10 +54,13 @@ final class SentryUserFeedbackIntegrationDriver: NSObject {
}

observeScreenshots()
observeShakeGesture()
}

deinit {
customButton?.removeTarget(self, action: #selector(showForm(sender:)), for: .touchUpInside)
SentryShakeDetector.disable()
NotificationCenter.default.removeObserver(self)
}

func showWidget() {
Expand Down Expand Up @@ -115,11 +119,15 @@ extension SentryUserFeedbackIntegrationDriver: UIAdaptivePresentationControllerD
@available(iOSApplicationExtension, unavailable)
private extension SentryUserFeedbackIntegrationDriver {
func showForm(screenshot: UIImage?) {
guard let presenter = presenter else {
SentrySDKLog.debug("Cannot show feedback form β€” no presenter available")
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?()
}
}
Expand Down Expand Up @@ -149,6 +157,23 @@ private extension SentryUserFeedbackIntegrationDriver {
}
}

func observeShakeGesture() {
guard configuration.useShakeGesture else {
SentrySDKLog.debug("Shake gesture detection is disabled in configuration")
return
}
SentryShakeDetector.enable()
NotificationCenter.default.addObserver(self, selector: #selector(handleShakeGesture), name: .SentryShakeDetected, object: nil)
}

@objc func handleShakeGesture() {
guard !displayingForm else {
SentrySDKLog.debug("Shake gesture ignored β€” feedback form is already displayed")
return
}
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)
}

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