diff --git a/Samples/iOS-Swift/iOS-Swift-UITests/UserFeedbackUITests.swift b/Samples/iOS-Swift/iOS-Swift-UITests/UserFeedbackUITests.swift index a5f1eb50b69..c41deb96915 100644 --- a/Samples/iOS-Swift/iOS-Swift-UITests/UserFeedbackUITests.swift +++ b/Samples/iOS-Swift/iOS-Swift-UITests/UserFeedbackUITests.swift @@ -111,18 +111,21 @@ class UserFeedbackUITests: BaseUITest { // MARK: Tests validating happy path / successful submission func testSubmitFullyFilledForm() throws { - launchApp(args: ["--io.sentry.feedback.all-defaults"]) + launchApp(args: ["--io.sentry.feedback.all-defaults", "--io.sentry.disable-everything", "--io.sentry.wipe-data", "--io.sentry.base64-attachment-data"]) widgetButton.tap() nameField.tap() - nameField.typeText("Andrew") + let testName = "Andrew" + nameField.typeText(testName) emailField.tap() - emailField.typeText("andrew.mcknight@sentry.io") + let testContactEmail = "andrew.mcknight@sentry.io" + emailField.typeText(testContactEmail) messageTextView.tap() - messageTextView.typeText("UITest user feedback") + let testMessage = "UITest user feedback" + messageTextView.typeText(testMessage) sendButton.tap() @@ -134,6 +137,21 @@ class UserFeedbackUITests: BaseUITest { XCTAssertEqual(try XCTUnwrap(emailField.value as? String), "your.email@example.org") XCTAssertEqual(try XCTUnwrap(messageTextView.value as? String), "", "The UITextView shouldn't have any initial text functioning as a placeholder; as UITextView has no placeholder property, the \"placeholder\" is a label on top of it.") + + cancelButton.tap() + + app.buttons["Extra"].tap() + app.buttons["io.sentry.ui-test.button.get-latest-envelope"].tap() + let marshaledDataBase64 = try XCTUnwrap(app.textFields["io.sentry.ui-test.text-field.data-marshaling.latest-envelope"].value as? String) + let data = try XCTUnwrap(Data(base64Encoded: marshaledDataBase64)) + let dict = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(try XCTUnwrap(dict["event_type"] as? String), "feedback") + XCTAssertEqual(try XCTUnwrap(dict["message"] as? String), testMessage) + XCTAssertEqual(try XCTUnwrap(dict["contact_email"] as? String), testContactEmail) + XCTAssertEqual(try XCTUnwrap(dict["source"] as? String), "widget") + XCTAssertEqual(try XCTUnwrap(dict["name"] as? String), testName) + XCTAssertNotNil(dict["event_id"]) + XCTAssertEqual(try XCTUnwrap(dict["item_header_type"] as? String), "feedback") } func testSubmitWithOnlyRequiredFieldsFilled() { diff --git a/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme b/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme index 6e1f1501d94..cdd03802f82 100644 --- a/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme +++ b/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme @@ -73,6 +73,30 @@ argument = "--io.sentry.disable-everything" isEnabled = "NO"> + + + + + + + + + + + + @@ -240,11 +264,6 @@ value = "" isEnabled = "NO"> - - Bool { - args.contains("--disable-everything") || args.contains(arg) + args.contains("--io.sentry.disable-everything") || args.contains(arg) } // MARK: features that care about simulator vs device, ui tests and profiling benchmarks @@ -312,7 +312,7 @@ extension AppDelegate { /// - note: the benchmark test starts and stops a custom transaction using a UIButton, and automatic user interaction tracing stops the transaction that begins with that button press after the idle timeout elapses, stopping the profiler (only one profiler runs regardless of the number of concurrent transactions) var enableUITracing: Bool { !isBenchmarking && !checkDisabled(with: "--disable-ui-tracing") } - var enablePrewarmedAppStartTracing: Bool { !isBenchmarking } + var enablePrewarmedAppStartTracing: Bool { !isBenchmarking && !checkDisabled(with: "--disable-prewarmed-app-start-tracing") } var enablePerformanceTracing: Bool { !isBenchmarking && !checkDisabled(with: "--disable-auto-performance-tracing") } var enableTracing: Bool { !isBenchmarking && !checkDisabled(with: "--disable-tracing") } /// - note: UI tests generate false OOMs @@ -322,6 +322,10 @@ extension AppDelegate { // MARK: Other features + var enableTimeToFullDisplayTracing: Bool { !checkDisabled(with: "--disable-time-to-full-display-tracing")} + var enableAttachScreenshot: Bool { !checkDisabled(with: "--disable-attach-screenshot")} + var enableAttachViewHierarchy: Bool { !checkDisabled(with: "--disable-attach-view-hierarchy")} + var enablePerformanceV2: Bool { !checkDisabled(with: "--disable-performance-v2")} var enableSessionReplay: Bool { !checkDisabled(with: "--disable-session-replay") } var enableMetricKit: Bool { !checkDisabled(with: "--disable-metrickit-integration") } var enableSessionTracking: Bool { !checkDisabled(with: "--disable-automatic-session-tracking") } diff --git a/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard b/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard index 30fe801f7c5..9d20ad7e705 100644 --- a/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard +++ b/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard @@ -899,221 +899,267 @@ - - + + - - + + - - + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + - + - + + - - - + + @@ -1121,7 +1167,10 @@ + + + @@ -1280,7 +1329,7 @@ - + diff --git a/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift b/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift index ab35ec59664..29955471085 100644 --- a/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift @@ -9,7 +9,10 @@ class ExtraViewController: UIViewController { @IBOutlet weak var uiTestNameLabel: UILabel! @IBOutlet weak var anrFullyBlockingButton: UIButton! @IBOutlet weak var anrFillingRunLoopButton: UIButton! - + @IBOutlet weak var envelopeDataMarshalingField: UITextField! + @IBOutlet weak var dataMarshalingStatusLabel: UILabel! + @IBOutlet weak var dataMarshalingErrorLabel: UILabel! + @IBOutlet weak var dsnView: UIView! private let dispatchQueue = DispatchQueue(label: "ExtraViewControllers", attributes: .concurrent) @@ -17,6 +20,7 @@ class ExtraViewController: UIViewController { super.viewDidLoad() if let uiTestName = ProcessInfo.processInfo.environment["--io.sentry.ui-test.test-name"] { uiTestNameLabel.text = uiTestName + uiTestNameLabel.isHidden = false } Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in @@ -185,4 +189,100 @@ class ExtraViewController: UIViewController { return pi } + + enum EnvelopeContent { + case image(Data) + case rawText(String) + case json([String: Any]) + } + + func displayError(message: String) { + dataMarshalingStatusLabel.isHidden = false + dataMarshalingStatusLabel.text = "❌" + dataMarshalingErrorLabel.isHidden = false + dataMarshalingErrorLabel.text = message + print("[iOS-Swift] \(message)") + } + + @IBAction func getLatestEnvelope(_ sender: Any) { + guard let latestEnvelopePath = latestEnvelopePath() else { return } + guard let base64String = base64EncodedStructuredUITestData(envelopePath: latestEnvelopePath) else { return } + envelopeDataMarshalingField.text = base64String + envelopeDataMarshalingField.isHidden = false + dataMarshalingStatusLabel.isHidden = false + dataMarshalingStatusLabel.text = "✅" + dataMarshalingErrorLabel.isHidden = true + } + + func latestEnvelopePath() -> String? { + guard let cachesDirectory = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first else { + displayError(message: "No user caches directory found on device.") + return nil + } + let fm = FileManager.default + guard let dsnHash = try? SentryDsn(string: AppDelegate.defaultDSN).getHash() else { + displayError(message: "Couldn't compute DSN hash.") + return nil + } + let dir = "\(cachesDirectory)/io.sentry/\(dsnHash)/envelopes" + guard let contents = try? fm.contentsOfDirectory(atPath: dir) else { + displayError(message: "\(dir) has no contents.") + return nil + } + guard let latest = contents.compactMap({ path -> (String, Date)? in + guard let attr = try? fm.attributesOfItem(atPath: "\(dir)/\(path)"), let date = attr[FileAttributeKey.modificationDate] as? Date else { + return nil + } + return (path, date) + }).sorted(by: { a, b in + return a.1.compare(b.1) == .orderedAscending + }).last else { + displayError(message: "Could not find any envelopes in \(dir).") + return nil + } + return "\(dir)/\(latest.0)" + } + + func base64EncodedStructuredUITestData(envelopePath: String) -> String? { + guard let envelopeFileContents = try? String(contentsOfFile: envelopePath) else { + displayError(message: "\(envelopePath) had no contents.") + return nil + } + let parsedEnvelopeContents = envelopeFileContents.split(separator: "\n").map { line in + if let imageData = Data(base64Encoded: String(line), options: []) { + return EnvelopeContent.image(imageData) + } else if let data = line.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return EnvelopeContent.json(json) + } else { + return EnvelopeContent.rawText(String(line)) + } + } + let contentsForUITest = parsedEnvelopeContents.reduce(into: [String: Any]()) { result, item in + if case let .json(json) = item { + insertValues(from: json, into: &result) + } + } + guard let data = try? JSONSerialization.data(withJSONObject: contentsForUITest) else { + displayError(message: "Couldn't serialize marshaling dictionary.") + return nil + } + + return data.base64EncodedString() + } + + func insertValues(from json: [String: Any], into result: inout [String: Any]) { + if let eventContexts = json["contexts"] as? [String: Any] { + result["event_type"] = json["type"] + if let feedback = eventContexts["feedback"] as? [String: Any] { + result["message"] = feedback["message"] + result["contact_email"] = feedback["contact_email"] + result["source"] = feedback["source"] + result["name"] = feedback["name"] + } + } else if let itemHeaderEventId = json["event_id"] { + result["event_id"] = itemHeaderEventId + } else if let _ = json["length"], let type = json["type"] as? String, type == "feedback" { + result["item_header_type"] = json["type"] + } + } } diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 461568e8703..2fe7e8bafe5 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -656,6 +656,7 @@ 84354E1229BF944900CDBB8B /* SentryProfileTimeseries.mm in Sources */ = {isa = PBXBuildFile; fileRef = 84354E1029BF944900CDBB8B /* SentryProfileTimeseries.mm */; }; 843FB3232D0CD04D00558F18 /* SentryUserAccess.m in Sources */ = {isa = PBXBuildFile; fileRef = 843FB3222D0CD04D00558F18 /* SentryUserAccess.m */; }; 843FB3242D0CD04D00558F18 /* SentryUserAccess.h in Headers */ = {isa = PBXBuildFile; fileRef = 843FB3212D0CD04D00558F18 /* SentryUserAccess.h */; }; + 843FB3432D156B9900558F18 /* SentryFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843FB3412D156B9900558F18 /* SentryFeedbackTests.swift */; }; 844EDC6F294143B900C86F34 /* SentryNSProcessInfoWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = 844EDC6D294143B900C86F34 /* SentryNSProcessInfoWrapper.h */; }; 844EDC70294143B900C86F34 /* SentryNSProcessInfoWrapper.mm in Sources */ = {isa = PBXBuildFile; fileRef = 844EDC6E294143B900C86F34 /* SentryNSProcessInfoWrapper.mm */; }; 844EDC76294144DB00C86F34 /* SentrySystemWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = 844EDC74294144DB00C86F34 /* SentrySystemWrapper.h */; }; @@ -714,6 +715,7 @@ 84CFA4CA2C9DF884008DA5F4 /* SentryUserFeedbackWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CFA4C92C9DF884008DA5F4 /* SentryUserFeedbackWidget.swift */; }; 84CFA4CD2C9E0CA3008DA5F4 /* SentryUserFeedbackIntegration.m in Sources */ = {isa = PBXBuildFile; fileRef = 84CFA4CC2C9E0CA3008DA5F4 /* SentryUserFeedbackIntegration.m */; }; 84CFA4CE2C9E0CA3008DA5F4 /* SentryUserFeedbackIntegration.h in Headers */ = {isa = PBXBuildFile; fileRef = 84CFA4CB2C9E0CA3008DA5F4 /* SentryUserFeedbackIntegration.h */; }; + 84DBC62C2CE82F12000C4904 /* SentryFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DBC62B2CE82F0E000C4904 /* SentryFeedback.swift */; }; 84DEE86B2B686BD400A7BC17 /* SentrySamplerDecision.h in Headers */ = {isa = PBXBuildFile; fileRef = 84DEE86A2B686BD400A7BC17 /* SentrySamplerDecision.h */; }; 84DEE8762B69AD6400A7BC17 /* SentryLaunchProfiling.h in Headers */ = {isa = PBXBuildFile; fileRef = 84DEE8752B69AD6400A7BC17 /* SentryLaunchProfiling.h */; }; 84E13B842CBF1D91003B52EC /* SentryUserFeedbackWidgetButtonMegaphoneIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E13B832CBF1D91003B52EC /* SentryUserFeedbackWidgetButtonMegaphoneIconView.swift */; }; @@ -1710,6 +1712,7 @@ 843BD6282AD8752300B0098F /* .clang-format */ = {isa = PBXFileReference; lastKnownFileType = text; path = ".clang-format"; sourceTree = ""; }; 843FB3212D0CD04D00558F18 /* SentryUserAccess.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SentryUserAccess.h; sourceTree = ""; }; 843FB3222D0CD04D00558F18 /* SentryUserAccess.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryUserAccess.m; sourceTree = ""; }; + 843FB3412D156B9900558F18 /* SentryFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryFeedbackTests.swift; sourceTree = ""; }; 8446F5182BE172290040D57E /* SentryContinuousProfilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryContinuousProfilerTests.swift; sourceTree = ""; }; 844A34C3282B278500C6D1DF /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = ""; }; 844A3563282B3C9F00C6D1DF /* .sauce */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .sauce; sourceTree = ""; }; @@ -1789,6 +1792,7 @@ 84CFA4C92C9DF884008DA5F4 /* SentryUserFeedbackWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUserFeedbackWidget.swift; sourceTree = ""; }; 84CFA4CB2C9E0CA3008DA5F4 /* SentryUserFeedbackIntegration.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryUserFeedbackIntegration.h; path = ../../../Sentry/include/SentryUserFeedbackIntegration.h; sourceTree = ""; }; 84CFA4CC2C9E0CA3008DA5F4 /* SentryUserFeedbackIntegration.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = SentryUserFeedbackIntegration.m; path = ../../../Sentry/SentryUserFeedbackIntegration.m; sourceTree = ""; }; + 84DBC62B2CE82F0E000C4904 /* SentryFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryFeedback.swift; sourceTree = ""; }; 84DEE86A2B686BD400A7BC17 /* SentrySamplerDecision.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentrySamplerDecision.h; path = include/SentrySamplerDecision.h; sourceTree = ""; }; 84DEE8752B69AD6400A7BC17 /* SentryLaunchProfiling.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryLaunchProfiling.h; path = Sources/Sentry/include/SentryLaunchProfiling.h; sourceTree = SOURCE_ROOT; }; 84E13B832CBF1D91003B52EC /* SentryUserFeedbackWidgetButtonMegaphoneIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUserFeedbackWidgetButtonMegaphoneIconView.swift; sourceTree = ""; }; @@ -2977,6 +2981,7 @@ 7B944FA924697E9700A10721 /* Integrations */ = { isa = PBXGroup; children = ( + 843FB3422D156B9900558F18 /* Feedback */, 7BF6505D292B77D100BBA5A8 /* MetricKit */, D808FB85281AB2EF009A2A33 /* UIEvents */, D8AB40D92806EBDC00E5E9F7 /* Screenshot */, @@ -3530,6 +3535,14 @@ path = SentryTestUtils; sourceTree = ""; }; + 843FB3422D156B9900558F18 /* Feedback */ = { + isa = PBXGroup; + children = ( + 843FB3412D156B9900558F18 /* SentryFeedbackTests.swift */, + ); + path = Feedback; + sourceTree = ""; + }; 8459FCC62BD86C9E0038E9C9 /* Recovered References */ = { isa = PBXGroup; children = ( @@ -3542,6 +3555,7 @@ children = ( 849B8F9E2C70091A00148E1F /* Configuration */, 849B8F962C6E906900148E1F /* SentryUserFeedbackIntegrationDriver.swift */, + 84DBC62B2CE82F0E000C4904 /* SentryFeedback.swift */, 84CFA4CB2C9E0CA3008DA5F4 /* SentryUserFeedbackIntegration.h */, 84CFA4CC2C9E0CA3008DA5F4 /* SentryUserFeedbackIntegration.m */, 84CFA4C92C9DF884008DA5F4 /* SentryUserFeedbackWidget.swift */, @@ -4589,6 +4603,7 @@ 7B6438AB26A70F24000D0F65 /* UIViewController+Sentry.m in Sources */, 84302A812B5767A50027A629 /* SentryLaunchProfiling.m in Sources */, 63AA76A31EB9CBAA00D153DE /* SentryDsn.m in Sources */, + 84DBC62C2CE82F12000C4904 /* SentryFeedback.swift in Sources */, 63B818FA1EC34639002FDF4C /* SentryDebugMeta.m in Sources */, 7B98D7D325FB65AE00C5A389 /* SentryWatchdogTerminationTracker.m in Sources */, 8E564AE8267AF22600FE117D /* SentryNetworkTrackingIntegration.m in Sources */, @@ -5046,6 +5061,7 @@ 7BD4BD4B27EB2DC20071F4FF /* SentryDiscardedEventTests.swift in Sources */, 63FE721A20DA66EC00CDBAE8 /* SentryCrashSysCtl_Tests.m in Sources */, 7B88F30424BC8E6500ADF90A /* SentrySerializationTests.swift in Sources */, + 843FB3432D156B9900558F18 /* SentryFeedbackTests.swift in Sources */, D80694C42B7CC9AE00B820E6 /* SentryReplayEventTests.swift in Sources */, 7B34721728086A9D0041F047 /* SentrySwizzleWrapperTests.swift in Sources */, 8EC4CF5025C3A0070093DEE9 /* SentrySpanContextTests.swift in Sources */, diff --git a/SentryTestUtils/TestTransportAdapter.swift b/SentryTestUtils/TestTransportAdapter.swift index c8325a0b212..70d3c8e87b9 100644 --- a/SentryTestUtils/TestTransportAdapter.swift +++ b/SentryTestUtils/TestTransportAdapter.swift @@ -2,10 +2,6 @@ import _SentryPrivate import Foundation public class TestTransportAdapter: SentryTransportAdapter { - public override func send(_ event: Event, session: SentrySession, attachments: [Attachment]) { - self.send(event, with: session, traceContext: nil, attachments: attachments) - } - public var sentEventsWithSessionTraceState = Invocations<(event: Event, session: SentrySession, traceContext: TraceContext?, attachments: [Attachment])>() public override func send(_ event: Event, with session: SentrySession, traceContext: TraceContext?, attachments: [Attachment]) { sentEventsWithSessionTraceState.record((event, session, traceContext, attachments)) diff --git a/Sources/Sentry/Public/SentrySDK.h b/Sources/Sentry/Public/SentrySDK.h index a7f8295417a..b93a8243349 100644 --- a/Sources/Sentry/Public/SentrySDK.h +++ b/Sources/Sentry/Public/SentrySDK.h @@ -8,6 +8,7 @@ @class SentryBreadcrumb; @class SentryEvent; +@class SentryFeedback; @class SentryId; @class SentryMetricsAPI; @class SentryOptions; diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index 5f0f146e41a..49ef2445781 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -458,13 +458,7 @@ - (SentryId *)sendEvent:(SentryEvent *)event SentryTraceContext *traceContext = [self getTraceStateWithEvent:event withScope:scope]; - NSArray *attachments = scope.attachments; - if (self.attachmentProcessors.count) { - for (id attachmentProcessor in self.attachmentProcessors) { - attachments = [attachmentProcessor processAttachments:attachments - forEvent:preparedEvent]; - } - } + NSArray *attachments = [self attachmentsForEvent:preparedEvent scope:scope]; [self.transportAdapter sendEvent:preparedEvent traceContext:traceContext @@ -474,18 +468,23 @@ - (SentryId *)sendEvent:(SentryEvent *)event return preparedEvent.eventId; } +- (NSArray *)attachmentsForEvent:(SentryEvent *)event scope:(SentryScope *)scope +{ + NSArray *attachments = scope.attachments; + if (self.attachmentProcessors.count) { + for (id attachmentProcessor in self.attachmentProcessors) { + attachments = [attachmentProcessor processAttachments:attachments forEvent:event]; + } + } + return attachments; +} + - (SentryId *)sendEvent:(SentryEvent *)event withSession:(SentrySession *)session withScope:(SentryScope *)scope { if (nil != event) { - NSArray *attachments = scope.attachments; - if (self.attachmentProcessors.count) { - for (id attachmentProcessor in self - .attachmentProcessors) { - attachments = [attachmentProcessor processAttachments:attachments forEvent:event]; - } - } + NSArray *attachments = [self attachmentsForEvent:event scope:scope]; if (event.isCrashEvent && event.context[@"replay"] && [event.context[@"replay"] isKindOfClass:NSDictionary.class]) { @@ -586,6 +585,39 @@ - (void)captureUserFeedback:(SentryUserFeedback *)userFeedback [self.transportAdapter sendUserFeedback:userFeedback]; } +- (void)captureFeedback:(SentryFeedback *)feedback withScope:(SentryScope *)scope +{ + SentryEvent *feedbackEvent = [[SentryEvent alloc] init]; + feedbackEvent.eventId = feedback.eventId; + feedbackEvent.type = SentryEnvelopeItemTypeFeedback; + + NSDictionary *serializedFeedback = [feedback serialize]; + + NSUInteger optionalItems = (scope.span == nil ? 0 : 1) + (scope.replayId == nil ? 0 : 1); + NSMutableDictionary *context = [NSMutableDictionary dictionaryWithCapacity:1 + optionalItems]; + context[@"feedback"] = serializedFeedback; + + if (scope.replayId != nil) { + NSMutableDictionary *replayContext = [NSMutableDictionary dictionaryWithCapacity:1]; + replayContext[@"replay_id"] = scope.replayId; + context[@"replay"] = replayContext; + } + + feedbackEvent.context = context; + + SentryEvent *preparedEvent = [self prepareEvent:feedbackEvent + withScope:scope + alwaysAttachStacktrace:NO]; + SentryTraceContext *traceContext = [self getTraceStateWithEvent:preparedEvent withScope:scope]; + NSArray *attachments = [[self attachmentsForEvent:preparedEvent scope:scope] + arrayByAddingObjectsFromArray:[feedback attachments]]; + + [self.transportAdapter sendEvent:preparedEvent + traceContext:traceContext + attachments:attachments + additionalEnvelopeItems:@[]]; +} + - (void)storeEnvelope:(SentryEnvelope *)envelope { [self.fileManager storeEnvelope:envelope]; @@ -644,6 +676,8 @@ - (SentryEvent *_Nullable)prepareEvent:(SentryEvent *)event = event.type == nil || ![event.type isEqualToString:SentryEnvelopeItemTypeTransaction]; BOOL eventIsNotReplay = event.type == nil || ![event.type isEqualToString:SentryEnvelopeItemTypeReplayVideo]; + BOOL eventIsNotUserFeedback + = event.type == nil || ![event.type isEqualToString:SentryEnvelopeItemTypeFeedback]; // Transactions and replays have their own sampleRate if (eventIsNotATransaction && eventIsNotReplay && [self isSampled:self.options.sampleRate]) { @@ -673,8 +707,9 @@ - (SentryEvent *_Nullable)prepareEvent:(SentryEvent *)event [self setSdk:event]; - // We don't want to attach debug meta and stacktraces for transactions and replays. - if (eventIsNotATransaction && eventIsNotReplay) { + // We don't want to attach debug meta and stacktraces for transactions, replays or user + // feedback. + if (eventIsNotATransaction && eventIsNotReplay && eventIsNotUserFeedback) { BOOL shouldAttachStacktrace = alwaysAttachStacktrace || self.options.attachStacktrace || (nil != event.exceptions && [event.exceptions count] > 0); diff --git a/Sources/Sentry/SentryDataCategoryMapper.m b/Sources/Sentry/SentryDataCategoryMapper.m index 2af6068f1fc..9899ec1d7c4 100644 --- a/Sources/Sentry/SentryDataCategoryMapper.m +++ b/Sources/Sentry/SentryDataCategoryMapper.m @@ -18,6 +18,7 @@ NSString *const kSentryDataCategoryNameReplay = @"replay"; NSString *const kSentryDataCategoryNameMetricBucket = @"metric_bucket"; NSString *const kSentryDataCategoryNameSpan = @"span"; +NSString *const kSentryDataCategoryNameFeedback = @"feedback"; NSString *const kSentryDataCategoryNameUnknown = @"unknown"; NS_ASSUME_NONNULL_BEGIN @@ -46,6 +47,9 @@ if ([itemType isEqualToString:SentryEnvelopeItemTypeReplayVideo]) { return kSentryDataCategoryReplay; } + if ([itemType isEqualToString:SentryEnvelopeItemTypeFeedback]) { + return kSentryDataCategoryFeedback; + } // The envelope item type used for metrics is statsd whereas the client report category for // discarded events is metric_bucket. if ([itemType isEqualToString:SentryEnvelopeItemTypeStatsd]) { @@ -104,6 +108,9 @@ if ([value isEqualToString:kSentryDataCategoryNameSpan]) { return kSentryDataCategorySpan; } + if ([value isEqualToString:kSentryDataCategoryNameFeedback]) { + return kSentryDataCategoryFeedback; + } return kSentryDataCategoryUnknown; } @@ -111,13 +118,10 @@ NSString * nameForSentryDataCategory(SentryDataCategory category) { - if (category < kSentryDataCategoryAll && category > kSentryDataCategoryUnknown) { - return kSentryDataCategoryNameUnknown; - } - switch (category) { case kSentryDataCategoryAll: return kSentryDataCategoryNameAll; + case kSentryDataCategoryDefault: return kSentryDataCategoryNameDefault; case kSentryDataCategoryError: @@ -136,12 +140,16 @@ return kSentryDataCategoryNameProfileChunk; case kSentryDataCategoryMetricBucket: return kSentryDataCategoryNameMetricBucket; - case kSentryDataCategoryUnknown: - return kSentryDataCategoryNameUnknown; case kSentryDataCategoryReplay: return kSentryDataCategoryNameReplay; case kSentryDataCategorySpan: return kSentryDataCategoryNameSpan; + case kSentryDataCategoryFeedback: + return kSentryDataCategoryNameFeedback; + + default: // !!!: fall-through! + case kSentryDataCategoryUnknown: + return kSentryDataCategoryNameUnknown; } } diff --git a/Sources/Sentry/SentryEnvelope.m b/Sources/Sentry/SentryEnvelope.m index 8cfefa0ffc2..9b0f6f52818 100644 --- a/Sources/Sentry/SentryEnvelope.m +++ b/Sources/Sentry/SentryEnvelope.m @@ -93,6 +93,8 @@ - (instancetype)initWithEvent:(SentryEvent *)event // default. In any case in the envelope type it should be event. Except for transactions NSString *envelopeType = [event.type isEqualToString:SentryEnvelopeItemTypeTransaction] ? SentryEnvelopeItemTypeTransaction + : [event.type isEqualToString:SentryEnvelopeItemTypeFeedback] + ? SentryEnvelopeItemTypeFeedback : SentryEnvelopeItemTypeEvent; return [self initWithHeader:[[SentryEnvelopeItemHeader alloc] initWithType:envelopeType @@ -159,7 +161,17 @@ - (_Nullable instancetype)initWithAttachment:(SentryAttachment *)attachment return nil; } +#if DEBUG || TEST || TESTCI + if ([NSProcessInfo.processInfo.arguments + containsObject:@"--io.sentry.base64-attachment-data"]) { + data = [[attachment.data base64EncodedStringWithOptions:0] + dataUsingEncoding:NSUTF8StringEncoding]; + } else { + data = attachment.data; + } +#else data = attachment.data; +#endif // DEBUG || TEST || TESTCI } else if (nil != attachment.path) { NSError *error = nil; @@ -184,7 +196,17 @@ - (_Nullable instancetype)initWithAttachment:(SentryAttachment *)attachment return nil; } +#if DEBUG || TEST || TESTCI + if ([NSProcessInfo.processInfo.arguments + containsObject:@"--io.sentry.base64-attachment-data"]) { + data = [[[[NSFileManager defaultManager] contentsAtPath:attachment.path] + base64EncodedStringWithOptions:0] dataUsingEncoding:NSUTF8StringEncoding]; + } else { + data = [[NSFileManager defaultManager] contentsAtPath:attachment.path]; + } +#else data = [[NSFileManager defaultManager] contentsAtPath:attachment.path]; +#endif // DEBUG || TEST || TESTCI } if (data == nil) { diff --git a/Sources/Sentry/SentryHttpTransport.m b/Sources/Sentry/SentryHttpTransport.m index ae71cf89dc4..72c13ca8baf 100644 --- a/Sources/Sentry/SentryHttpTransport.m +++ b/Sources/Sentry/SentryHttpTransport.m @@ -289,10 +289,14 @@ - (void)sendAllCachedEnvelopes SENTRY_LOG_DEBUG(@"sendAllCachedEnvelopes start."); @synchronized(self) { - if (self.isSending || ![self.requestManager isReady]) { + if (self.isSending) { SENTRY_LOG_DEBUG(@"Already sending."); return; } + if (![self.requestManager isReady]) { + SENTRY_LOG_DEBUG(@"Request manager not ready."); + return; + } self.isSending = YES; } diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index a83f66c71c0..86f584962f7 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -504,6 +504,14 @@ - (void)captureUserFeedback:(SentryUserFeedback *)userFeedback } } +- (void)captureFeedback:(SentryFeedback *)feedback +{ + SentryClient *client = _client; + if (client != nil) { + [client captureFeedback:feedback withScope:self.scope]; + } +} + - (void)addBreadcrumb:(SentryBreadcrumb *)crumb { SentryOptions *options = [[self client] options]; diff --git a/Sources/Sentry/SentryQueueableRequestManager.m b/Sources/Sentry/SentryQueueableRequestManager.m index 04b405c8375..d70a95d7da4 100644 --- a/Sources/Sentry/SentryQueueableRequestManager.m +++ b/Sources/Sentry/SentryQueueableRequestManager.m @@ -27,6 +27,20 @@ - (instancetype)initWithSession:(NSURLSession *)session - (BOOL)isReady { +#if TEST || TESTCI + // force every envelope to be cached in UI tests so we can inspect what the SDK would've sent + // for a given operation + if ([NSProcessInfo.processInfo.environment[@"--io.sentry.sdk-environment"] + isEqualToString:@"ui-tests"]) { + return NO; + } +#elif DEBUG + if ([NSProcessInfo.processInfo.arguments + containsObject:@"--io.sentry.disable-http-transport"]) { + return NO; + } +#endif // TEST || TESTCI + // We always have at least one operation in the queue when calling this return self.queue.operationCount <= 1; } diff --git a/Sources/Sentry/SentrySDK.m b/Sources/Sentry/SentrySDK.m index e77108f05de..44632a8125a 100644 --- a/Sources/Sentry/SentrySDK.m +++ b/Sources/Sentry/SentrySDK.m @@ -405,10 +405,17 @@ + (void)captureUserFeedback:(SentryUserFeedback *)userFeedback [SentrySDK.currentHub captureUserFeedback:userFeedback]; } ++ (void)captureFeedback:(SentryFeedback *)feedback +{ + [SentrySDK.currentHub captureFeedback:feedback]; +} + +#if TARGET_OS_IOS && SENTRY_HAS_UIKIT + (void)showUserFeedbackForm { // TODO: implement } +#endif // TARGET_OS_IOS && SENTRY_HAS_UIKIT + (void)addBreadcrumb:(SentryBreadcrumb *)crumb { diff --git a/Sources/Sentry/SentryUserFeedbackIntegration.m b/Sources/Sentry/SentryUserFeedbackIntegration.m index 9bf8b13dcfb..8f213dbad7f 100644 --- a/Sources/Sentry/SentryUserFeedbackIntegration.m +++ b/Sources/Sentry/SentryUserFeedbackIntegration.m @@ -1,9 +1,13 @@ #import "SentryUserFeedbackIntegration.h" #import "SentryOptions+Private.h" +#import "SentrySDK+Private.h" #import "SentrySwift.h" #if TARGET_OS_IOS && SENTRY_HAS_UIKIT +@interface SentryUserFeedbackIntegration () +@end + @implementation SentryUserFeedbackIntegration { SentryUserFeedbackIntegrationDriver *_driver; } @@ -15,10 +19,18 @@ - (BOOL)installWithOptions:(SentryOptions *)options } _driver = [[SentryUserFeedbackIntegrationDriver alloc] - initWithConfiguration:options.userFeedbackConfiguration]; + initWithConfiguration:options.userFeedbackConfiguration + delegate:self]; return YES; } +// MARK: SentryUserFeedbackIntegrationDriverDelegate + +- (void)captureWithFeedback:(SentryFeedback *)feedback +{ + [SentrySDK captureFeedback:feedback]; +} + @end #endif // TARGET_OS_IOS && SENTRY_HAS_UIKIT diff --git a/Sources/Sentry/include/HybridPublic/SentryEnvelope.h b/Sources/Sentry/include/HybridPublic/SentryEnvelope.h index b1be5d43da9..8414cc16510 100644 --- a/Sources/Sentry/include/HybridPublic/SentryEnvelope.h +++ b/Sources/Sentry/include/HybridPublic/SentryEnvelope.h @@ -16,13 +16,14 @@ #endif -@class SentryEvent; -@class SentrySession; -@class SentryId; -@class SentryUserFeedback; @class SentryAttachment; @class SentryEnvelopeItemHeader; +@class SentryEvent; +@class SentryFeedback; +@class SentryId; +@class SentrySession; @class SentryTraceContext; +@class SentryUserFeedback; NS_ASSUME_NONNULL_BEGIN diff --git a/Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h b/Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h index e7747588cca..3499a206150 100644 --- a/Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h +++ b/Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h @@ -6,6 +6,7 @@ static NSString *const SentryEnvelopeItemTypeEvent = @"event"; static NSString *const SentryEnvelopeItemTypeSession = @"session"; static NSString *const SentryEnvelopeItemTypeUserFeedback = @"user_report"; +static NSString *const SentryEnvelopeItemTypeFeedback = @"feedback"; static NSString *const SentryEnvelopeItemTypeTransaction = @"transaction"; static NSString *const SentryEnvelopeItemTypeAttachment = @"attachment"; static NSString *const SentryEnvelopeItemTypeClientReport = @"client_report"; diff --git a/Sources/Sentry/include/SentryClient+Private.h b/Sources/Sentry/include/SentryClient+Private.h index 7e70ab22974..61dff8034f7 100644 --- a/Sources/Sentry/include/SentryClient+Private.h +++ b/Sources/Sentry/include/SentryClient+Private.h @@ -1,9 +1,11 @@ #import "SentryClient.h" #import "SentryDataCategory.h" #import "SentryDiscardReason.h" + @class SentryAttachment; @class SentryEnvelope; @class SentryEnvelopeItem; +@class SentryFeedback; @class SentryId; @class SentryReplayEvent; @class SentryReplayRecording; @@ -72,6 +74,13 @@ NS_ASSUME_NONNULL_BEGIN - (void)addAttachmentProcessor:(id)attachmentProcessor; - (void)removeAttachmentProcessor:(id)attachmentProcessor; +/** + * Captures a new-style user feedback and sends it to Sentry. + * @param feedback The user feedback to send to Sentry. + */ +- (void)captureFeedback:(SentryFeedback *)feedback + withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(feedback:scope:)); + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryDataCategory.h b/Sources/Sentry/include/SentryDataCategory.h index 3a384add2cb..63b18153c1b 100644 --- a/Sources/Sentry/include/SentryDataCategory.h +++ b/Sources/Sentry/include/SentryDataCategory.h @@ -18,5 +18,6 @@ typedef NS_ENUM(NSUInteger, SentryDataCategory) { kSentryDataCategoryReplay = 9, kSentryDataCategoryProfileChunk = 10, kSentryDataCategorySpan = 11, - kSentryDataCategoryUnknown = 12, + kSentryDataCategoryFeedback = 12, + kSentryDataCategoryUnknown = 13, }; diff --git a/Sources/Sentry/include/SentryDataCategoryMapper.h b/Sources/Sentry/include/SentryDataCategoryMapper.h index 677996907e2..3f9b6bbddc8 100644 --- a/Sources/Sentry/include/SentryDataCategoryMapper.h +++ b/Sources/Sentry/include/SentryDataCategoryMapper.h @@ -13,6 +13,7 @@ FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameUserFeedback; FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameProfile; FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameProfileChunk; FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameReplay; +FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameFeedback; FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameMetricBucket; FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameSpan; FOUNDATION_EXPORT NSString *const kSentryDataCategoryNameUnknown; diff --git a/Sources/Sentry/include/SentryHub+Private.h b/Sources/Sentry/include/SentryHub+Private.h index 531ec23b6da..bb4a88662f1 100644 --- a/Sources/Sentry/include/SentryHub+Private.h +++ b/Sources/Sentry/include/SentryHub+Private.h @@ -6,6 +6,7 @@ @class SentryTransaction; @class SentryDispatchQueueWrapper; @class SentryEnvelope; +@class SentryFeedback; @class SentryNSTimerFactory; @class SentrySession; @class SentryTracer; @@ -63,6 +64,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)storeEnvelope:(SentryEnvelope *)envelope; - (void)captureEnvelope:(SentryEnvelope *)envelope; +- (void)captureFeedback:(SentryFeedback *)feedback; - (void)registerSessionListener:(id)listener; - (void)unregisterSessionListener:(id)listener; diff --git a/Sources/Sentry/include/SentryOptions+Private.h b/Sources/Sentry/include/SentryOptions+Private.h index 7748f635764..932417b530a 100644 --- a/Sources/Sentry/include/SentryOptions+Private.h +++ b/Sources/Sentry/include/SentryOptions+Private.h @@ -21,6 +21,7 @@ FOUNDATION_EXPORT NSString *const kSentryDefaultEnvironment; - (BOOL)isContinuousProfilingEnabled; #endif // SENTRY_TARGET_PROFILING_SUPPORTED +#if TARGET_OS_IOS && SENTRY_HAS_UIKIT /** * A block that can be defined that receives a user feedback configuration object to modify. * @warning This is an experimental feature and may still have bugs. @@ -31,6 +32,7 @@ FOUNDATION_EXPORT NSString *const kSentryDefaultEnvironment; */ @property (nonatomic, copy, nullable) SentryUserFeedbackConfigurationBlock configureUserFeedback API_AVAILABLE(ios(13.0)); +#endif // TARGET_OS_IOS && SENTRY_HAS_UIKIT @property (nonatomic, readonly, class) NSArray *defaultIntegrationClasses; diff --git a/Sources/Sentry/include/SentryTransportAdapter.h b/Sources/Sentry/include/SentryTransportAdapter.h index 05a19f6d9a8..c94bd77ad42 100644 --- a/Sources/Sentry/include/SentryTransportAdapter.h +++ b/Sources/Sentry/include/SentryTransportAdapter.h @@ -7,6 +7,7 @@ @class SentryEnvelope; @class SentryEnvelopeItem; @class SentryEvent; +@class SentryFeedback; @class SentryOptions; @class SentrySession; @class SentryTraceContext; diff --git a/Sources/Swift/Integrations/UserFeedback/SentryFeedback.swift b/Sources/Swift/Integrations/UserFeedback/SentryFeedback.swift new file mode 100644 index 00000000000..33140e1e203 --- /dev/null +++ b/Sources/Swift/Integrations/UserFeedback/SentryFeedback.swift @@ -0,0 +1,63 @@ +@_implementationOnly import _SentryPrivate +import Foundation + +@objcMembers +class SentryFeedback: NSObject, SentrySerializable { + enum Source: String { + case widget + case custom + } + + var name: String? + var email: String? + var message: String + var source: Source + let eventId: SentryId + + /// PNG data for the screenshot image + var screenshot: Data? + + /// The event id that this feedback is associated with, like a crash report. + var associatedEventId: String? + + /// - parameter screenshot Image encoded as PNG data. + init(message: String, name: String?, email: String?, source: Source = .widget, associatedEventId: String? = nil, screenshot: Data? = nil) { + self.eventId = SentryId() + self.name = name + self.email = email + self.message = message + self.source = source + self.associatedEventId = associatedEventId + self.screenshot = screenshot + super.init() + } + + func serialize() -> [String: Any] { + let numberOfOptionalItems = (name == nil ? 0 : 1) + (email == nil ? 0 : 1) + (associatedEventId == nil ? 0 : 1) + var dict = [String: Any](minimumCapacity: 2 + numberOfOptionalItems) + dict["message"] = message + if let name = name { + dict["name"] = name + } + if let email = email { + dict["contact_email"] = email + } + if let associatedEventId = associatedEventId { + dict["associated_event_id"] = associatedEventId + } + dict["source"] = source.rawValue + + return dict + } + + /** + * - note: Currently there is only a single attachment possible, for the screenshot, of which there can be only one. + */ + func attachments() -> [Attachment] { + var items = [Attachment]() + if let screenshot = screenshot { + items.append(Attachment(data: screenshot, filename: "screenshot.png", contentType: "application/png")) + } + return items + } +} diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift index 6557176ddf2..4e591f080ab 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift @@ -8,8 +8,7 @@ import UIKit @available(iOS 13.0, *) protocol SentryUserFeedbackFormDelegate: NSObjectProtocol { - func cancelled() - func confirmed() + func finished(with feedback: SentryFeedback?) } @available(iOS 13.0, *) @@ -26,6 +25,7 @@ class SentryUserFeedbackForm: UIViewController { updateLayout() } + //swiftlint:disable function_body_length init(config: SentryUserFeedbackConfiguration, delegate: any SentryUserFeedbackFormDelegate) { self.config = config self.delegate = delegate @@ -83,6 +83,7 @@ class SentryUserFeedbackForm: UIViewController { $0.setTitleColor(config.theme.buttonForeground, for: .normal) } } + //swiftlint:enable function_body_length // MARK: Actions @@ -132,12 +133,14 @@ class SentryUserFeedbackForm: UIViewController { present(alert, animated: config.animations) return } - - delegate?.confirmed() + + let feedback = SentryFeedback(message: messageTextView.text, name: fullNameTextField.text, email: emailTextField.text, screenshot: screenshotImageView.image?.pngData()) + SentryLog.log(message: "Sending user feedback", andLevel: .debug) + delegate?.finished(with: feedback) } func cancelButtonTapped() { - delegate?.cancelled() + delegate?.finished(with: nil) } // MARK: Layout diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift index f9b3d7167c8..efda466e6e2 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackIntegrationDriver.swift @@ -3,18 +3,25 @@ import Foundation @_implementationOnly import _SentryPrivate import UIKit +@available(iOS 13.0, *) @objc +protocol SentryUserFeedbackIntegrationDriverDelegate: NSObjectProtocol { + func capture(feedback: SentryFeedback) +} + /** * An integration managing a workflow for end users to report feedback via Sentry. * - note: The default method to show the feedback form is via a floating widget placed in the bottom trailing corner of the screen. See the configuration classes for alternative options. */ @available(iOS 13.0, *) @objcMembers -class SentryUserFeedbackIntegrationDriver: NSObject { +class SentryUserFeedbackIntegrationDriver: NSObject, SentryUserFeedbackWidgetDelegate { let configuration: SentryUserFeedbackConfiguration private var window: SentryUserFeedbackWidget.Window? + weak var delegate: (any SentryUserFeedbackIntegrationDriverDelegate)? - public init(configuration: SentryUserFeedbackConfiguration) { + public init(configuration: SentryUserFeedbackConfiguration, delegate: any SentryUserFeedbackIntegrationDriverDelegate) { self.configuration = configuration + self.delegate = delegate super.init() if let widgetConfigBuilder = configuration.configureWidget { @@ -49,7 +56,7 @@ class SentryUserFeedbackIntegrationDriver: NSObject { * If `SentryUserFeedbackConfiguration.autoInject` is `false`, this must be called explicitly. */ func createWidget() { - window = SentryUserFeedbackWidget.Window(config: configuration) + window = SentryUserFeedbackWidget.Window(config: configuration, delegate: self) window?.isHidden = false } @@ -60,18 +67,6 @@ class SentryUserFeedbackIntegrationDriver: NSObject { } - /** - * Captures feedback using custom UI. This method allows you to submit feedback data directly. - * - Parameters: - * - message: The feedback message (required). - * - name: The name of the user (optional). - * - email: The email of the user (optional). - * - hints: Additional hints or metadata for the feedback submission (optional). - */ - func captureFeedback(message: String, name: String? = nil, email: String? = nil, hints: [String: Any]? = nil) { - // Implementation to capture feedback - } - private func validate(_ config: SentryUserFeedbackWidgetConfiguration) { let noOpposingHorizontals = config.location.contains(.trailing) && !config.location.contains(.leading) || !config.location.contains(.trailing) && config.location.contains(.leading) @@ -90,6 +85,12 @@ class SentryUserFeedbackIntegrationDriver: NSObject { SentryLog.warning("Invalid widget location specified: \(config.location). Must specify either one edge or one corner of the screen rect to place the widget.") } } + + // MARK: SentryUserFeedbackWidgetDelegate + + func capture(feedback: SentryFeedback) { + delegate?.capture(feedback: feedback) + } } #endif // os(iOS) && !SENTRY_NO_UIKIT diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidget.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidget.swift index 0233bd50aa1..99e218db662 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidget.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidget.swift @@ -7,6 +7,10 @@ import UIKit var displayingForm = false +protocol SentryUserFeedbackWidgetDelegate: NSObjectProtocol { + func capture(feedback: SentryFeedback) +} + @available(iOS 13.0, *) struct SentryUserFeedbackWidget { class Window: UIWindow { @@ -22,8 +26,11 @@ struct SentryUserFeedbackWidget { let config: SentryUserFeedbackConfiguration - init(config: SentryUserFeedbackConfiguration) { + weak var delegate: (any SentryUserFeedbackWidgetDelegate)? + + init(config: SentryUserFeedbackConfiguration, delegate: any SentryUserFeedbackWidgetDelegate) { self.config = config + self.delegate = delegate super.init(nibName: nil, bundle: nil) view.addSubview(button) @@ -68,16 +75,13 @@ struct SentryUserFeedbackWidget { // MARK: SentryUserFeedbackFormDelegate - func cancelled() { - closeForm() - } - -//swiftlint:disable todo - func confirmed() { - // TODO: submit + func finished(with feedback: SentryFeedback?) { closeForm() + + if let feedback = feedback { + delegate?.capture(feedback: feedback) + } } -//swiftlint:enable todo // MARK: UIAdaptivePresentationControllerDelegate @@ -86,9 +90,9 @@ struct SentryUserFeedbackWidget { } } - init(config: SentryUserFeedbackConfiguration) { + init(config: SentryUserFeedbackConfiguration, delegate: any SentryUserFeedbackWidgetDelegate) { super.init(frame: UIScreen.main.bounds) - rootViewController = RootViewController(config: config) + rootViewController = RootViewController(config: config, delegate: delegate) windowLevel = config.widgetConfig.windowLevel } diff --git a/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift new file mode 100644 index 00000000000..001279d7c51 --- /dev/null +++ b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift @@ -0,0 +1,33 @@ +import Foundation +@testable import Sentry +import XCTest + +class SentryFeedbackTests: XCTestCase { + func testSerializeWithAllFields() throws { + let sut = SentryFeedback(message: "Test feedback message", name: "Test feedback provider", email: "test-feedback-provider@sentry.io", screenshot: Data()) + + let serialization = sut.serialize() + XCTAssertEqual(try XCTUnwrap(serialization["message"] as? String), "Test feedback message") + XCTAssertEqual(try XCTUnwrap(serialization["name"] as? String), "Test feedback provider") + XCTAssertEqual(try XCTUnwrap(serialization["contact_email"] as? String), "test-feedback-provider@sentry.io") + XCTAssertEqual(try XCTUnwrap(serialization["source"] as? String), "widget") + + let attachments = sut.attachments() + XCTAssertEqual(attachments.count, 1) + XCTAssertEqual(try XCTUnwrap(attachments.first).filename, "screenshot.png") + XCTAssertEqual(try XCTUnwrap(attachments.first).contentType, "application/png") + } + + func testSerializeWithNoOptionalFields() throws { + let sut = SentryFeedback(message: "Test feedback message", name: nil, email: nil) + + let serialization = sut.serialize() + XCTAssertEqual(try XCTUnwrap(serialization["message"] as? String), "Test feedback message") + XCTAssertNil(serialization["name"]) + XCTAssertNil(serialization["contact_email"]) + XCTAssertEqual(try XCTUnwrap(serialization["source"] as? String), "widget") + + let attachments = sut.attachments() + XCTAssertEqual(attachments.count, 0) + } +} diff --git a/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift b/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift index cb0b6d6099b..1fc9a8999a5 100644 --- a/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift +++ b/Tests/SentryTests/Networking/SentryDataCategoryMapperTests.swift @@ -12,6 +12,7 @@ class SentryDataCategoryMapperTests: XCTestCase { XCTAssertEqual(sentryDataCategoryForEnvelopItemType("profile_chunk"), .profileChunk) XCTAssertEqual(sentryDataCategoryForEnvelopItemType("statsd"), .metricBucket) XCTAssertEqual(sentryDataCategoryForEnvelopItemType("replay_video"), .replay) + XCTAssertEqual(sentryDataCategoryForEnvelopItemType("feedback"), .feedback) XCTAssertEqual(sentryDataCategoryForEnvelopItemType("unknown item type"), .default) } @@ -28,7 +29,8 @@ class SentryDataCategoryMapperTests: XCTestCase { XCTAssertEqual(sentryDataCategoryForNSUInteger(9), .replay) XCTAssertEqual(sentryDataCategoryForNSUInteger(10), .profileChunk) XCTAssertEqual(sentryDataCategoryForNSUInteger(11), .span) - XCTAssertEqual(sentryDataCategoryForNSUInteger(12), .unknown) + XCTAssertEqual(sentryDataCategoryForNSUInteger(12), .feedback) + XCTAssertEqual(sentryDataCategoryForNSUInteger(13), .unknown) XCTAssertEqual(.unknown, sentryDataCategoryForNSUInteger(13), "Failed to map unknown category number to case .unknown") } @@ -45,6 +47,7 @@ class SentryDataCategoryMapperTests: XCTestCase { XCTAssertEqual(sentryDataCategoryForString(kSentryDataCategoryNameProfileChunk), .profileChunk) XCTAssertEqual(sentryDataCategoryForString(kSentryDataCategoryNameMetricBucket), .metricBucket) XCTAssertEqual(sentryDataCategoryForString(kSentryDataCategoryNameReplay), .replay) + XCTAssertEqual(sentryDataCategoryForString(kSentryDataCategoryNameFeedback), .feedback) XCTAssertEqual(sentryDataCategoryForString(kSentryDataCategoryNameSpan), .span) XCTAssertEqual(sentryDataCategoryForString(kSentryDataCategoryNameUnknown), .unknown) @@ -63,6 +66,7 @@ class SentryDataCategoryMapperTests: XCTestCase { XCTAssertEqual(nameForSentryDataCategory(.profileChunk), kSentryDataCategoryNameProfileChunk) XCTAssertEqual(nameForSentryDataCategory(.metricBucket), kSentryDataCategoryNameMetricBucket) XCTAssertEqual(nameForSentryDataCategory(.replay), kSentryDataCategoryNameReplay) + XCTAssertEqual(nameForSentryDataCategory(.feedback), kSentryDataCategoryNameFeedback) XCTAssertEqual(nameForSentryDataCategory(.span), kSentryDataCategoryNameSpan) XCTAssertEqual(nameForSentryDataCategory(.unknown), kSentryDataCategoryNameUnknown) }