-
-
Notifications
You must be signed in to change notification settings - Fork 386
feat(feedback): implement shake gesture detection #7579
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 fb973ee
fix(feedback): guard UIKit import inside TARGET_OS_IOS to fix non-UIKβ¦
antonis cc676fe
fix(feedback): expose SentryShakeDetector on all platforms; no-op on β¦
antonis c83f0f0
Merge branch 'main' into antonis/feedback-shake
antonis 3575377
impr(feedback): harden shake detection
antonis 67a100d
chore: update public API snapshot for SentryShakeDetector
antonis bcda268
test(feedback): add unit tests for SentryShakeDetector
antonis 27ff27e
fix(feedback): prevent deinit race on shake detector
antonis 3e4b593
fix(feedback): set up shake detection before SwiftUI early return
antonis 3c24378
ref(feedback): rewrite SentryShakeDetector in Swift
antonis a447914
Merge branch 'main' into antonis/feedback-shake
antonis 0f86f9f
Update changelog
antonis 6a17a68
chore: update API snapshot for Swift shake detector
antonis f0cd795
ref(feedback): make SentryShakeDetector final
antonis e47ec7b
ref(feedback): simplify deinit shake cleanup
antonis 9044b8b
Merge branch 'main' into antonis/feedback-shake
antonis 36137d7
Merge branch 'main' into antonis/feedback-shake
antonis 81be891
impr(feedback): add debug logging to shake detection
antonis 1ebfe2f
fix(feedback): prevent displayingForm stuck when presenter is nil
antonis 725eba9
Emit a warning log when Shake detector could not add motionEnded to Uβ¦
antonis f5eacb9
Merge branch 'main' into antonis/feedback-shake
antonis 1a04fd8
fix(feedback): remove screenshot observer from SwiftUI early-return
antonis 2934db5
Merge branch 'main' into antonis/feedback-shake
antonis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 103 additions & 0 deletions
103
Sources/Swift/Integrations/UserFeedback/SentryShakeDetector.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
antonis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| SentrySDKLog.debug("Shake detector: disabled") | ||
| } | ||
antonis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| #else | ||
| /// No-op on non-iOS platforms. | ||
| @objc public static func enable() {} | ||
| /// No-op on non-iOS platforms. | ||
| @objc public static func disable() {} | ||
| #endif | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
96 changes: 96 additions & 0 deletions
96
Tests/SentryTests/Integrations/Feedback/SentryShakeDetectorTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
antonis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.