diff options
| author | Jon Petersson <jon.petersson@mullvad.net> | 2025-04-25 08:11:02 +0200 |
|---|---|---|
| committer | Jon Petersson <jon.petersson@mullvad.net> | 2025-05-30 11:32:54 +0200 |
| commit | 83effc10b5cb3f26ca1c65d30ae62baf10f495d3 (patch) | |
| tree | 59b470da0d74cf1311ccd53787df47ccdca854d4 /ios/MullvadVPN/View controllers/ProblemReport | |
| parent | 42b53b4bcd3d3155d42ebca7e33c9450ca5dab73 (diff) | |
| download | mullvadvpn-83effc10b5cb3f26ca1c65d30ae62baf10f495d3.tar.xz mullvadvpn-83effc10b5cb3f26ca1c65d30ae62baf10f495d3.zip | |
Let users cancel sending a problem report
Diffstat (limited to 'ios/MullvadVPN/View controllers/ProblemReport')
6 files changed, 203 insertions, 78 deletions
diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift index 98746416dc..400db47dd1 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift @@ -16,6 +16,7 @@ final class ProblemReportInteractor: @unchecked Sendable { private let tunnelManager: TunnelManager private let consolidatedLog: ConsolidatedApplicationLog private var reportedString = "" + private var requestCancellable: Cancellable? init(apiProxy: APIQuerying, tunnelManager: TunnelManager) { self.apiProxy = apiProxy @@ -28,13 +29,10 @@ final class ProblemReportInteractor: @unchecked Sendable { ) } - func fetchReportString(completion: @escaping @Sendable (String) -> Void) { + func fetchReportString(completion: @escaping @Sendable (Result<String, Error>) -> Void) { consolidatedLog.addLogFiles(fileURLs: ApplicationTarget.allCases.flatMap { ApplicationConfiguration.logFileURLs(for: $0, in: ApplicationConfiguration.containerURL) - }) { [weak self] in - guard let self else { return } - completion(consolidatedLog.string) - } + }, completion: completion) } func sendReport( @@ -43,15 +41,19 @@ final class ProblemReportInteractor: @unchecked Sendable { completion: @escaping @Sendable (Result<Void, Error>) -> Void ) { let logString = self.consolidatedLog.string - if logString.isEmpty { - fetchReportString { [weak self] updatedLogString in - self?.sendProblemReport( - email: email, - message: message, - logString: updatedLogString, - completion: completion - ) + fetchReportString { [weak self] result in + switch result { + case let .success(logString): + self?.sendProblemReport( + email: email, + message: message, + logString: logString, + completion: completion + ) + case let .failure(error): + completion(.failure(error)) + } } } else { sendProblemReport( @@ -63,6 +65,11 @@ final class ProblemReportInteractor: @unchecked Sendable { } } + func cancelSendingReport() { + consolidatedLog.cancel() + requestCancellable?.cancel() + } + private func sendProblemReport( email: String, message: String, @@ -80,10 +87,10 @@ final class ProblemReportInteractor: @unchecked Sendable { metadata: metadataDict ) - _ = self.apiProxy.sendProblemReport(request, retryStrategy: .default, completionHandler: { result in + requestCancellable = self.apiProxy.sendProblemReport(request, retryStrategy: .default) { result in DispatchQueue.main.async { completion(result) } - }) + } } } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift index 01d7afb09c..3ba1ca5f58 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift @@ -95,20 +95,28 @@ class ProblemReportReviewViewController: UIViewController { private func loadLogs() { spinnerView.startAnimating() - interactor.fetchReportString { [weak self] reportString in + interactor.fetchReportString { [weak self] result in guard let self else { return } - Task { @MainActor in - textView.text = reportString - spinnerView.stopAnimating() - spinnerContainerView.isHidden = true + + if case let .success(reportString) = result { + Task { @MainActor in + textView.text = reportString + spinnerView.stopAnimating() + spinnerContainerView.isHidden = true + } } } } #if DEBUG private func share() { - interactor.fetchReportString { [weak self] reportString in - guard let self,!reportString.isEmpty else { return } + interactor.fetchReportString { [weak self] result in + guard + let self, + case let .success(reportString) = result, + !reportString.isEmpty + else { return } + Task { @MainActor in let activityController = UIActivityViewController( activityItems: [reportString], diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift index e4b68a4cea..0d7148380f 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift @@ -11,6 +11,8 @@ import MullvadREST import UIKit class ProblemReportSubmissionOverlayView: UIView { + var viewLogsButtonAction: (() -> Void)? + var cancelButtonAction: (() -> Void)? var editButtonAction: (() -> Void)? var retryButtonAction: (() -> Void)? @@ -19,24 +21,28 @@ class ProblemReportSubmissionOverlayView: UIView { case sent(_ email: String) case failure(Error) + var supportEmail: String { + "support@mullvadvpn.net" + } + var title: String? { switch self { case .sending: - return NSLocalizedString( + NSLocalizedString( "SUBMISSION_STATUS_SENDING", tableName: "ProblemReport", value: "Sending...", comment: "" ) case .sent: - return NSLocalizedString( + NSLocalizedString( "SUBMISSION_STATUS_SENT", tableName: "ProblemReport", value: "Sent", comment: "" ) case .failure: - return NSLocalizedString( + NSLocalizedString( "SUBMISSION_STATUS_FAILURE", tableName: "ProblemReport", value: "Failed to send", @@ -45,7 +51,7 @@ class ProblemReportSubmissionOverlayView: UIView { } } - var body: NSAttributedString? { + var body: [NSAttributedString]? { switch self { case .sending: return nil @@ -93,14 +99,44 @@ class ProblemReportSubmissionOverlayView: UIView { combinedAttributedString.append(emailAttributedString) } - return combinedAttributedString + return [combinedAttributedString] - case let .failure(error): - if let error = error as? REST.Error { - return error.displayErrorDescription.flatMap { NSAttributedString(string: $0) } - } else { - return NSAttributedString(string: error.localizedDescription) - } + case .failure: + return [ + NSAttributedString( + string: NSLocalizedString( + "MESSAGE_FAILED_PART_1", + tableName: "ProblemReport", + value: + """ + If you exit the form and try again later, the information you already entered will still \ + be here. + """, + comment: "" + ) + ), + NSAttributedString( + markdownString: NSLocalizedString( + "MESSAGE_FAILED_PART_2", + tableName: "ProblemReport", + value: + """ + If you still experience issues you can email our support directly at \ + **\(supportEmail)**. Please attach your app log to your email. + """, + comment: "" + ), + options: MarkdownStylingOptions( + font: .preferredFont(forTextStyle: .body) + ), applyEffect: { _, _ in + [ + // Setting font again to circumvent bold weight. + .font: UIFont.preferredFont(forTextStyle: .body), + .foregroundColor: UIColor.white, + ] + } + ), + ] } } } @@ -127,24 +163,54 @@ class ProblemReportSubmissionOverlayView: UIView { return textLabel }() - let bodyLabel: UILabel = { - let textLabel = UILabel() - textLabel.font = UIFont.systemFont(ofSize: 17) - textLabel.textColor = .white - textLabel.numberOfLines = 0 - return textLabel + let bodyLabelContainer: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 24 + return stackView }() - /// Footer stack view that contains action buttons - private lazy var buttonsStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [self.editMessageButton, self.tryAgainButton]) + /// Footer stack view that contains action buttons. + private lazy var buttonContainer: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [cancelButton, failedToSendButtons]) stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical stackView.spacing = 18 + return stackView + }() + /// Footer stack view that contains action buttons when sending failed. + private lazy var failedToSendButtons: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [editMessageButton, viewLogsButton, tryAgainButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 18 return stackView }() + private lazy var viewLogsButton: AppButton = { + let button = AppButton(style: .default) + button.setAccessibilityIdentifier(.problemReportAppLogsButton) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(ProblemReportViewModel.viewLogsButtonTitle, for: .normal) + button.addTarget(self, action: #selector(handleViewLogsButton), for: .touchUpInside) + return button + }() + + private lazy var cancelButton: AppButton = { + let button = AppButton(style: .default) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString( + "CANCEL_BUTTON", + tableName: "ProblemReport", + value: "Cancel", + comment: "" + ), for: .normal) + button.addTarget(self, action: #selector(handleCancelButton), for: .touchUpInside) + return button + }() + private lazy var editMessageButton: AppButton = { let button = AppButton(style: .default) button.translatesAutoresizingMaskIntoConstraints = false @@ -189,10 +255,10 @@ class ProblemReportSubmissionOverlayView: UIView { private func addSubviews() { for subview in [ titleLabel, - bodyLabel, + bodyLabelContainer, activityIndicator, statusImageView, - buttonsStackView, + buttonContainer, ] { subview.translatesAutoresizingMaskIntoConstraints = false addSubview(subview) @@ -212,49 +278,81 @@ class ProblemReportSubmissionOverlayView: UIView { titleLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), titleLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - bodyLabel.topAnchor.constraint( + bodyLabelContainer.topAnchor.constraint( equalToSystemSpacingBelow: titleLabel.bottomAnchor, multiplier: 1 ), - bodyLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), - bodyLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - buttonsStackView.topAnchor.constraint( - greaterThanOrEqualTo: bodyLabel.bottomAnchor, + bodyLabelContainer.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + bodyLabelContainer.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + buttonContainer.topAnchor.constraint( + greaterThanOrEqualTo: bodyLabelContainer.bottomAnchor, constant: 18 ), - buttonsStackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), - buttonsStackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - buttonsStackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + buttonContainer.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + buttonContainer.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + buttonContainer.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), ]) } private func transitionToState(_ state: State) { titleLabel.text = state.title - bodyLabel.attributedText = state.body + + bodyLabelContainer.subviews.forEach { $0.removeFromSuperview() } + state.body?.forEach { attributedString in + let textLabel = UILabel() + textLabel.font = UIFont.systemFont(ofSize: 17) + textLabel.textColor = .white.withAlphaComponent(0.6) + textLabel.numberOfLines = 0 + textLabel.attributedText = attributedString + + if attributedString.string.contains(state.supportEmail) { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleEmailLabelTap)) + textLabel.addGestureRecognizer(tapGesture) + textLabel.isUserInteractionEnabled = true + } + + bodyLabelContainer.addArrangedSubview(textLabel) + } switch state { case .sending: activityIndicator.startAnimating() statusImageView.isHidden = true - buttonsStackView.isHidden = true + cancelButton.isHidden = false + failedToSendButtons.isHidden = true case .sent: activityIndicator.stopAnimating() statusImageView.style = .success statusImageView.isHidden = false - buttonsStackView.isHidden = true + buttonContainer.isHidden = true case .failure: activityIndicator.stopAnimating() statusImageView.style = .failure statusImageView.isHidden = false - buttonsStackView.isHidden = false + cancelButton.isHidden = true + failedToSendButtons.isHidden = false } } // MARK: - Actions + @objc private func handleEmailLabelTap() { + if let url = URL(string: "mailto:\(state.supportEmail)") { + UIApplication.shared.open(url) + } + } + + @objc private func handleViewLogsButton() { + viewLogsButtonAction?() + } + + @objc private func handleCancelButton() { + cancelButtonAction?() + } + @objc private func handleEditButton() { editButtonAction?() } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift index 484c60ef8f..c5370188eb 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift @@ -30,7 +30,7 @@ extension ProblemReportViewController { textLabel.translatesAutoresizingMaskIntoConstraints = false textLabel.numberOfLines = 0 textLabel.textColor = .white - textLabel.text = Self.persistentViewModel.subheadLabelText + textLabel.text = ProblemReportViewModel.subheadLabelText return textLabel } @@ -48,7 +48,7 @@ extension ProblemReportViewController { textField.backgroundColor = .white textField.inputAccessoryView = emailAccessoryToolbar textField.font = UIFont.systemFont(ofSize: 17) - textField.placeholder = Self.persistentViewModel.emailPlaceholderText + textField.placeholder = ProblemReportViewModel.emailPlaceholderText return textField } @@ -58,7 +58,7 @@ extension ProblemReportViewController { textView.backgroundColor = .white textView.inputAccessoryView = messageAccessoryToolbar textView.font = UIFont.systemFont(ofSize: 17) - textView.placeholder = Self.persistentViewModel.messageTextViewPlaceholder + textView.placeholder = ProblemReportViewModel.messageTextViewPlaceholder textView.contentInsetAdjustmentBehavior = .never return textView @@ -90,7 +90,7 @@ extension ProblemReportViewController { let button = AppButton(style: .default) button.setAccessibilityIdentifier(.problemReportAppLogsButton) button.translatesAutoresizingMaskIntoConstraints = false - button.setTitle(Self.persistentViewModel.viewLogsButtonTitle, for: .normal) + button.setTitle(ProblemReportViewModel.viewLogsButtonTitle, for: .normal) button.addTarget(self, action: #selector(handleViewLogsButtonTap), for: .touchUpInside) return button } @@ -99,7 +99,7 @@ extension ProblemReportViewController { let button = AppButton(style: .success) button.setAccessibilityIdentifier(.problemReportSendButton) button.translatesAutoresizingMaskIntoConstraints = false - button.setTitle(Self.persistentViewModel.sendLogsButtonTitle, for: .normal) + button.setTitle(ProblemReportViewModel.sendLogsButtonTitle, for: .normal) button.addTarget(self, action: #selector(handleSendButtonTap), for: .touchUpInside) return button } @@ -108,6 +108,14 @@ extension ProblemReportViewController { let overlay = ProblemReportSubmissionOverlayView() overlay.translatesAutoresizingMaskIntoConstraints = false + overlay.viewLogsButtonAction = { [weak self] in + self?.handleViewLogsButtonTap() + } + + overlay.cancelButtonAction = { [weak self] in + self?.interactor.cancelSendingReport() + } + overlay.editButtonAction = { [weak self] in self?.hideSubmissionOverlay() } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift index 0e7cda5f44..e1b37598cb 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift @@ -12,8 +12,8 @@ import Operations import UIKit final class ProblemReportViewController: UIViewController, UITextFieldDelegate { - private let interactor: ProblemReportInteractor private let alertPresenter: AlertPresenter + let interactor: ProblemReportInteractor var textViewKeyboardResponder: AutomaticKeyboardResponder? var scrollViewKeyboardResponder: AutomaticKeyboardResponder? @@ -77,7 +77,7 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { view.backgroundColor = .secondaryColor view.setAccessibilityIdentifier(.problemReportView) - navigationItem.title = Self.persistentViewModel.navigationTitle + navigationItem.title = ProblemReportViewModel.navigationTitle textViewKeyboardResponder = AutomaticKeyboardResponder(targetView: messageTextView) scrollViewKeyboardResponder = AutomaticKeyboardResponder(targetView: scrollView) @@ -170,17 +170,17 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { let presentation = AlertPresentation( id: "problem-report-alert", icon: .alert, - message: Self.persistentViewModel.emptyEmailAlertWarning, + message: ProblemReportViewModel.emptyEmailAlertWarning, buttons: [ AlertAction( - title: Self.persistentViewModel.confirmEmptyEmailTitle, + title: ProblemReportViewModel.confirmEmptyEmailTitle, style: .destructive, handler: { completion(true) } ), AlertAction( - title: Self.persistentViewModel.cancelEmptyEmailTitle, + title: ProblemReportViewModel.cancelEmptyEmailTitle, style: .default, handler: { completion(false) @@ -245,7 +245,11 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { clearPersistentViewModel() case let .failure(error): - submissionOverlayView.state = .failure(error) + if let error = error as? OperationError, error == .cancelled { + hideSubmissionOverlay() + } else { + submissionOverlayView.state = .failure(error) + } } navigationItem.setHidesBackButton(false, animated: true) @@ -261,9 +265,9 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { interactor.sendReport( email: viewModel.email, message: viewModel.message - ) { completion in + ) { [weak self] completion in Task { @MainActor in - self.didSendProblemReport(viewModel: viewModel, completion: completion) + self?.didSendProblemReport(viewModel: viewModel, completion: completion) } } } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift index 6805b97b6c..e6537ec46b 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift @@ -12,14 +12,14 @@ struct ProblemReportViewModel { let email: String let message: String - let navigationTitle = NSLocalizedString( + static let navigationTitle = NSLocalizedString( "NAVIGATION_TITLE", tableName: "ProblemReport", value: "Report a problem", comment: "" ) - let subheadLabelText = NSLocalizedString( + static let subheadLabelText = NSLocalizedString( "SUBHEAD_LABEL", tableName: "ProblemReport", value: """ @@ -30,14 +30,14 @@ struct ProblemReportViewModel { comment: "" ) - let emailPlaceholderText = NSLocalizedString( + static let emailPlaceholderText = NSLocalizedString( "EMAIL_TEXTFIELD_PLACEHOLDER", tableName: "ProblemReport", value: "Your email (optional)", comment: "" ) - let messageTextViewPlaceholder = NSLocalizedString( + static let messageTextViewPlaceholder = NSLocalizedString( "DESCRIPTION_TEXTVIEW_PLACEHOLDER", tableName: "ProblemReport", value: """ @@ -47,21 +47,21 @@ struct ProblemReportViewModel { comment: "" ) - let viewLogsButtonTitle = NSLocalizedString( + static let viewLogsButtonTitle = NSLocalizedString( "VIEW_APP_LOGS_BUTTON_TITLE", tableName: "ProblemReport", value: "View app logs", comment: "" ) - let sendLogsButtonTitle = NSLocalizedString( + static let sendLogsButtonTitle = NSLocalizedString( "SEND_BUTTON_TITLE", tableName: "ProblemReport", value: "Send", comment: "" ) - let emptyEmailAlertWarning = NSLocalizedString( + static let emptyEmailAlertWarning = NSLocalizedString( "EMPTY_EMAIL_ALERT_MESSAGE", tableName: "ProblemReport", value: """ @@ -71,14 +71,14 @@ struct ProblemReportViewModel { comment: "" ) - let confirmEmptyEmailTitle = NSLocalizedString( + static let confirmEmptyEmailTitle = NSLocalizedString( "EMPTY_EMAIL_ALERT_SEND_ANYWAY_ACTION", tableName: "ProblemReport", value: "Send anyway", comment: "" ) - let cancelEmptyEmailTitle = NSLocalizedString( + static let cancelEmptyEmailTitle = NSLocalizedString( "EMPTY_EMAIL_ALERT_CANCEL_ACTION", tableName: "ProblemReport", value: "Cancel", |
