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": [ {