diff --git a/CHANGELOG.md b/CHANGELOG.md index 4980be865c..ab32df3cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Add SentryObjC User Feedback presentation APIs and a feedback form factory returning `UIViewController` instances. (#8027) +### Fixes + +- Show feedback form from shake or screenshot without widget (#8050) + ### Deprecations - Deprecate the managed User Feedback custom button. It will be removed in v10. Present the feedback form from your own UI with `SentrySDK.feedback.show()`, `SentrySDK.FeedbackForm`, or `.sentryFeedback(isPresented:)` instead. (#8052) diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift index 0c7a699a3d..2cbc82ea67 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift @@ -37,6 +37,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. The managed widget is deprecated; prefer presenting the feedback form from your own UI using SentrySDK.feedback.show(), SentrySDK.FeedbackForm, or sentryFeedback(isPresented:). */ if UIApplication.shared.connectedScenes.isEmpty && UIApplication.shared.delegate == nil { + observeScreenshots() observeShakeGesture() return } @@ -203,11 +204,15 @@ private extension SentryUserFeedbackIntegrationDriver { } var presenter: UIViewController? { - if let customButton = configuration.customButton { - return customButton.controller + if let customButton = configuration.customButton?.controller { + return customButton + } + + if let widgetRootViewController = widget?.rootVC { + return widgetRootViewController } - return widget?.rootVC + return SentryFeedbackFormPresenter.presentingViewController() } } diff --git a/Tests/SentryTests/Integrations/Feedback/UserFeedbackIntegrationTests.swift b/Tests/SentryTests/Integrations/Feedback/UserFeedbackIntegrationTests.swift index 5bc13c172b..3ad71184a5 100644 --- a/Tests/SentryTests/Integrations/Feedback/UserFeedbackIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/Feedback/UserFeedbackIntegrationTests.swift @@ -241,6 +241,9 @@ final class UserFeedbackIntegrationTests: XCTestCase { } func testShowForm_whenNoPresenterAvailable_shouldNotPresentForm() { + let application = TestSentryUIApplication() + application.windows = [] + SentryDependencyContainer.sharedInstance().applicationOverride = application let sut = SentryUserFeedbackIntegrationDriver( configuration: SentryUserFeedbackConfiguration(), screenshotSource: makeScreenshotSource()) @@ -250,6 +253,50 @@ final class UserFeedbackIntegrationTests: XCTestCase { XCTAssertFalse(sut.displayingForm) } + func testShakeGesture_whenNoWidgetOrCustomButton_shouldUseFallbackPresenter() throws { + let window = UIWindow(frame: UIScreen.main.bounds) + let viewController = TestPresentingViewController() + let config = SentryUserFeedbackConfiguration() + config.animations = false + config.useShakeGesture = true + let sut = SentryUserFeedbackIntegrationDriver( + configuration: config, + screenshotSource: makeScreenshotSource()) + useFallbackPresenter(viewController, in: window) + + NotificationCenter.default.post(name: .SentryShakeDetected, object: nil) + + _ = try XCTUnwrap(viewController.lastPresentedViewController as? SentryUserFeedbackFormController) + XCTAssertTrue(sut.displayingForm) + + withExtendedLifetime(window) { } + } + + @available(*, deprecated, message: "Testing deprecated widget configuration") + func testScreenshotTrigger_whenWidgetAutoInjectionDisabled_shouldUseFallbackPresenter() throws { + let window = UIWindow(frame: UIScreen.main.bounds) + let viewController = TestPresentingViewController() + let screenshot = UIImage() + let config = SentryUserFeedbackConfiguration() + config.animations = false + config.showFormForScreenshots = true + config.configureWidget = { widget in + widget.autoInject = false + } + let sut = SentryUserFeedbackIntegrationDriver( + configuration: config, + screenshotSource: TestScreenshotSource(screenshots: [screenshot])) + useFallbackPresenter(viewController, in: window) + + NotificationCenter.default.post(name: UIApplication.userDidTakeScreenshotNotification, object: nil) + + let form = try XCTUnwrap(viewController.lastPresentedViewController as? SentryUserFeedbackFormController) + XCTAssertIdentical(form.screenshot, screenshot) + XCTAssertNil(widgetHost(for: sut)) + + withExtendedLifetime(window) { } + } + func testShowForm_whenConfigurationBuildersAreSet_shouldNotApplyBuildersAgain() throws { let window = UIWindow(frame: UIScreen.main.bounds) let viewController = TestPresentingViewController() @@ -483,6 +530,29 @@ final class UserFeedbackIntegrationTests: XCTestCase { viewController.view.addSubview(customButton) } + private func useFallbackPresenter(_ viewController: UIViewController, in window: UIWindow) { + window.rootViewController = viewController + let application = TestSentryUIApplication() + application.windows = [window] + SentryDependencyContainer.sharedInstance().applicationOverride = application + } + + private final class TestScreenshotSource: SentryScreenshotSource { + private let screenshots: [UIImage] + + init(screenshots: [UIImage]) { + self.screenshots = screenshots + super.init(photographer: SentryViewPhotographer( + renderer: SentryDefaultViewRenderer(), + redactOptions: Options().screenshot, + enableMaskRendererV2: false)) + } + + override func appScreenshots() -> [UIImage] { + return screenshots + } + } + private final class TestPresentingViewController: UIViewController { private(set) var lastPresentedViewController: UIViewController? private(set) var presentCallCount = 0