diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2020-09-15 13:24:42 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2021-02-24 14:28:01 +0100 |
| commit | 1a789a006da5bbf788d8ca956fbcb96a0c884144 (patch) | |
| tree | edb539421099207a046c03e531213fe12b993265 /ios/MullvadVPN/ProblemReportViewController.swift | |
| parent | c25245302974e7534fbc665420e294e0effcaf80 (diff) | |
| download | mullvadvpn-1a789a006da5bbf788d8ca956fbcb96a0c884144.tar.xz mullvadvpn-1a789a006da5bbf788d8ca956fbcb96a0c884144.zip | |
Implement problem report UI and log consolidation
Diffstat (limited to 'ios/MullvadVPN/ProblemReportViewController.swift')
| -rw-r--r-- | ios/MullvadVPN/ProblemReportViewController.swift | 653 |
1 files changed, 653 insertions, 0 deletions
diff --git a/ios/MullvadVPN/ProblemReportViewController.swift b/ios/MullvadVPN/ProblemReportViewController.swift new file mode 100644 index 0000000000..b2096963d6 --- /dev/null +++ b/ios/MullvadVPN/ProblemReportViewController.swift @@ -0,0 +1,653 @@ +// +// ProblemReportViewController.swift +// MullvadVPN +// +// Created by pronebird on 15/09/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class ProblemReportViewController: UIViewController, UITextFieldDelegate, ConditionalNavigation { + + private let mullvadRest = MullvadRest(session: URLSession(configuration: .ephemeral)) + private lazy var consolidatedLog: ConsolidatedApplicationLog = { + let securityGroupIdentifier = ApplicationConfiguration.securityGroupIdentifier + + // TODO: make sure we redact old tokens + let redactStrings = Account.shared.token.flatMap { [$0] } ?? [] + + let report = ConsolidatedApplicationLog( + redactCustomStrings: redactStrings, + redactContainerPathsForSecurityGroupIdentifiers: [securityGroupIdentifier] + ) + + report.addLogFiles(fileURLs: ApplicationConfiguration.logFileURLs) + + return report + }() + + /// Scroll view + private lazy var scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + return scrollView + }() + + /// Scroll view content container + private lazy var containerView: UIView = { + let containerView = UIView() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.layoutMargins = UIEdgeInsets(top: 8, left: 24, bottom: 24, right: 24) + containerView.backgroundColor = .clear + return containerView + }() + + /// Subheading label displayed below navigation bar + private lazy var subheaderLabel: UILabel = { + let textLabel = UILabel() + textLabel.translatesAutoresizingMaskIntoConstraints = false + textLabel.numberOfLines = 0 + textLabel.textColor = .white + textLabel.text = NSLocalizedString("To help you more effectively, your app's log file will be attached to this message. Your data will remain secure and private, as it is anonymised before being sent over an encrypted channel.", comment: "") + + return textLabel + }() + + private lazy var emailTextField: CustomTextField = { + let textField = CustomTextField() + textField.translatesAutoresizingMaskIntoConstraints = false + textField.delegate = self + textField.keyboardType = .emailAddress + textField.textContentType = .emailAddress + textField.autocorrectionType = .no + textField.autocapitalizationType = .none + textField.smartInsertDeleteType = .no + textField.returnKeyType = .next + textField.borderStyle = .none + textField.backgroundColor = .white + textField.inputAccessoryView = emailAccessoryToolbar + textField.font = UIFont.systemFont(ofSize: 17) + textField.placeholder = NSLocalizedString("Your email (optional)", comment: "") + + return textField + }() + + private lazy var messageTextView: CustomTextView = { + let textView = CustomTextView() + textView.translatesAutoresizingMaskIntoConstraints = false + textView.backgroundColor = .white + textView.inputAccessoryView = messageAccessoryToolbar + textView.font = UIFont.systemFont(ofSize: 17) + textView.placeholder = NSLocalizedString("Describe your problem", comment: "") + textView.contentInsetAdjustmentBehavior = .never + + return textView + }() + + /// Container view for text input fields + private lazy var textFieldsHolder: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + /// Constraints used when description text view is active + private var activeMessageTextViewConstraints = [NSLayoutConstraint]() + + /// Constraints used when description text view is inactive + private var inactiveMessageTextViewConstraints = [NSLayoutConstraint]() + + /// Flag indicating when the text view is expanded to fill the entire view + private var isMessageTextViewExpanded = false + + /// Keyboard intersection with the controller view + private var keyboardIntersectionRect = CGRect.zero + + /// Bottom content inset necessary to compensate for the keyboard overlapping + var scrollViewBottomContentInsetAccountingForKeyboard: CGFloat { + return max(0, keyboardIntersectionRect.height - view.safeAreaInsets.bottom) + } + + /// Placeholder view used to fill the space within the scroll view when the text view is + /// expanded to fill the entire view + private lazy var messagePlaceholder: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + return view + }() + + /// Footer stack view that contains action buttons + private lazy var buttonsStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.viewLogsButton, self.sendButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 18 + + return stackView + }() + + private lazy var viewLogsButton: AppButton = { + let button = AppButton(style: .default) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString("View app logs", comment: ""), for: .normal) + button.addTarget(self, action: #selector(handleViewLogsButtonTap), for: .touchUpInside) + return button + }() + + private lazy var sendButton: AppButton = { + let button = AppButton(style: .success) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString("Send", comment: ""), for: .normal) + button.addTarget(self, action: #selector(handleSendButtonTap), for: .touchUpInside) + return button + }() + + private lazy var emailAccessoryToolbar: UIToolbar = { + return makeKeyboardToolbar(canGoBackward: false, canGoForward: true) + }() + + private lazy var messageAccessoryToolbar: UIToolbar = { + return makeKeyboardToolbar(canGoBackward: true, canGoForward: false) + }() + + private lazy var submissionOverlayView: ProblemReportSubmissionOverlayView = { + let overlay = ProblemReportSubmissionOverlayView() + overlay.translatesAutoresizingMaskIntoConstraints = false + + overlay.editButtonAction = { [weak self] in + self?.hideSubmissionOverlay() + } + + overlay.retryButtonAction = { [weak self] in + self?.sendProblemReport() + } + + return overlay + }() + + // MARK: - View lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .secondaryColor + + navigationItem.title = NSLocalizedString("Report a problem", comment: "Navigation title") + + // Make sure that the user can't easily dismiss the controller on iOS 13 and above + if #available(iOS 13.0, *) { + isModalInPresentation = true + } + + // Set hugging & compression priorities so that description text view wants to grow + emailTextField.setContentHuggingPriority(.defaultHigh, for: .vertical) + emailTextField.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + messageTextView.setContentHuggingPriority(.defaultLow, for: .vertical) + messageTextView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + + textFieldsHolder.addSubview(emailTextField) + textFieldsHolder.addSubview(messagePlaceholder) + textFieldsHolder.addSubview(messageTextView) + + view.addSubview(scrollView) + scrollView.addSubview(containerView) + containerView.addSubview(subheaderLabel) + containerView.addSubview(textFieldsHolder) + containerView.addSubview(buttonsStackView) + + addConstraints() + registerForNotifications() + + loadPersistentViewModel() + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + updateScrollViewContentInsets() + updateMessageTextViewContentInsets() + } + + + // MARK: - Actions + + @objc func focusEmailTextField() { + emailTextField.becomeFirstResponder() + } + + @objc func focusDescriptionTextView() { + messageTextView.becomeFirstResponder() + } + + @objc func dismissKeyboard() { + view.endEditing(false) + } + + @objc func handleSendButtonTap() { + let proceedWithSubmission = { + self.sendProblemReport() + } + + if Self.persistentViewModel.email.isEmpty { + presentEmptyEmailConfirmationAlert { (shouldSend) in + if shouldSend { + proceedWithSubmission() + } + } + } else { + proceedWithSubmission() + } + } + + @objc func handleViewLogsButtonTap() { + let reviewController = ProblemReportReviewViewController(reportString: consolidatedLog.string) + let navigationController = UINavigationController(rootViewController: reviewController) + + present(navigationController, animated: true) + } + + // MARK: - Private + + private func registerForNotifications() { + let notificationCenter = NotificationCenter.default + + notificationCenter.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), + name: UIWindow.keyboardWillChangeFrameNotification, + object: nil) + notificationCenter.addObserver(self, selector: #selector(emailTextFieldDidChange), + name: UITextField.textDidChangeNotification, + object: emailTextField) + notificationCenter.addObserver(self, selector: #selector(messageTextViewDidBeginEditing), + name: UITextView.textDidBeginEditingNotification, + object: messageTextView) + notificationCenter.addObserver(self, selector: #selector(messageTextViewDidEndEditing), + name: UITextView.textDidEndEditingNotification, + object: messageTextView) + notificationCenter.addObserver(self, selector: #selector(messageTextViewDidChange), + name: UITextView.textDidChangeNotification, + object: messageTextView) + } + + private func makeKeyboardToolbar(canGoBackward: Bool, canGoForward: Bool) -> UIToolbar { + var toolbarItems = UIBarButtonItem.makeKeyboardNavigationItems { (prevButton, nextButton) in + prevButton.target = self + prevButton.action = #selector(focusEmailTextField) + prevButton.isEnabled = canGoBackward + + nextButton.target = self + nextButton.action = #selector(focusDescriptionTextView) + nextButton.isEnabled = canGoForward + } + + toolbarItems.append(contentsOf: [ + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(dismissKeyboard)) + ]) + + let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + toolbar.items = toolbarItems + return toolbar + } + + private func addConstraints() { + self.activeMessageTextViewConstraints = [ + messageTextView.topAnchor.constraint(equalTo: view.topAnchor), + messageTextView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + messageTextView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + messageTextView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ] + + self.inactiveMessageTextViewConstraints = [ + messageTextView.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 12), + messageTextView.leadingAnchor.constraint(equalTo: textFieldsHolder.leadingAnchor), + messageTextView.trailingAnchor.constraint(equalTo: textFieldsHolder.trailingAnchor), + messageTextView.bottomAnchor.constraint(equalTo: textFieldsHolder.bottomAnchor), + ] + + var constraints = [ + subheaderLabel.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor), + subheaderLabel.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), + subheaderLabel.trailingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor), + + textFieldsHolder.topAnchor.constraint(equalTo: subheaderLabel.bottomAnchor, constant: 24), + textFieldsHolder.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), + textFieldsHolder.trailingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor), + + buttonsStackView.topAnchor.constraint(equalTo: textFieldsHolder.bottomAnchor, constant: 18), + buttonsStackView.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), + buttonsStackView.trailingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor), + buttonsStackView.bottomAnchor.constraint(equalTo: containerView.layoutMarginsGuide.bottomAnchor), + + emailTextField.topAnchor.constraint(equalTo: textFieldsHolder.topAnchor), + emailTextField.leadingAnchor.constraint(equalTo: textFieldsHolder.leadingAnchor), + emailTextField.trailingAnchor.constraint(equalTo: textFieldsHolder.trailingAnchor), + + messagePlaceholder.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 12), + messagePlaceholder.leadingAnchor.constraint(equalTo: textFieldsHolder.leadingAnchor), + messagePlaceholder.trailingAnchor.constraint(equalTo: textFieldsHolder.trailingAnchor), + messagePlaceholder.bottomAnchor.constraint(equalTo: textFieldsHolder.bottomAnchor), + messagePlaceholder.heightAnchor.constraint(equalTo: messageTextView.heightAnchor), + + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), + scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + scrollView.contentLayoutGuide.topAnchor.constraint(equalTo: containerView.topAnchor), + scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + scrollView.contentLayoutGuide.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + scrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + + scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + scrollView.contentLayoutGuide.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.safeAreaLayoutGuide.heightAnchor), + + messageTextView.heightAnchor.constraint(greaterThanOrEqualToConstant: 150), + ] + + constraints.append(contentsOf: self.inactiveMessageTextViewConstraints) + + NSLayoutConstraint.activate(constraints) + } + + private func setDescriptionFieldExpanded(_ isExpanded: Bool) { + if isExpanded { + // Disable the large title + self.navigationItem.largeTitleDisplayMode = .never + + // Move the text view above scroll view + view.addSubview(self.messageTextView) + + // Re-add old constraints + NSLayoutConstraint.activate(self.inactiveMessageTextViewConstraints) + + // Do a layout pass + view.layoutIfNeeded() + + // Swap constraints + NSLayoutConstraint.deactivate(self.inactiveMessageTextViewConstraints) + NSLayoutConstraint.activate(self.activeMessageTextViewConstraints) + + // Enable content inset adjustment on text view + self.messageTextView.contentInsetAdjustmentBehavior = .always + + // Animate constraints & rounded corners on the text view + animateDescriptionTextView(animations: { + // Turn off rounded corners as the text view fills in the entire view + self.messageTextView.roundCorners = false + + self.view.layoutIfNeeded() + }) { (completed) in + self.isMessageTextViewExpanded = true + + self.updateMessageTextViewContentInsets() + } + + } else { + // Re-enable the large title + self.navigationItem.largeTitleDisplayMode = .automatic + + // Swap constraints + NSLayoutConstraint.deactivate(self.activeMessageTextViewConstraints) + NSLayoutConstraint.activate(self.inactiveMessageTextViewConstraints) + + // Animate constraints & rounded corners on the text view + animateDescriptionTextView(animations: { + // Turn on rounded corners as the text view returns back to where it was + self.messageTextView.roundCorners = true + + self.view.layoutIfNeeded() + }) { (completed) in + // Revert the content adjustment behavior + self.messageTextView.contentInsetAdjustmentBehavior = .never + + // Add the text view inside of the scroll view + self.textFieldsHolder.addSubview(self.messageTextView) + + self.isMessageTextViewExpanded = false + } + } + } + + private func updateScrollViewContentInsets() { + let scrollViewBottomInset = scrollViewBottomContentInsetAccountingForKeyboard + + scrollView.contentInset.bottom = scrollViewBottomInset + scrollView.scrollIndicatorInsets.bottom = scrollViewBottomInset + } + + private func updateMessageTextViewContentInsets() { + // Ignore updating text view insets until it's fully expanded + guard isMessageTextViewExpanded else { return } + + let textViewBottomInset: CGFloat + + if messageTextView.isFirstResponder { + textViewBottomInset = scrollViewBottomContentInsetAccountingForKeyboard + } else { + textViewBottomInset = 0 + } + + messageTextView.contentInset.bottom = textViewBottomInset + messageTextView.scrollIndicatorInsets.bottom = textViewBottomInset + } + + private func animateDescriptionTextView(animations: @escaping () -> Void, completion: @escaping (Bool) -> Void) { + UIView.animate(withDuration: 0.25, animations: animations) { (completed) in + completion(completed) + } + } + + private func presentEmptyEmailConfirmationAlert(completion: @escaping (Bool) -> Void) { + let message = NSLocalizedString("You are about to send the problem report without a way for us to get back to you. If you want an answer to your report you will have to enter an email address.", comment: "") + + let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert) + + let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { _ in + completion(false) + } + let sendAction = UIAlertAction(title: NSLocalizedString("Send anyway", comment: ""), style: .destructive) { _ in + completion(true) + } + + alertController.addAction(cancelAction) + alertController.addAction(sendAction) + + present(alertController, animated: true) + } + + // MARK: - Private: Problem report submission + + private var showsSubmissionOverlay = false + + private func showSubmissionOverlay() { + guard !showsSubmissionOverlay else { return } + + self.showsSubmissionOverlay = true + + view.addSubview(submissionOverlayView) + + NSLayoutConstraint.activate([ + submissionOverlayView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + submissionOverlayView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + submissionOverlayView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + submissionOverlayView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + + UIView.transition(from: scrollView, to: submissionOverlayView, duration: 0.25, options: [.showHideTransitionViews, .transitionCrossDissolve]) { (success) in + // success + } + } + + private func hideSubmissionOverlay() { + guard showsSubmissionOverlay else { return } + + self.showsSubmissionOverlay = false + + UIView.transition(from: submissionOverlayView, to: scrollView, duration: 0.25, options: [.showHideTransitionViews, .transitionCrossDissolve]) { (success) in + // success + self.submissionOverlayView.removeFromSuperview() + } + } + + // MARK: - Data model + + private struct ViewModel { + let email: String + let message: String + + init() { + email = "" + message = "" + } + + init(email: String, message: String) { + self.email = email.trimmingCharacters(in: .whitespacesAndNewlines) + self.message = message.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var isValid: Bool { + return !message.isEmpty + } + } + + private static var persistentViewModel = ViewModel() + + private func loadPersistentViewModel() { + emailTextField.text = Self.persistentViewModel.email + messageTextView.text = Self.persistentViewModel.message + + validateForm() + } + + private func updatePersistentViewModel() { + Self.persistentViewModel = ViewModel( + email: emailTextField.text ?? "", + message: messageTextView.text + ) + + validateForm() + } + + private func clearPersistentViewModel() { + Self.persistentViewModel = ViewModel() + } + + // MARK: - Form validation + + private func validateForm() { + sendButton.isEnabled = Self.persistentViewModel.isValid + } + + // MARK: - Problem submission progress handling + + private func willSendProblemReport() { + showSubmissionOverlay() + + submissionOverlayView.state = .sending + navigationItem.setHidesBackButton(true, animated: true) + } + + private func didSendProblemReport(viewModel: ViewModel, result: Result<(), RestError>) { + switch result { + case .success: + submissionOverlayView.state = .sent(viewModel.email) + + // Clear persistent view model upon successful submission + clearPersistentViewModel() + + case .failure(let error): + submissionOverlayView.state = .failure(error) + } + + navigationItem.setHidesBackButton(false, animated: true) + } + + // MARK: - Problem report submission helpers + + private func sendProblemReport() { + let viewModel = Self.persistentViewModel + + willSendProblemReport() + sendProblemReportHelper(with: viewModel) { [weak self] (result) in + self?.didSendProblemReport(viewModel: viewModel, result: result) + } + } + + private func sendProblemReportHelper(with viewModel: ViewModel, completion: @escaping (Result<(), RestError>) -> Void) { + let log = consolidatedLog.string + let metadata = consolidatedLog.metadata.reduce(into: [:]) { (output, entry) in + output[entry.key.rawValue] = entry.value + } + + let request = ProblemReportRequest(address: viewModel.email, message: viewModel.message, log: log, metadata: metadata) + let result = mullvadRest.sendProblemReport().dataTask(payload: request) { (result) in + DispatchQueue.main.async { + completion(result) + } + } + + switch result { + case .success(let task): + task.resume() + case .failure(let error): + completion(.failure(error)) + } + } + + // MARK: - Input fields' notifications + + @objc private func messageTextViewDidBeginEditing() { + setDescriptionFieldExpanded(true) + } + + @objc private func messageTextViewDidEndEditing() { + setDescriptionFieldExpanded(false) + } + + @objc private func messageTextViewDidChange() { + updatePersistentViewModel() + } + + @objc private func emailTextFieldDidChange() { + updatePersistentViewModel() + } + + // MARK: - Keyboard notifications + + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + guard let keyboardFrameValue = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? NSValue else { return } + + let screenRect = self.view.convert(self.view.bounds, to: nil) + + keyboardIntersectionRect = screenRect.intersection(keyboardFrameValue.cgRectValue) + + updateScrollViewContentInsets() + updateMessageTextViewContentInsets() + } + + // MARK: - UITextFieldDelegate + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + messageTextView.becomeFirstResponder() + return false + } + + // MARK: - ConditionalNavigation + + func shouldPopNavigationItem(_ navigationItem: UINavigationItem, trigger: NavigationPopTrigger) -> Bool { + switch trigger { + case .interactiveGesture: + // Disable swipe when editing + return !emailTextField.isFirstResponder && !messageTextView.isFirstResponder + + case .backButton: + // Dismiss the keyboard to fix a visual glitch when moving back to the previous controller + view.endEditing(true) + return true + } + } + +} |
