From 86b175e5f26cd87ff39e1a31e2af3724fd4deeec Mon Sep 17 00:00:00 2001 From: Kirill <17173528+0xff23@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:40:07 -0700 Subject: [PATCH 1/4] fix: limit feedback message length Validate managed feedback messages against the backend 4096 Unicode-scalar limit. Show an accessible live counter and preserve existing missing-field validation precedence. --- .../SentryUserFeedbackFormController.swift | 11 +- .../SentryUserFeedbackFormViewModel.swift | 33 ++++++ .../Feedback/SentryFeedbackTests.swift | 101 ++++++++++++++++++ 3 files changed, 142 insertions(+), 3 deletions(-) diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormController.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormController.swift index 4e82f434ac..62e69ca14d 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormController.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormController.swift @@ -229,14 +229,18 @@ extension SentryUserFeedbackFormController: SentryUserFeedbackFormViewModelDeleg } } - guard case let SentryUserFeedbackFormViewModel.InputError.validationError(missing, _) = error, - let errorDescription = error.errorDescription else { + guard let errorDescription = error.errorDescription else { SentrySDKLog.warning("Unexpected error type.") presentAlert(message: config.formConfig.unexpectedErrorText, errorCode: 2, info: [NSLocalizedDescriptionKey: "Client error: ."]) return } - presentAlert(message: errorDescription, errorCode: 1, info: ["missing_fields": missing, NSLocalizedDescriptionKey: "The user did not complete the feedback form."]) + switch error { + case .validationError(let missing, _): + presentAlert(message: errorDescription, errorCode: 1, info: ["missing_fields": missing, NSLocalizedDescriptionKey: "The user did not complete the feedback form."]) + case .messageTooLong: + presentAlert(message: errorDescription, errorCode: 1, info: [NSLocalizedDescriptionKey: errorDescription]) + } } } @@ -313,6 +317,7 @@ extension SentryUserFeedbackFormController: UITextViewDelegate { /// Updates validation state when the feedback message changes. public func textViewDidChange(_ textView: UITextView) { viewModel.messageTextViewPlaceholder.isHidden = textView.text != "" + viewModel.updateMessageCharacterCount() viewModel.updateSubmitButtonAccessibilityHint() } } diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift index e574603fab..f9e3f87738 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift @@ -13,6 +13,8 @@ protocol SentryUserFeedbackFormViewModelDelegate: NSObjectProtocol { @objcMembers @_spi(Private) public class SentryUserFeedbackFormViewModel: NSObject { + static let maxMessageLength = 4_096 + let config: SentryUserFeedbackConfiguration unowned let controller: SentryUserFeedbackFormController weak var delegate: SentryUserFeedbackFormViewModelDelegate? @@ -126,6 +128,16 @@ protocol SentryUserFeedbackFormViewModelDelegate: NSObjectProtocol { textView.accessibilityIdentifier = "io.sentry.feedback.form.message" return textView }() + + lazy var messageCharacterCountLabel = { + let label = UILabel(frame: .zero) + label.font = UIFont.preferredFont(forTextStyle: .caption1) + label.adjustsFontForContentSizeCategory = true + label.textAlignment = .right + label.accessibilityIdentifier = "io.sentry.feedback.form.message-character-count" + updateMessageCharacterCount(label: label) + return label + }() lazy var screenshotImageView = { let iv = UIImageView() @@ -227,6 +239,7 @@ protocol SentryUserFeedbackFormViewModelDelegate: NSObjectProtocol { let messageAndScreenshotStack = UIStackView(arrangedSubviews: [ self.messageTextView, + self.messageCharacterCountLabel, self.addScreenshotButton, self.removeScreenshotStack ]) @@ -399,6 +412,17 @@ extension SentryUserFeedbackFormViewModel { case .failure(let error): submitButton.accessibilityHint = error.errorDescription } } + + func updateMessageCharacterCount() { + updateMessageCharacterCount(label: messageCharacterCountLabel) + } + + private func updateMessageCharacterCount(label: UILabel) { + let count = messageTextView.text.unicodeScalars.count + label.text = "\(count) / \(Self.maxMessageLength)" + label.accessibilityLabel = "\(count) of \(Self.maxMessageLength) characters used" + label.textColor = count > Self.maxMessageLength ? config.theme.errorColor : config.theme.foreground + } func themeElements() { [fullNameTextField, emailTextField].forEach { @@ -499,7 +523,9 @@ extension SentryUserFeedbackFormViewModel { } // include the message they'll submit + var messageLength = 0 if let message = messageTextView.textOrNil { + messageLength = message.unicodeScalars.count hint.append("with message: \(message)") } else { missing.append(config.formConfig.messageLabel.lowercased()) @@ -510,17 +536,24 @@ extension SentryUserFeedbackFormViewModel { let result = SentryUserFeedbackFormValidation.failure(InputError.validationError(missingFields: missing, localizedError: localizedError)) return result } + + guard messageLength <= Self.maxMessageLength else { + return .failure(.messageTooLong(maximumLength: Self.maxMessageLength)) + } return SentryUserFeedbackFormValidation.success(hint.joined(separator: " ").appending(".")) } enum InputError: LocalizedError { case validationError(missingFields: [String], localizedError: String) + case messageTooLong(maximumLength: Int) var description: String { switch self { case .validationError(_, let localizedError): return localizedError + case .messageTooLong(let maximumLength): + return "The description must not exceed \(maximumLength) characters." } } diff --git a/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift index 83d470cb99..ff6d22c680 100644 --- a/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift +++ b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift @@ -456,6 +456,107 @@ class SentryFeedbackTests: XCTestCase { XCTAssertEqual(attachments[2].contentType, "video/mp4") } + func testValidate_whenMessageExceedsMaximumLength_shouldReturnSpecificError() throws { + // -- Arrange -- + let config = SentryUserFeedbackConfiguration() + let sut = SentryUserFeedbackFormController(preparedConfig: config, screenshot: nil) + sut.viewModel.messageTextView.text = String(repeating: "a", count: 4_097) + + // -- Act -- + let result = sut.viewModel.validate() + + // -- Assert -- + guard case .failure(let error) = result else { + return XCTFail("Expected an over-limit message to fail validation.") + } + XCTAssertEqual(error.errorDescription, "The description must not exceed 4096 characters.") + } + + func testValidate_whenMessageIsAtMaximumLength_shouldSucceed() { + // -- Arrange -- + let config = SentryUserFeedbackConfiguration() + let sut = SentryUserFeedbackFormController(preparedConfig: config, screenshot: nil) + sut.viewModel.messageTextView.text = String(repeating: "a", count: 4_096) + + // -- Act -- + let result = sut.viewModel.validate() + + // -- Assert -- + guard case .success = result else { + return XCTFail("Expected a message at the limit to validate.") + } + } + + func testValidate_whenRequiredFieldsAreMissingAndMessageIsTooLong_shouldReportMissingFields() throws { + // -- Arrange -- + let config = SentryUserFeedbackConfiguration() + config.formConfig.isNameRequired = true + config.formConfig.isEmailRequired = true + let sut = SentryUserFeedbackFormController(preparedConfig: config, screenshot: nil) + sut.viewModel.messageTextView.text = String(repeating: "a", count: 4_097) + + // -- Act -- + let result = sut.viewModel.validate() + + // -- Assert -- + guard case .failure(let error) = result else { + return XCTFail("Expected missing required fields to fail validation.") + } + XCTAssertEqual(error.errorDescription, "You must provide all required information before submitting. Please check the following fields: name and email.") + } + + func testMessageCharacterCount_whenTextChanges_shouldCountUnicodeScalars() { + // -- Arrange -- + let config = SentryUserFeedbackConfiguration() + let sut = SentryUserFeedbackFormController(preparedConfig: config, screenshot: nil) + sut.viewModel.messageTextView.text = "e\u{301}" + + // -- Act -- + sut.textViewDidChange(sut.viewModel.messageTextView) + + // -- Assert -- + XCTAssertEqual(sut.viewModel.messageCharacterCountLabel.text, "2 / 4096") + XCTAssertEqual(sut.viewModel.messageCharacterCountLabel.accessibilityLabel, "2 of 4096 characters used") + XCTAssertEqual(sut.viewModel.messageCharacterCountLabel.textColor, config.theme.foreground) + } + + func testMessageCharacterCount_whenMessageExceedsMaximumLength_shouldUseErrorColor() { + // -- Arrange -- + let config = SentryUserFeedbackConfiguration() + let sut = SentryUserFeedbackFormController(preparedConfig: config, screenshot: nil) + sut.viewModel.messageTextView.text = String(repeating: "a", count: 4_097) + + // -- Act -- + sut.textViewDidChange(sut.viewModel.messageTextView) + + // -- Assert -- + XCTAssertEqual(sut.viewModel.messageCharacterCountLabel.text, "4097 / 4096") + XCTAssertEqual(sut.viewModel.messageCharacterCountLabel.textColor, config.theme.errorColor) + } + +#if !targetEnvironment(macCatalyst) + func testSubmitFeedback_whenMessageExceedsMaximumLength_shouldPresentSpecificError() throws { + // -- Arrange -- + let config = SentryUserFeedbackConfiguration() + config.animations = false + let sut = SentryUserFeedbackFormController(preparedConfig: config, screenshot: nil) + sut.viewModel.messageTextView.text = String(repeating: "a", count: 4_097) + let window = UIWindow(windowScene: Self.mockWindowScene) + window.rootViewController = sut + window.makeKeyAndVisible() + addTeardownBlock { [window] in + window.isHidden = true + } + + // -- Act -- + sut.submitFeedback() + + // -- Assert -- + let alert = try XCTUnwrap(sut.presentedViewController as? UIAlertController) + XCTAssertEqual(alert.message, "The description must not exceed 4096 characters.") + } +#endif + 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 (config: (requiresName: false, requiresEmail: false, nameInput: nil, emailInput: nil, messageInput: nil, includeScreenshot: false), shouldValidate: false, expectedSubmitButtonAccessibilityHint: "You must provide all required information before submitting. Please check the following field: description."), From 8874b9d8364cb7723a21bbd33c500ff01523021d Mon Sep 17 00:00:00 2001 From: Kirill <17173528+0xff23@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:41:06 -0700 Subject: [PATCH 2/4] docs: add feedback limit changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 566a99a317..263d63ac4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,9 @@ ### Internal - Fix `SentrySDK.internal.replay.replayId` returning nil for buffered replays (#8976) +- Mark the fabricated `mach` and `signal` crash mechanisms as `synthetic` so an Apple crash groups with the identical crash reported by the other Sentry SDKs, and so a mach-caught and a signal-caught report of the same bug no longer split into two issues (#8919) +- Set `mechanism.handled` to `false` on crash reports that carry no mach context, which previously left it unset (#8919) +- Prevent managed user feedback from submitting messages longer than 4096 Unicode scalars, and show the limit in the feedback form. (#8973) ## 9.27.0 From bfe48f592aac97bf086286072e12c0b9823bcae1 Mon Sep 17 00:00:00 2001 From: Kirill <17173528+0xff23@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:18:58 -0700 Subject: [PATCH 3/4] test: harden feedback message limit handling --- .../SentryUserFeedbackFormViewModel.swift | 5 +- .../Feedback/SentryFeedbackTests.swift | 56 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift index f9e3f87738..a984a04377 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift @@ -13,6 +13,7 @@ protocol SentryUserFeedbackFormViewModelDelegate: NSObjectProtocol { @objcMembers @_spi(Private) public class SentryUserFeedbackFormViewModel: NSObject { + // The backend uses Python code-point length, which matches Swift Unicode scalars. static let maxMessageLength = 4_096 let config: SentryUserFeedbackConfiguration @@ -131,7 +132,7 @@ protocol SentryUserFeedbackFormViewModelDelegate: NSObjectProtocol { lazy var messageCharacterCountLabel = { let label = UILabel(frame: .zero) - label.font = UIFont.preferredFont(forTextStyle: .caption1) + label.font = config.theme.scaledFont(style: .caption1) label.adjustsFontForContentSizeCategory = true label.textAlignment = .right label.accessibilityIdentifier = "io.sentry.feedback.form.message-character-count" @@ -418,7 +419,7 @@ extension SentryUserFeedbackFormViewModel { } private func updateMessageCharacterCount(label: UILabel) { - let count = messageTextView.text.unicodeScalars.count + let count = messageTextView.text?.unicodeScalars.count ?? 0 label.text = "\(count) / \(Self.maxMessageLength)" label.accessibilityLabel = "\(count) of \(Self.maxMessageLength) characters used" label.textColor = count > Self.maxMessageLength ? config.theme.errorColor : config.theme.foreground diff --git a/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift index ff6d22c680..fbb4734617 100644 --- a/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift +++ b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift @@ -487,6 +487,37 @@ class SentryFeedbackTests: XCTestCase { } } + func testValidate_whenDecomposedMessageIsAtMaximumScalarLength_shouldSucceed() { + // -- Arrange -- + let config = SentryUserFeedbackConfiguration() + let sut = SentryUserFeedbackFormController(preparedConfig: config, screenshot: nil) + sut.viewModel.messageTextView.text = String(repeating: "e\u{301}", count: 2_048) + + // -- Act -- + let result = sut.viewModel.validate() + + // -- Assert -- + guard case .success = result else { + return XCTFail("Expected 4096 Unicode scalars to validate.") + } + } + + func testValidate_whenDecomposedMessageExceedsMaximumScalarLength_shouldFail() throws { + // -- Arrange -- + let config = SentryUserFeedbackConfiguration() + let sut = SentryUserFeedbackFormController(preparedConfig: config, screenshot: nil) + sut.viewModel.messageTextView.text = String(repeating: "e\u{301}", count: 2_048) + "a" + + // -- Act -- + let result = sut.viewModel.validate() + + // -- Assert -- + guard case .failure(let error) = result else { + return XCTFail("Expected 4097 Unicode scalars to fail validation.") + } + XCTAssertEqual(error.errorDescription, "The description must not exceed 4096 characters.") + } + func testValidate_whenRequiredFieldsAreMissingAndMessageIsTooLong_shouldReportMissingFields() throws { // -- Arrange -- let config = SentryUserFeedbackConfiguration() @@ -534,6 +565,31 @@ class SentryFeedbackTests: XCTestCase { XCTAssertEqual(sut.viewModel.messageCharacterCountLabel.textColor, config.theme.errorColor) } + func testMessageCharacterCount_whenTextIsNil_shouldShowZero() { + // -- Arrange -- + let config = SentryUserFeedbackConfiguration() + let sut = SentryUserFeedbackFormController(preparedConfig: config, screenshot: nil) + sut.viewModel.messageTextView.text = nil + + // -- Act -- + sut.viewModel.updateMessageCharacterCount() + + // -- Assert -- + XCTAssertEqual(sut.viewModel.messageCharacterCountLabel.text, "0 / 4096") + } + + func testMessageCharacterCount_whenFontFamilyConfigured_shouldUseThemeFont() { + // -- Arrange -- + let config = SentryUserFeedbackConfiguration() + config.theme.fontFamily = "Helvetica" + + // -- Act -- + let sut = SentryUserFeedbackFormController(preparedConfig: config, screenshot: nil) + + // -- Assert -- + XCTAssertEqual(sut.viewModel.messageCharacterCountLabel.font.familyName, "Helvetica") + } + #if !targetEnvironment(macCatalyst) func testSubmitFeedback_whenMessageExceedsMaximumLength_shouldPresentSpecificError() throws { // -- Arrange -- From 74e8f399cf4e3a502ef23fe8ba140335a2c974d3 Mon Sep 17 00:00:00 2001 From: Kirill <17173528+0xff23@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:02:24 -0700 Subject: [PATCH 4/4] fix: improve feedback counter accessibility --- .../UserFeedback/SentryUserFeedbackFormViewModel.swift | 1 + .../SentryTests/Integrations/Feedback/SentryFeedbackTests.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift index a984a04377..8d6c96de0e 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackFormViewModel.swift @@ -136,6 +136,7 @@ protocol SentryUserFeedbackFormViewModelDelegate: NSObjectProtocol { label.adjustsFontForContentSizeCategory = true label.textAlignment = .right label.accessibilityIdentifier = "io.sentry.feedback.form.message-character-count" + label.accessibilityTraits.insert(.updatesFrequently) updateMessageCharacterCount(label: label) return label }() diff --git a/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift index fbb4734617..743d39c3b2 100644 --- a/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift +++ b/Tests/SentryTests/Integrations/Feedback/SentryFeedbackTests.swift @@ -548,6 +548,7 @@ class SentryFeedbackTests: XCTestCase { // -- Assert -- XCTAssertEqual(sut.viewModel.messageCharacterCountLabel.text, "2 / 4096") XCTAssertEqual(sut.viewModel.messageCharacterCountLabel.accessibilityLabel, "2 of 4096 characters used") + XCTAssertTrue(sut.viewModel.messageCharacterCountLabel.accessibilityTraits.contains(.updatesFrequently)) XCTAssertEqual(sut.viewModel.messageCharacterCountLabel.textColor, config.theme.foreground) }