diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14762f29b1..341285ba9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,10 @@
- Bump the minimum deployment targets to macOS 12 and watchOS 9 because Xcode 27 no longer supports earlier versions. This lets the SDK adopt Xcode 27 without blocking users from building and submitting their apps with the latest Xcode. (#8595, #8113, #8189)
+### Features
+
+- Add `SentrySDK.feedback.enableOnShake()` and `disableOnShake()` to toggle the shake-to-report gesture at runtime (#8591)
+
### Fixes
- Fix incorrect `duration` sent for active sessions (#8612)
diff --git a/Samples/iOS-Swift/App/Resources/Base.lproj/Main.storyboard b/Samples/iOS-Swift/App/Resources/Base.lproj/Main.storyboard
index 9ab3d4cad9..88a658745e 100644
--- a/Samples/iOS-Swift/App/Resources/Base.lproj/Main.storyboard
+++ b/Samples/iOS-Swift/App/Resources/Base.lproj/Main.storyboard
@@ -1998,6 +1998,22 @@
+
+
diff --git a/Samples/iOS-Swift/App/Sources/FeedbackViewController.swift b/Samples/iOS-Swift/App/Sources/FeedbackViewController.swift
index afc42b1f91..ec747a9741 100644
--- a/Samples/iOS-Swift/App/Sources/FeedbackViewController.swift
+++ b/Samples/iOS-Swift/App/Sources/FeedbackViewController.swift
@@ -30,6 +30,14 @@ final class FeedbackViewController: UIViewController {
}
}
+ @IBAction private func enableFeedbackOnShake(_: UIButton) {
+ SentrySDK.feedback.enableOnShake()
+ }
+
+ @IBAction private func disableFeedbackOnShake(_: UIButton) {
+ SentrySDK.feedback.disableOnShake()
+ }
+
@IBAction private func toggleWidget(_: UIButton) {
if isFeedbackWidgetVisible {
SentrySDK.feedback.hideWidget()
diff --git a/Sources/SentryObjC/Public/SentryObjCFeedbackApi.h b/Sources/SentryObjC/Public/SentryObjCFeedbackApi.h
index 228371f74e..aa660408d4 100644
--- a/Sources/SentryObjC/Public/SentryObjCFeedbackApi.h
+++ b/Sources/SentryObjC/Public/SentryObjCFeedbackApi.h
@@ -61,6 +61,24 @@ SENTRY_NO_INIT
SentryObjCUserFeedbackConfiguration *configuration))configure
NS_EXTENSION_UNAVAILABLE("Not available in app extensions.");
+/**
+ * Enables the shake-gesture trigger for the feedback form at runtime.
+ * @discussion Use this to enable shake-to-report after @c SentrySDK.start, e.g. once an
+ * asynchronously fetched feature flag resolves. Requires the User Feedback integration to be
+ * configured; otherwise this is a no-op.
+ * @note This method must be called from the main thread.
+ * @warning This is an experimental feature and may still have bugs.
+ */
+- (void)enableOnShake NS_EXTENSION_UNAVAILABLE("Not available in app extensions.");
+
+/**
+ * Disables the shake-gesture trigger for the feedback form at runtime.
+ * @discussion Requires the User Feedback integration to be configured; otherwise this is a no-op.
+ * @note This method must be called from the main thread.
+ * @warning This is an experimental feature and may still have bugs.
+ */
+- (void)disableOnShake NS_EXTENSION_UNAVAILABLE("Not available in app extensions.");
+
# if !SDK_V10
/**
* Show the feedback widget button.
diff --git a/Sources/SentryObjCCompat/SentryObjCFeedbackApi.swift b/Sources/SentryObjCCompat/SentryObjCFeedbackApi.swift
index 79255cba46..0af0ebeccb 100644
--- a/Sources/SentryObjCCompat/SentryObjCFeedbackApi.swift
+++ b/Sources/SentryObjCCompat/SentryObjCFeedbackApi.swift
@@ -39,6 +39,16 @@ import UIKit
wrapped.show(screenshot: screenshot, configure: wrappedConfigure(configure))
}
+ @available(iOSApplicationExtension, unavailable)
+ @objc public func enableOnShake() {
+ wrapped.enableOnShake()
+ }
+
+ @available(iOSApplicationExtension, unavailable)
+ @objc public func disableOnShake() {
+ wrapped.disableOnShake()
+ }
+
#if !SDK_V10
@available(iOSApplicationExtension, unavailable)
@available(*, deprecated, message: "The Sentry-managed User Feedback widget is deprecated and will be removed in v10.")
diff --git a/Sources/Swift/Integrations/UserFeedback/SentryFeedbackAPI.swift b/Sources/Swift/Integrations/UserFeedback/SentryFeedbackAPI.swift
index c0971cf082..48c5c1f009 100644
--- a/Sources/Swift/Integrations/UserFeedback/SentryFeedbackAPI.swift
+++ b/Sources/Swift/Integrations/UserFeedback/SentryFeedbackAPI.swift
@@ -61,6 +61,42 @@ import UIKit
driver.showForm(from: presenter, screenshot: screenshot, configure: configure)
}
+ /// Enables the shake-gesture trigger for the feedback form at runtime.
+ ///
+ /// Sentry's options are applied synchronously during `SentrySDK.start`, so consumers that
+ /// decide whether to offer feedback based on an asynchronous signal (e.g. a feature flag or a
+ /// user role fetched at launch) cannot express that choice through `useShakeGesture` alone.
+ /// Use this to enable shake-to-report after initialization.
+ ///
+ /// Requires the User Feedback integration to be configured (`SentryOptions.configureUserFeedback`);
+ /// otherwise this is a no-op. Only affects iOS/iPadOS; a no-op on other platforms.
+ /// - Important: Call this method from the main thread.
+ /// - warning: This is an experimental feature and may still have bugs.
+ @available(iOSApplicationExtension, unavailable)
+ @objc public func enableOnShake() {
+ setShakeGestureEnabled(true)
+ }
+
+ /// Disables the shake-gesture trigger for the feedback form at runtime.
+ ///
+ /// Requires the User Feedback integration to be configured (`SentryOptions.configureUserFeedback`);
+ /// otherwise this is a no-op. Only affects iOS/iPadOS; a no-op on other platforms.
+ /// - Important: Call this method from the main thread.
+ /// - warning: This is an experimental feature and may still have bugs.
+ @available(iOSApplicationExtension, unavailable)
+ @objc public func disableOnShake() {
+ setShakeGestureEnabled(false)
+ }
+
+ @available(iOSApplicationExtension, unavailable)
+ private func setShakeGestureEnabled(_ enabled: Bool) {
+ guard let driver = getIntegration()?.driver else {
+ SentrySDKLog.debug("Cannot toggle shake gesture — user feedback is not configured")
+ return
+ }
+ driver.setShakeGestureEnabled(enabled)
+ }
+
@available(iOSApplicationExtension, unavailable)
private func getIntegration() -> UserFeedbackIntegration? {
SentrySDKInternal.currentHub().getInstalledIntegration(UserFeedbackIntegration.self) as? UserFeedbackIntegration
diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift
index b9b06dfbf2..d13d037db8 100644
--- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift
+++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift
@@ -12,6 +12,7 @@ import UIKit
final class SentryUserFeedbackIntegrationDriver: NSObject {
let configuration: SentryUserFeedbackConfiguration
private weak var activeForm: SentryUserFeedbackFormController?
+ private var isObservingShakeGesture = false
let screenshotSource: SentryScreenshotSource
let windowFactory: SentryUserFeedbackWindowFactory
private let notificationCenter: SentryNSNotificationCenterWrapper
@@ -163,6 +164,27 @@ extension SentryUserFeedbackIntegrationDriver {
activeForm = form
presenter.present(form, animated: formConfig.animations)
}
+
+ /// Enables or disables shake-gesture-triggered feedback at runtime.
+ ///
+ /// This lets consumers toggle shake-to-report after `SentrySDK.start`, e.g. once an
+ /// asynchronously fetched feature flag resolves. Repeated calls with the same value are no-ops.
+ /// - Parameter enabled: `true` to start detecting shakes and presenting the feedback form;
+ /// `false` to stop.
+ func setShakeGestureEnabled(_ enabled: Bool) {
+ configuration.useShakeGesture = enabled
+ if enabled {
+ guard !isObservingShakeGesture else { return }
+ isObservingShakeGesture = true
+ SentryShakeDetector.enable()
+ notificationCenter.addObserver(self, selector: #selector(handleShakeGesture), name: .SentryShakeDetected, object: nil)
+ } else {
+ guard isObservingShakeGesture else { return }
+ isObservingShakeGesture = false
+ SentryShakeDetector.disable()
+ notificationCenter.removeObserver(self, name: .SentryShakeDetected, object: nil)
+ }
+ }
}
// MARK: Private
@@ -209,8 +231,7 @@ private extension SentryUserFeedbackIntegrationDriver {
SentrySDKLog.debug("Shake gesture detection is disabled in configuration")
return
}
- SentryShakeDetector.enable()
- notificationCenter.addObserver(self, selector: #selector(handleShakeGesture), name: .SentryShakeDetected, object: nil)
+ setShakeGestureEnabled(true)
}
@objc func handleShakeGesture() {
diff --git a/Tests/SentryObjCTests/SentryObjCFeedbackApiTests.m b/Tests/SentryObjCTests/SentryObjCFeedbackApiTests.m
index 1bdcc89f6d..6bac300cb8 100644
--- a/Tests/SentryObjCTests/SentryObjCFeedbackApiTests.m
+++ b/Tests/SentryObjCTests/SentryObjCFeedbackApiTests.m
@@ -40,6 +40,13 @@ - (void)testShowWithScreenshotAndConfigure_whenNoPresenter_shouldNotCrash
}];
}
+- (void)testOnShake_whenFeedbackNotConfigured_shouldNotCrash
+{
+ // -- Act & Assert (no crash) --
+ [SentryObjCSDK.feedback enableOnShake];
+ [SentryObjCSDK.feedback disableOnShake];
+}
+
@end
#endif
diff --git a/Tests/SentryTests/Integrations/Feedback/UserFeedbackIntegrationTests.swift b/Tests/SentryTests/Integrations/Feedback/UserFeedbackIntegrationTests.swift
index 3256e5cb12..8fab16d2a0 100644
--- a/Tests/SentryTests/Integrations/Feedback/UserFeedbackIntegrationTests.swift
+++ b/Tests/SentryTests/Integrations/Feedback/UserFeedbackIntegrationTests.swift
@@ -355,6 +355,190 @@ final class UserFeedbackIntegrationTests: XCTestCase {
withExtendedLifetime(window) { }
}
+ func testSetShakeGestureEnabled_whenEnabledAtRuntime_shouldPresentFormOnShake() throws {
+ let window = makeWindow()
+ let viewController = TestPresentingViewController()
+ let config = SentryUserFeedbackConfiguration()
+ config.animations = false
+ config.useShakeGesture = false
+ let sut = SentryUserFeedbackIntegrationDriver(
+ configuration: config,
+ screenshotSource: makeScreenshotSource())
+ useFallbackPresenter(viewController, in: window)
+
+ // Not observing yet — a shake should be ignored.
+ NotificationCenter.default.post(name: .SentryShakeDetected, object: nil)
+ XCTAssertFalse(sut.displayingForm)
+
+ sut.setShakeGestureEnabled(true)
+ NotificationCenter.default.post(name: .SentryShakeDetected, object: nil)
+
+ _ = try XCTUnwrap(viewController.lastPresentedViewController as? SentryUserFeedbackFormController)
+ XCTAssertTrue(sut.displayingForm)
+ XCTAssertTrue(config.useShakeGesture)
+
+ withExtendedLifetime(window) { }
+ }
+
+ func testSetShakeGestureEnabled_whenDisabledAtRuntime_shouldNotPresentFormOnShake() {
+ let window = makeWindow()
+ let viewController = TestPresentingViewController()
+ let config = SentryUserFeedbackConfiguration()
+ config.animations = false
+ config.useShakeGesture = true
+ let sut = SentryUserFeedbackIntegrationDriver(
+ configuration: config,
+ screenshotSource: makeScreenshotSource())
+ useFallbackPresenter(viewController, in: window)
+
+ sut.setShakeGestureEnabled(false)
+ NotificationCenter.default.post(name: .SentryShakeDetected, object: nil)
+
+ XCTAssertFalse(sut.displayingForm)
+ XCTAssertEqual(viewController.presentCallCount, 0)
+ XCTAssertFalse(config.useShakeGesture)
+
+ withExtendedLifetime(window) { }
+ }
+
+ func testSetShakeGestureEnabled_whenEnabledTwice_shouldRegisterObserverOnce() {
+ let notificationCenter = TestNSNotificationCenterWrapper()
+ let config = SentryUserFeedbackConfiguration()
+ config.useShakeGesture = false
+ let sut = SentryUserFeedbackIntegrationDriver(
+ configuration: config,
+ screenshotSource: makeScreenshotSource(),
+ notificationCenter: notificationCenter)
+
+ sut.setShakeGestureEnabled(true)
+ sut.setShakeGestureEnabled(true)
+
+ let shakeObservers = notificationCenter.addObserverWithObjectInvocations.invocations
+ .filter { $0.name == .SentryShakeDetected }
+ XCTAssertEqual(shakeObservers.count, 1)
+ }
+
+ func testSetShakeGestureEnabled_whenDisabledAfterInit_shouldRemoveObserver() throws {
+ let notificationCenter = TestNSNotificationCenterWrapper()
+ let config = SentryUserFeedbackConfiguration()
+ config.useShakeGesture = true
+ let sut = SentryUserFeedbackIntegrationDriver(
+ configuration: config,
+ screenshotSource: makeScreenshotSource(),
+ notificationCenter: notificationCenter)
+
+ sut.setShakeGestureEnabled(false)
+
+ let removal = try XCTUnwrap(notificationCenter.removeObserverWithNameAndObjectInvocations
+ .invocations.first { $0.name == .SentryShakeDetected })
+ XCTAssertEqual(removal.name, .SentryShakeDetected)
+ }
+
+ func testSetShakeGestureEnabled_whenReEnabledAfterDisable_shouldPresentFormOnShake() throws {
+ let window = makeWindow()
+ let viewController = TestPresentingViewController()
+ let config = SentryUserFeedbackConfiguration()
+ config.animations = false
+ config.useShakeGesture = true
+ let sut = SentryUserFeedbackIntegrationDriver(
+ configuration: config,
+ screenshotSource: makeScreenshotSource())
+ useFallbackPresenter(viewController, in: window)
+
+ sut.setShakeGestureEnabled(false)
+ sut.setShakeGestureEnabled(true)
+ NotificationCenter.default.post(name: .SentryShakeDetected, object: nil)
+
+ _ = try XCTUnwrap(viewController.lastPresentedViewController as? SentryUserFeedbackFormController)
+ XCTAssertTrue(sut.displayingForm)
+ XCTAssertTrue(config.useShakeGesture)
+
+ withExtendedLifetime(window) { }
+ }
+
+ func testSetShakeGestureEnabled_whenDisabled_shouldKeepScreenshotObserver() throws {
+ let notificationCenter = TestNSNotificationCenterWrapper()
+ let config = SentryUserFeedbackConfiguration()
+ config.showFormForScreenshots = true
+ config.useShakeGesture = true
+ let sut = SentryUserFeedbackIntegrationDriver(
+ configuration: config,
+ screenshotSource: makeScreenshotSource(),
+ notificationCenter: notificationCenter)
+ XCTAssertEqual(notificationCenter.observerCount, 2)
+
+ sut.setShakeGestureEnabled(false)
+
+ // Only the shake observer is removed; the screenshot observer survives.
+ let removals = notificationCenter.removeObserverWithNameAndObjectInvocations.invocations
+ XCTAssertEqual(removals.count, 1)
+ XCTAssertEqual(removals.first?.name, .SentryShakeDetected)
+ XCTAssertEqual(notificationCenter.observerCount, 1)
+
+ withExtendedLifetime(sut) { }
+ }
+
+ func testSetShakeGestureEnabled_whenDisabledWhileNotObserving_shouldBeNoOp() {
+ let notificationCenter = TestNSNotificationCenterWrapper()
+ let config = SentryUserFeedbackConfiguration()
+ config.useShakeGesture = false
+ let sut = SentryUserFeedbackIntegrationDriver(
+ configuration: config,
+ screenshotSource: makeScreenshotSource(),
+ notificationCenter: notificationCenter)
+
+ sut.setShakeGestureEnabled(false)
+
+ XCTAssertTrue(notificationCenter.removeObserverWithNameAndObjectInvocations.invocations.isEmpty)
+ XCTAssertFalse(config.useShakeGesture)
+
+ withExtendedLifetime(sut) { }
+ }
+
+ func testFeedbackAPI_enableOnShake_whenConfigured_shouldPresentFormOnShake() throws {
+ let window = makeWindow()
+ let viewController = TestPresentingViewController()
+ let integration = try installFeedbackIntegration { $0.animations = false }
+ useFallbackPresenter(viewController, in: window)
+
+ // Not observing yet (useShakeGesture defaults to false) — a shake is ignored.
+ NotificationCenter.default.post(name: .SentryShakeDetected, object: nil)
+ XCTAssertFalse(integration.driver.displayingForm)
+
+ SentrySDK.feedback.enableOnShake()
+ NotificationCenter.default.post(name: .SentryShakeDetected, object: nil)
+
+ _ = try XCTUnwrap(viewController.lastPresentedViewController as? SentryUserFeedbackFormController)
+ XCTAssertTrue(integration.driver.displayingForm)
+
+ withExtendedLifetime(window) { }
+ }
+
+ func testFeedbackAPI_disableOnShake_whenConfigured_shouldSuppressFormOnShake() throws {
+ let window = makeWindow()
+ let viewController = TestPresentingViewController()
+ let integration = try installFeedbackIntegration {
+ $0.animations = false
+ $0.useShakeGesture = true
+ }
+ useFallbackPresenter(viewController, in: window)
+
+ SentrySDK.feedback.disableOnShake()
+ NotificationCenter.default.post(name: .SentryShakeDetected, object: nil)
+
+ XCTAssertFalse(integration.driver.displayingForm)
+ XCTAssertEqual(viewController.presentCallCount, 0)
+
+ withExtendedLifetime(window) { }
+ }
+
+ func testFeedbackAPI_onShake_whenFeedbackNotConfigured_shouldNotCrash() {
+ clearTestState()
+
+ SentrySDK.feedback.enableOnShake()
+ SentrySDK.feedback.disableOnShake()
+ }
+
@available(*, deprecated, message: "Testing deprecated widget configuration")
func testScreenshotTrigger_whenWidgetAutoInjectionDisabled_shouldUseFallbackPresenter() throws {
#if SDK_V10
diff --git a/sdk_api.json b/sdk_api.json
index efa8ac32f9..27ff05051a 100644
--- a/sdk_api.json
+++ b/sdk_api.json
@@ -39851,6 +39851,50 @@
"printedName": "init()",
"usr": "c:@M@Sentry@objc(cs)SentryFeedbackAPI(im)init"
},
+ {
+ "children": [
+ {
+ "kind": "TypeNominal",
+ "name": "Void",
+ "printedName": "()"
+ }
+ ],
+ "declAttributes": [
+ "Available",
+ "Final",
+ "ObjC"
+ ],
+ "declKind": "Func",
+ "funcSelfKind": "NonMutating",
+ "kind": "Function",
+ "mangledName": "$s6Sentry0A11FeedbackAPIC14disableOnShakeyyF",
+ "moduleName": "Sentry",
+ "name": "disableOnShake",
+ "printedName": "disableOnShake()",
+ "usr": "c:@M@Sentry@objc(cs)SentryFeedbackAPI(im)disableOnShake"
+ },
+ {
+ "children": [
+ {
+ "kind": "TypeNominal",
+ "name": "Void",
+ "printedName": "()"
+ }
+ ],
+ "declAttributes": [
+ "Available",
+ "Final",
+ "ObjC"
+ ],
+ "declKind": "Func",
+ "funcSelfKind": "NonMutating",
+ "kind": "Function",
+ "mangledName": "$s6Sentry0A11FeedbackAPIC13enableOnShakeyyF",
+ "moduleName": "Sentry",
+ "name": "enableOnShake",
+ "printedName": "enableOnShake()",
+ "usr": "c:@M@Sentry@objc(cs)SentryFeedbackAPI(im)enableOnShake"
+ },
{
"children": [
{
diff --git a/sdk_api_objc.json b/sdk_api_objc.json
index 91236ca8be..72823bf296 100644
--- a/sdk_api_objc.json
+++ b/sdk_api_objc.json
@@ -1746,6 +1746,13 @@
"returnType": "SentryObjCLevel",
"instance": true
},
+ {
+ "kind": "ObjCMethodDecl",
+ "name": "disableOnShake",
+ "parent": "SentryObjCFeedbackApi",
+ "returnType": "void",
+ "instance": true
+ },
{
"kind": "ObjCMethodDecl",
"name": "discardFor:",
@@ -2019,6 +2026,13 @@
"returnType": "BOOL",
"instance": true
},
+ {
+ "kind": "ObjCMethodDecl",
+ "name": "enableOnShake",
+ "parent": "SentryObjCFeedbackApi",
+ "returnType": "void",
+ "instance": true
+ },
{
"kind": "ObjCMethodDecl",
"name": "enablePersistingTracesWhenCrashing",
diff --git a/sdk_api_objc_v10.json b/sdk_api_objc_v10.json
index a294e17b0a..fbf8d0c06a 100644
--- a/sdk_api_objc_v10.json
+++ b/sdk_api_objc_v10.json
@@ -1852,6 +1852,13 @@
"returnType": "SentryObjCLevel",
"instance": true
},
+ {
+ "kind": "ObjCMethodDecl",
+ "name": "disableOnShake",
+ "parent": "SentryObjCFeedbackApi",
+ "returnType": "void",
+ "instance": true
+ },
{
"kind": "ObjCMethodDecl",
"name": "discardFor:",
@@ -2132,6 +2139,13 @@
"returnType": "BOOL",
"instance": true
},
+ {
+ "kind": "ObjCMethodDecl",
+ "name": "enableOnShake",
+ "parent": "SentryObjCFeedbackApi",
+ "returnType": "void",
+ "instance": true
+ },
{
"kind": "ObjCMethodDecl",
"name": "enablePersistingTracesWhenCrashing",
diff --git a/sdk_api_v10.json b/sdk_api_v10.json
index 860b92da4a..2e2af506f4 100644
--- a/sdk_api_v10.json
+++ b/sdk_api_v10.json
@@ -41937,6 +41937,50 @@
"printedName": "init()",
"usr": "c:@M@Sentry@objc(cs)SentryFeedbackAPI(im)init"
},
+ {
+ "children": [
+ {
+ "kind": "TypeNominal",
+ "name": "Void",
+ "printedName": "()"
+ }
+ ],
+ "declAttributes": [
+ "Available",
+ "Final",
+ "ObjC"
+ ],
+ "declKind": "Func",
+ "funcSelfKind": "NonMutating",
+ "kind": "Function",
+ "mangledName": "$s6Sentry0A11FeedbackAPIC14disableOnShakeyyF",
+ "moduleName": "Sentry",
+ "name": "disableOnShake",
+ "printedName": "disableOnShake()",
+ "usr": "c:@M@Sentry@objc(cs)SentryFeedbackAPI(im)disableOnShake"
+ },
+ {
+ "children": [
+ {
+ "kind": "TypeNominal",
+ "name": "Void",
+ "printedName": "()"
+ }
+ ],
+ "declAttributes": [
+ "Available",
+ "Final",
+ "ObjC"
+ ],
+ "declKind": "Func",
+ "funcSelfKind": "NonMutating",
+ "kind": "Function",
+ "mangledName": "$s6Sentry0A11FeedbackAPIC13enableOnShakeyyF",
+ "moduleName": "Sentry",
+ "name": "enableOnShake",
+ "printedName": "enableOnShake()",
+ "usr": "c:@M@Sentry@objc(cs)SentryFeedbackAPI(im)enableOnShake"
+ },
{
"children": [
{