// // 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 = UIMetrics.contentLayoutMargins 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("Please describe your problem in English or Swedish", 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 } } }