diff --git a/CHANGELOG.md b/CHANGELOG.md index c7673bb7d4b..a8692560724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/Sources/Swift/Integrations/UserFeedback/SentryShakeDetector.swift b/Sources/Swift/Integrations/UserFeedback/SentryShakeDetector.swift new file mode 100644 index 00000000000..a58746df8c4 --- /dev/null +++ b/Sources/Swift/Integrations/UserFeedback/SentryShakeDetector.swift @@ -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 +} diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift index 84e76e28645..7a4953ffc40 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift @@ -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 } @@ -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() { @@ -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?() } } @@ -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) diff --git a/Tests/SentryTests/Integrations/Feedback/SentryShakeDetectorTests.swift b/Tests/SentryTests/Integrations/Feedback/SentryShakeDetectorTests.swift new file mode 100644 index 00000000000..0213e4ea12c --- /dev/null +++ b/Tests/SentryTests/Integrations/Feedback/SentryShakeDetectorTests.swift @@ -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 diff --git a/sdk_api.json b/sdk_api.json index 0a1e6e68960..1aca035b64f 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -356,6 +356,13 @@ "name": "PDFKit", "printedName": "PDFKit" }, + { + "declKind": "Import", + "kind": "Import", + "moduleName": "Sentry", + "name": "QuartzCore", + "printedName": "QuartzCore" + }, { "declAttributes": [ "Exported" @@ -19286,6 +19293,140 @@ "superclassUsr": "c:objc(cs)NSObject", "usr": "c:objc(cs)SentryMechanismContext" }, + { + "children": [ + { + "accessors": [ + { + "accessorKind": "get", + "children": [ + { + "kind": "TypeNominal", + "name": "Name", + "printedName": "Foundation.NSNotification.Name", + "usr": "c:@T@NSNotificationName" + } + ], + "declKind": "Accessor", + "implicit": true, + "isFromExtension": true, + "kind": "Accessor", + "mangledName": "$sSo18NSNotificationNamea6SentryE0C13ShakeDetectedABvgZ", + "moduleName": "Sentry", + "name": "Get", + "printedName": "Get()", + "static": true, + "usr": "s:So18NSNotificationNamea6SentryE0C13ShakeDetectedABvgZ" + } + ], + "children": [ + { + "kind": "TypeNominal", + "name": "Name", + "printedName": "Foundation.NSNotification.Name", + "usr": "c:@T@NSNotificationName" + } + ], + "declAttributes": [ + "HasStorage" + ], + "declKind": "Var", + "hasStorage": true, + "isFromExtension": true, + "isLet": true, + "kind": "Var", + "mangledName": "$sSo18NSNotificationNamea6SentryE0C13ShakeDetectedABvpZ", + "moduleName": "Sentry", + "name": "SentryShakeDetected", + "printedName": "SentryShakeDetected", + "static": true, + "usr": "s:So18NSNotificationNamea6SentryE0C13ShakeDetectedABvpZ" + } + ], + "conformances": [ + { + "kind": "Conformance", + "mangledName": "$ss8CopyableP", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP" + }, + { + "kind": "Conformance", + "mangledName": "$sSQ", + "name": "Equatable", + "printedName": "Equatable", + "usr": "s:SQ" + }, + { + "kind": "Conformance", + "mangledName": "$ss9EscapableP", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP" + }, + { + "kind": "Conformance", + "mangledName": "$sSH", + "name": "Hashable", + "printedName": "Hashable", + "usr": "s:SH" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNameAlias", + "name": "RawValue", + "printedName": "Foundation.NSNotification.Name.RawValue" + } + ], + "kind": "TypeWitness", + "name": "RawValue", + "printedName": "RawValue" + } + ], + "kind": "Conformance", + "mangledName": "$sSY", + "name": "RawRepresentable", + "printedName": "RawRepresentable", + "usr": "s:SY" + }, + { + "kind": "Conformance", + "mangledName": "$ss8SendableP", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP" + } + ], + "declAttributes": [ + "Sendable", + "SynthesizedProtocol", + "SynthesizedProtocol", + "SynthesizedProtocol", + "SynthesizedProtocol", + "SynthesizedProtocol", + "SynthesizedProtocol" + ], + "declKind": "Struct", + "isExternal": true, + "isFromExtension": true, + "kind": "TypeDecl", + "moduleName": "Foundation", + "name": "Name", + "printedName": "Name", + "usr": "c:@T@NSNotificationName" + }, { "children": [ { @@ -52741,6 +52882,205 @@ "superclassUsr": "c:objc(cs)NSObject", "usr": "c:objc(cs)SentrySessionReplayHybridSDK" }, + { + "children": [ + { + "children": [ + { + "kind": "TypeNominal", + "name": "SentryShakeDetector", + "printedName": "Sentry.SentryShakeDetector", + "usr": "c:@M@Sentry@objc(cs)SentryShakeDetector" + } + ], + "declAttributes": [ + "Dynamic", + "ObjC", + "Override" + ], + "declKind": "Constructor", + "init_kind": "Designated", + "kind": "Constructor", + "mangledName": "$s6Sentry0A13ShakeDetectorCACycfc", + "moduleName": "Sentry", + "name": "init", + "objc_name": "init", + "overriding": true, + "printedName": "init()", + "usr": "c:@M@Sentry@objc(cs)SentryShakeDetector(im)init" + }, + { + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ], + "declAttributes": [ + "Final", + "ObjC" + ], + "declKind": "Func", + "funcSelfKind": "NonMutating", + "kind": "Function", + "mangledName": "$s6Sentry0A13ShakeDetectorC7disableyyFZ", + "moduleName": "Sentry", + "name": "disable", + "printedName": "disable()", + "static": true, + "usr": "c:@M@Sentry@objc(cs)SentryShakeDetector(cm)disable" + }, + { + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ], + "declAttributes": [ + "Final", + "ObjC" + ], + "declKind": "Func", + "funcSelfKind": "NonMutating", + "kind": "Function", + "mangledName": "$s6Sentry0A13ShakeDetectorC6enableyyFZ", + "moduleName": "Sentry", + "name": "enable", + "printedName": "enable()", + "static": true, + "usr": "c:@M@Sentry@objc(cs)SentryShakeDetector(cm)enable" + }, + { + "accessors": [ + { + "accessorKind": "get", + "children": [ + { + "kind": "TypeNominal", + "name": "Name", + "printedName": "Foundation.NSNotification.Name", + "usr": "c:@T@NSNotificationName" + } + ], + "declAttributes": [ + "Final", + "ObjC" + ], + "declKind": "Accessor", + "implicit": true, + "kind": "Accessor", + "mangledName": "$s6Sentry0A13ShakeDetectorC25shakeDetectedNotificationSo18NSNotificationNameavgZ", + "moduleName": "Sentry", + "name": "Get", + "printedName": "Get()", + "static": true, + "usr": "c:@M@Sentry@objc(cs)SentryShakeDetector(cm)shakeDetectedNotification" + } + ], + "children": [ + { + "kind": "TypeNominal", + "name": "Name", + "printedName": "Foundation.NSNotification.Name", + "usr": "c:@T@NSNotificationName" + } + ], + "declAttributes": [ + "Final", + "HasStorage", + "ObjC" + ], + "declKind": "Var", + "hasStorage": true, + "isLet": true, + "kind": "Var", + "mangledName": "$s6Sentry0A13ShakeDetectorC25shakeDetectedNotificationSo18NSNotificationNameavpZ", + "moduleName": "Sentry", + "name": "shakeDetectedNotification", + "printedName": "shakeDetectedNotification", + "static": true, + "usr": "c:@M@Sentry@objc(cs)SentryShakeDetector(cpy)shakeDetectedNotification" + } + ], + "conformances": [ + { + "kind": "Conformance", + "mangledName": "$ss7CVarArgP", + "name": "CVarArg", + "printedName": "CVarArg", + "usr": "s:s7CVarArgP" + }, + { + "kind": "Conformance", + "mangledName": "$ss8CopyableP", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP" + }, + { + "kind": "Conformance", + "mangledName": "$ss28CustomDebugStringConvertibleP", + "name": "CustomDebugStringConvertible", + "printedName": "CustomDebugStringConvertible", + "usr": "s:s28CustomDebugStringConvertibleP" + }, + { + "kind": "Conformance", + "mangledName": "$ss23CustomStringConvertibleP", + "name": "CustomStringConvertible", + "printedName": "CustomStringConvertible", + "usr": "s:s23CustomStringConvertibleP" + }, + { + "kind": "Conformance", + "mangledName": "$sSQ", + "name": "Equatable", + "printedName": "Equatable", + "usr": "s:SQ" + }, + { + "kind": "Conformance", + "mangledName": "$ss9EscapableP", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP" + }, + { + "kind": "Conformance", + "mangledName": "$sSH", + "name": "Hashable", + "printedName": "Hashable", + "usr": "s:SH" + }, + { + "kind": "Conformance", + "name": "NSObjectProtocol", + "printedName": "NSObjectProtocol", + "usr": "c:objc(pl)NSObject" + } + ], + "declAttributes": [ + "Final", + "ObjC", + "ObjCMembers" + ], + "declKind": "Class", + "inheritsConvenienceInitializers": true, + "kind": "TypeDecl", + "mangledName": "$s6Sentry0A13ShakeDetectorC", + "moduleName": "Sentry", + "name": "SentryShakeDetector", + "objc_name": "SentryShakeDetector", + "printedName": "SentryShakeDetector", + "superclassNames": [ + "ObjectiveC.NSObject" + ], + "superclassUsr": "c:objc(cs)NSObject", + "usr": "c:@M@Sentry@objc(cs)SentryShakeDetector" + }, { "children": [ {