Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions Samples/iOS-Swift/App/Resources/Base.lproj/Main.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -1998,6 +1998,22 @@
<action selector="toggleWidget:" destination="fdb-vc-001" eventType="touchUpInside" id="fdb-act-toggle"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="fdb-btn-shake-enable">
<rect key="frame" x="0.0" y="210" width="288" height="34.5"/>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="filled" title="Enable Feedback On Shake"/>
<connections>
<action selector="enableFeedbackOnShake:" destination="fdb-vc-001" eventType="touchUpInside" id="fdb-act-shake-enable"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="fdb-btn-shake-disable">
<rect key="frame" x="0.0" y="260" width="288" height="34.5"/>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="filled" title="Disable Feedback On Shake"/>
<connections>
<action selector="disableFeedbackOnShake:" destination="fdb-vc-001" eventType="touchUpInside" id="fdb-act-shake-disable"/>
</connections>
</button>
</subviews>
</stackView>
</subviews>
Expand Down
8 changes: 8 additions & 0 deletions Samples/iOS-Swift/App/Sources/FeedbackViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
18 changes: 18 additions & 0 deletions Sources/SentryObjC/Public/SentryObjCFeedbackApi.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions Sources/SentryObjCCompat/SentryObjCFeedbackApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
36 changes: 36 additions & 0 deletions Sources/Swift/Integrations/UserFeedback/SentryFeedbackAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Comment thread
itaybre marked this conversation as resolved.
@available(iOSApplicationExtension, unavailable)
private func getIntegration() -> UserFeedbackIntegration<SentryDependencyContainer>? {
SentrySDKInternal.currentHub().getInstalledIntegration(UserFeedbackIntegration<SentryDependencyContainer>.self) as? UserFeedbackIntegration<SentryDependencyContainer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
antonis marked this conversation as resolved.
}
}
}

// MARK: Private
Expand Down Expand Up @@ -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() {
Expand Down
7 changes: 7 additions & 0 deletions Tests/SentryObjCTests/SentryObjCFeedbackApiTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ - (void)testShowWithScreenshotAndConfigure_whenNoPresenter_shouldNotCrash
}];
}

- (void)testOnShake_whenFeedbackNotConfigured_shouldNotCrash
{
// -- Act & Assert (no crash) --
[SentryObjCSDK.feedback enableOnShake];
[SentryObjCSDK.feedback disableOnShake];
}

@end

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading