diff --git a/CHANGELOG.md b/CHANGELOG.md index 712d3511842..e71708d492f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ If you need a precompiled XCFramework built with Xcode 15, continue using Sentry SDK 8.x.x. - Set `SentryException.type` to `nil` when `NSException` has no `reason` (#6653). The backend then can provide a proper message when there is no reason. - Rename `SentryLog.Level` and `SentryLog.Attribute` for ObjC (#6666) +- Change `SentryFeedback` initializer to support multiple attachments (#6752) ### Fixes diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift index cf3704da187..23b3d6fb66a 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift @@ -351,10 +351,19 @@ extension SentrySDKWrapper { alert.addAction(.init(title: "Deal with it 🕶️", style: .default)) UIApplication.shared.delegate?.window??.rootViewController?.present(alert, animated: true) - // if there's a screenshot's Data in this dictionary, JSONSerialization crashes _even though_ there's a `try?`, so we'll write the base64 encoding of it var infoToWriteToFile = info - if let attachments = info["attachments"] as? [Any], let screenshot = attachments.first as? Data { - infoToWriteToFile["attachments"] = [screenshot.base64EncodedString()] + if let attachments = info["attachments"] as? [[String: Any]] { + // Extract data from each attachment dictionary (JSONSerialization crashes _even though_ there's a `try?`, so we'll write the base64 encoding of it) + let processedAttachments = attachments.compactMap { attachment -> [String: Any]? in + var processed = attachment + if let data = attachment["data"] as? Data { + processed["data"] = data.base64EncodedString() + } + return processed + } + if !processedAttachments.isEmpty { + infoToWriteToFile["attachments"] = processedAttachments + } } let jsonData = (try? JSONSerialization.data(withJSONObject: infoToWriteToFile, options: .sortedKeys)) ?? Data() diff --git a/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift b/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift index 1acedd8d96a..ba2d66466de 100644 --- a/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift @@ -205,9 +205,9 @@ class ExtraViewController: UIViewController { @IBAction func captureUserFeedbackV2(_ sender: UIButton) { highlightButton(sender) - var attachments: [Data]? + var attachments: [Attachment]? if let url = BundleResourceProvider.screenshotURL, let data = try? Data(contentsOf: url) { - attachments = [data] + attachments = [Attachment(data: data, filename: "screenshot.png", contentType: "image/png")] } let errorEventID = SentrySDK.capture(error: NSError(domain: "test-error.user-feedback.iOS-Swift", code: 1)) let feedback = SentryFeedback(message: "It broke again on iOS-Swift. I don't know why, but this happens.", name: "John Me", email: "john@me.com", source: .custom, associatedEventId: errorEventID, attachments: attachments) diff --git a/Sources/Swift/Integrations/UserFeedback/SentryFeedback.swift b/Sources/Swift/Integrations/UserFeedback/SentryFeedback.swift index 371cb4273c5..ed82e40f766 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryFeedback.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryFeedback.swift @@ -20,17 +20,17 @@ public final class SentryFeedback: NSObject { var message: String var source: SentryFeedbackSource @_spi(Private) public let eventId: SentryId - - /// Data objects for any attachments. Currently the web UI only supports showing one attached image, like for a screenshot. - private var attachments: [Data]? - + + /// Attachments for this feedback submission, like a screenshot. + private var attachments: [Attachment]? + /// The event id that this feedback is associated with, like a crash report. var associatedEventId: SentryId? - + /// - parameters: /// - associatedEventId The ID for an event you'd like associated with the feedback. - /// - attachments Data objects for any attachments. Currently the web UI only supports showing one attached image, like for a screenshot. - @objc public init(message: String, name: String?, email: String?, source: SentryFeedbackSource = .widget, associatedEventId: SentryId? = nil, attachments: [Data]? = nil) { + /// - attachments Attachment objects for any files to include with the feedback. + @objc public init(message: String, name: String?, email: String?, source: SentryFeedbackSource = .widget, associatedEventId: SentryId? = nil, attachments: [Attachment]? = nil) { self.eventId = SentryId() self.name = name self.email = email @@ -83,19 +83,32 @@ extension SentryFeedback { dict["email"] = email } if let attachments = attachments { - dict["attachments"] = attachments + dict["attachments"] = attachments.map { $0.dataDictionary() } } return dict } /** - * - note: Currently there is only a single attachment possible, for the screenshot, of which there can be only one. + * Returns all attachments for inclusion in the feedback envelope. */ @_spi(Private) public func attachmentsForEnvelope() -> [Attachment] { - var items = [Attachment]() - if let screenshot = attachments?.first { - items.append(Attachment(data: screenshot, filename: "screenshot.png", contentType: "application/png")) + return attachments ?? [] + } +} + +// MARK: Attachment Serialization +extension Attachment { + func dataDictionary() -> [String: Any] { + var attDict: [String: Any] = ["filename": filename] + if let data = data { + attDict["data"] = data + } + if let path = path { + attDict["path"] = path + } + if let contentType = contentType { + attDict["contentType"] = contentType } - return items + return attDict } } diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift index 061c3efc962..024afa9726f 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift @@ -455,11 +455,11 @@ extension SentryUserFeedbackFormViewModel { } func feedbackObject() -> SentryFeedback { - var attachmentDatas: [Data]? + var attachments: [Attachment]? if let image = screenshotImageView.image, let data = image.pngData() { - attachmentDatas = [data] + attachments = [Attachment(data: data, filename: "screenshot.png", contentType: "image/png")] } - return SentryFeedback(message: messageTextView.text, name: fullNameTextField.text, email: emailTextField.text, attachments: attachmentDatas) + return SentryFeedback(message: messageTextView.text, name: fullNameTextField.text, email: emailTextField.text, attachments: attachments) } } diff --git a/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift index 3630ab990c1..1b89fea3111 100644 --- a/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift +++ b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift @@ -30,65 +30,84 @@ class SentryFeedbackTests: XCTestCase { } func testSerializeWithAllFields() throws { - let sut = SentryFeedback(message: "Test feedback message", name: "Test feedback provider", email: "test-feedback-provider@sentry.io", attachments: [Data()]) - + let attachment = Attachment(data: Data(), filename: "screenshot.png", contentType: "image/png") + let sut = SentryFeedback(message: "Test feedback message", name: "Test feedback provider", email: "test-feedback-provider@sentry.io", attachments: [attachment]) + 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.attachmentsForEnvelope() XCTAssertEqual(attachments.count, 1) XCTAssertEqual(try XCTUnwrap(attachments.first).filename, "screenshot.png") - XCTAssertEqual(try XCTUnwrap(attachments.first).contentType, "application/png") + XCTAssertEqual(try XCTUnwrap(attachments.first).contentType, "image/png") } func testSerializeCustomFeedback() throws { - let sut = SentryFeedback(message: "Test feedback message", name: "Test feedback provider", email: "test-feedback-provider@sentry.io", source: .custom, attachments: [Data()]) - + let attachment = Attachment(data: Data(), filename: "screenshot.png", contentType: "image/png") + let sut = SentryFeedback(message: "Test feedback message", name: "Test feedback provider", email: "test-feedback-provider@sentry.io", source: .custom, attachments: [attachment]) + 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), "custom") - + let attachments = sut.attachmentsForEnvelope() XCTAssertEqual(attachments.count, 1) XCTAssertEqual(try XCTUnwrap(attachments.first).filename, "screenshot.png") - XCTAssertEqual(try XCTUnwrap(attachments.first).contentType, "application/png") + XCTAssertEqual(try XCTUnwrap(attachments.first).contentType, "image/png") } func testSerializeWithAssociatedEventID() throws { let eventID = SentryId() - - let sut = SentryFeedback(message: "Test feedback message", name: "Test feedback provider", email: "test-feedback-provider@sentry.io", source: .custom, associatedEventId: eventID, attachments: [Data()]) - + let attachment = Attachment(data: Data(), filename: "screenshot.png", contentType: "image/png") + let sut = SentryFeedback(message: "Test feedback message", name: "Test feedback provider", email: "test-feedback-provider@sentry.io", source: .custom, associatedEventId: eventID, attachments: [attachment]) + 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), "custom") XCTAssertEqual(try XCTUnwrap(serialization["associated_event_id"] as? String), eventID.sentryIdString) - + let attachments = sut.attachmentsForEnvelope() XCTAssertEqual(attachments.count, 1) XCTAssertEqual(try XCTUnwrap(attachments.first).filename, "screenshot.png") - XCTAssertEqual(try XCTUnwrap(attachments.first).contentType, "application/png") + XCTAssertEqual(try XCTUnwrap(attachments.first).contentType, "image/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.attachmentsForEnvelope() XCTAssertEqual(attachments.count, 0) } + + func testMultipleAttachments() throws { + let screenshot = Attachment(data: Data("screenshot".utf8), filename: "screenshot.png", contentType: "image/png") + let logFile = Attachment(data: Data("log content".utf8), filename: "app.log", contentType: "text/plain") + let videoFile = Attachment(data: Data("video".utf8), filename: "recording.mp4", contentType: "video/mp4") + + let sut = SentryFeedback(message: "Test feedback with multiple attachments", name: "Test User", email: "test@example.com", attachments: [screenshot, logFile, videoFile]) + + let attachments = sut.attachmentsForEnvelope() + XCTAssertEqual(attachments.count, 3) + XCTAssertEqual(attachments[0].filename, "screenshot.png") + XCTAssertEqual(attachments[0].contentType, "image/png") + XCTAssertEqual(attachments[1].filename, "app.log") + XCTAssertEqual(attachments[1].contentType, "text/plain") + XCTAssertEqual(attachments[2].filename, "recording.mp4") + XCTAssertEqual(attachments[2].contentType, "video/mp4") + } private let inputCombinations: [FeedbackTestCase] = [ // base case: don't require name or email, don't input a name or email, don't input a message or screenshot diff --git a/sdk_api.json b/sdk_api.json index 2b81acd1a10..f72d587b231 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -32783,18 +32783,18 @@ { "kind": "TypeNominal", "name": "Optional", - "printedName": "[Foundation.Data]?", + "printedName": "[Sentry.Attachment]?", "children": [ { "kind": "TypeNominal", "name": "Array", - "printedName": "[Foundation.Data]", + "printedName": "[Sentry.Attachment]", "children": [ { "kind": "TypeNominal", - "name": "Data", - "printedName": "Foundation.Data", - "usr": "s:10Foundation4DataV" + "name": "Attachment", + "printedName": "Sentry.Attachment", + "usr": "c:objc(cs)SentryAttachment" } ], "usr": "s:Sa" @@ -32806,7 +32806,7 @@ ], "declKind": "Constructor", "usr": "c:@M@Sentry@objc(cs)SentryFeedback(im)initWithMessage:name:email:source:associatedEventId:attachments:", - "mangledName": "$s6Sentry0A8FeedbackC7message4name5email6source17associatedEventId11attachmentsACSS_SSSgAjC0aB6SourceOSo0aI0CSgSay10Foundation4DataVGSgtcfc", + "mangledName": "$s6Sentry0A8FeedbackC7message4name5email6source17associatedEventId11attachmentsACSS_SSSgAjC0aB6SourceOSo0aI0CSgSaySo0A10AttachmentCGSgtcfc", "moduleName": "Sentry", "objc_name": "initWithMessage:name:email:source:associatedEventId:attachments:", "declAttributes": [