summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJon Petersson <jon.petersson@mullvad.net>2025-04-25 08:11:02 +0200
committerJon Petersson <jon.petersson@mullvad.net>2025-05-30 11:32:54 +0200
commit83effc10b5cb3f26ca1c65d30ae62baf10f495d3 (patch)
tree59b470da0d74cf1311ccd53787df47ccdca854d4
parent42b53b4bcd3d3155d42ebca7e33c9450ca5dab73 (diff)
downloadmullvadvpn-83effc10b5cb3f26ca1c65d30ae62baf10f495d3.tar.xz
mullvadvpn-83effc10b5cb3f26ca1c65d30ae62baf10f495d3.zip
Let users cancel sending a problem report
-rw-r--r--ios/MullvadMockData/MullvadREST/MockRelayCache.swift1
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj2
-rw-r--r--ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved2
-rw-r--r--ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift46
-rw-r--r--ios/MullvadVPN/Classes/MarkdownStylingOptions.swift5
-rw-r--r--ios/MullvadVPN/Extensions/NSAttributedString+Extensions.swift2
-rw-r--r--ios/MullvadVPN/Extensions/UIFont+Weight.swift9
-rw-r--r--ios/MullvadVPN/Notifications/Notification Providers/LatestChangesNotificationProvider.swift12
-rw-r--r--ios/MullvadVPN/Notifications/Notification Providers/NewDeviceNotificationProvider.swift15
-rw-r--r--ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift4
-rw-r--r--ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift4
-rw-r--r--ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift2
-rw-r--r--ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift37
-rw-r--r--ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift22
-rw-r--r--ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift166
-rw-r--r--ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift18
-rw-r--r--ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift20
-rw-r--r--ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift18
18 files changed, 270 insertions, 115 deletions
diff --git a/ios/MullvadMockData/MullvadREST/MockRelayCache.swift b/ios/MullvadMockData/MullvadREST/MockRelayCache.swift
index f2b0123b73..d41173d111 100644
--- a/ios/MullvadMockData/MullvadREST/MockRelayCache.swift
+++ b/ios/MullvadMockData/MullvadREST/MockRelayCache.swift
@@ -6,6 +6,7 @@
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
import Foundation
+
@testable import MullvadREST
public struct MockRelayCache: RelayCacheProtocol {
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index eb7a32dcd8..2251f071e2 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -561,6 +561,7 @@
7A6389ED2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389EC2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift */; };
7A6389F82B864CDF008E77E1 /* LocationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389F72B864CDF008E77E1 /* LocationNode.swift */; };
7A6652B82BB44C3E0042D848 /* LocationDiffableDataSourceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6652B62BB44B120042D848 /* LocationDiffableDataSourceProtocol.swift */; };
+ 7A6811542DC8EC6E009CB61A /* UIFont+Weight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */; };
7A6B4F592AB8412E00123853 /* TunnelMonitorTimings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */; };
7A6F2FA52AFA3CB2006D0856 /* AccountExpiryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */; };
7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */; };
@@ -6015,6 +6016,7 @@
A9A5FA0D2ACB05160083449F /* StorePaymentManagerError.swift in Sources */,
A9A5FA0E2ACB05160083449F /* StorePaymentObserver.swift in Sources */,
A9A5FA0F2ACB05160083449F /* StoreSubscription.swift in Sources */,
+ 7A6811542DC8EC6E009CB61A /* UIFont+Weight.swift in Sources */,
A9A5FA102ACB05160083449F /* PacketTunnelTransport.swift in Sources */,
7AD63A472CDA666100445268 /* UIntTests.swift in Sources */,
A9A5FA112ACB05160083449F /* TransportMonitor.swift in Sources */,
diff --git a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 3ac5cc069c..190c222682 100644
--- a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "df1c07b51917a1cc3ae17733b2b162190d08c52c14f2eb6f68410c133c2f28cc",
+ "originHash" : "159534cbc9364e3882c14ea288a06d732b74555d93ae12274bcfad4509104caf",
"pins" : [
{
"identity" : "swift-log",
diff --git a/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift b/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift
index 5cb79a5705..ef357445c5 100644
--- a/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift
+++ b/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift
@@ -7,6 +7,7 @@
//
import Foundation
+import Operations
private let kLogDelimiter = "===================="
private let kRedactedPlaceholder = "[REDACTED]"
@@ -16,6 +17,7 @@ private let kRedactedContainerPlaceholder = "[REDACTED CONTAINER PATH]"
class ConsolidatedApplicationLog: TextOutputStreamable, @unchecked Sendable {
typealias Metadata = KeyValuePairs<MetadataKey, String>
private let bufferSize: UInt64
+ private var workItem: DispatchWorkItem?
enum MetadataKey: String {
case id, os
@@ -50,21 +52,36 @@ class ConsolidatedApplicationLog: TextOutputStreamable, @unchecked Sendable {
}
}
- func addLogFiles(fileURLs: [URL], completion: (@Sendable () -> Void)? = nil) {
- logQueue.async(flags: .barrier) {
+ func cancel() {
+ workItem?.cancel()
+ }
+
+ func addLogFiles(fileURLs: [URL], completion: (@Sendable (Result<String, Error>) -> Void)? = nil) {
+ let workItem = DispatchWorkItem { [weak self] in
for fileURL in fileURLs {
- self.addSingleLogFile(fileURL)
+ guard let workItem = self?.workItem, !workItem.isCancelled else {
+ DispatchQueue.main.async {
+ completion?(.failure(OperationError.cancelled))
+ }
+ return
+ }
+
+ self?.addSingleLogFile(fileURL)
}
- DispatchQueue.main.async {
- completion?()
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ completion?(.success(self.string))
}
}
+ self.workItem = workItem
+
+ logQueue.async(execute: workItem)
}
func addError(message: String, error: String, completion: (@Sendable () -> Void)? = nil) {
let redactedError = redact(string: error)
- logQueue.async(flags: .barrier) {
- self.logs.append(LogAttachment(label: message, content: redactedError))
+ safeAsync { [weak self] in
+ self?.logs.append(LogAttachment(label: message, content: redactedError))
DispatchQueue.main.async {
completion?()
}
@@ -108,6 +125,17 @@ class ConsolidatedApplicationLog: TextOutputStreamable, @unchecked Sendable {
return result
}
+ private func safeAsync(execute: @escaping @Sendable () -> Void) {
+ let isCancelled = workItem?.isCancelled ?? false
+ guard !isCancelled else { return }
+
+ logQueue.async {
+ if !isCancelled {
+ execute()
+ }
+ }
+ }
+
private func addSingleLogFile(_ fileURL: URL) {
guard fileURL.isFileURL else {
addError(
@@ -122,8 +150,8 @@ class ConsolidatedApplicationLog: TextOutputStreamable, @unchecked Sendable {
if let lossyString = readFileLossy(path: path, maxBytes: bufferSize) {
let redactedString = redact(string: lossyString)
- logQueue.async(flags: .barrier) {
- self.logs.append(LogAttachment(label: redactedPath, content: redactedString))
+ safeAsync { [weak self] in
+ self?.logs.append(LogAttachment(label: redactedPath, content: redactedString))
}
} else {
addError(message: redactedPath, error: "Log file does not exist: \(path).")
diff --git a/ios/MullvadVPN/Classes/MarkdownStylingOptions.swift b/ios/MullvadVPN/Classes/MarkdownStylingOptions.swift
index 07811aec1b..5075cae646 100644
--- a/ios/MullvadVPN/Classes/MarkdownStylingOptions.swift
+++ b/ios/MullvadVPN/Classes/MarkdownStylingOptions.swift
@@ -11,9 +11,4 @@ import UIKit
struct MarkdownStylingOptions {
var font: UIFont
var paragraphStyle: NSParagraphStyle = .default
-
- var boldFont: UIFont {
- let fontDescriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor
- return UIFont(descriptor: fontDescriptor, size: font.pointSize)
- }
}
diff --git a/ios/MullvadVPN/Extensions/NSAttributedString+Extensions.swift b/ios/MullvadVPN/Extensions/NSAttributedString+Extensions.swift
index 9e5745a56f..fd9ebc5e7e 100644
--- a/ios/MullvadVPN/Extensions/NSAttributedString+Extensions.swift
+++ b/ios/MullvadVPN/Extensions/NSAttributedString+Extensions.swift
@@ -27,7 +27,7 @@ extension NSAttributedString {
if stringIndex % 2 == 0 {
attributes[.font] = options.font
} else {
- attributes[.font] = options.boldFont
+ attributes[.font] = options.font.withWeight(.bold)
attributes.merge(applyEffect?(.bold, string) ?? [:], uniquingKeysWith: { $1 })
}
diff --git a/ios/MullvadVPN/Extensions/UIFont+Weight.swift b/ios/MullvadVPN/Extensions/UIFont+Weight.swift
index c3438650eb..146cbd128b 100644
--- a/ios/MullvadVPN/Extensions/UIFont+Weight.swift
+++ b/ios/MullvadVPN/Extensions/UIFont+Weight.swift
@@ -17,4 +17,13 @@ extension UIFont {
return UIFont(descriptor: descriptor, size: 0)
}
+
+ func withWeight(_ weight: UIFont.Weight) -> UIFont {
+ let newDescriptor = fontDescriptor.addingAttributes([
+ .traits: [
+ UIFontDescriptor.TraitKey.weight: weight,
+ ],
+ ])
+ return UIFont(descriptor: newDescriptor, size: pointSize)
+ }
}
diff --git a/ios/MullvadVPN/Notifications/Notification Providers/LatestChangesNotificationProvider.swift b/ios/MullvadVPN/Notifications/Notification Providers/LatestChangesNotificationProvider.swift
index 59441d899d..02991b5848 100644
--- a/ios/MullvadVPN/Notifications/Notification Providers/LatestChangesNotificationProvider.swift
+++ b/ios/MullvadVPN/Notifications/Notification Providers/LatestChangesNotificationProvider.swift
@@ -60,12 +60,12 @@ class LatestChangesNotificationProvider: NotificationProvider, InAppNotification
value: "**Tap here** to see what’s new.",
comment: ""
),
- options: MarkdownStylingOptions(font: UIFont.preferredFont(forTextStyle: .body)),
- applyEffect: { markdownType, _ in
- guard case .bold = markdownType else { return [:] }
- return [.foregroundColor: UIColor.InAppNotificationBanner.titleColor]
- }
- )
+ options: MarkdownStylingOptions(
+ font: .preferredFont(forTextStyle: .body)
+ )
+ ) { _, _ in
+ [.foregroundColor: UIColor.InAppNotificationBanner.titleColor]
+ }
}
private func createCloseButtonAction() -> InAppNotificationAction {
diff --git a/ios/MullvadVPN/Notifications/Notification Providers/NewDeviceNotificationProvider.swift b/ios/MullvadVPN/Notifications/Notification Providers/NewDeviceNotificationProvider.swift
index 22a7a09da5..66a448db4d 100644
--- a/ios/MullvadVPN/Notifications/Notification Providers/NewDeviceNotificationProvider.swift
+++ b/ios/MullvadVPN/Notifications/Notification Providers/NewDeviceNotificationProvider.swift
@@ -33,14 +33,13 @@ final class NewDeviceNotificationProvider: NotificationProvider,
let deviceName = storedDeviceData?.capitalizedName ?? ""
let string = String(format: formattedString, deviceName)
- let stylingOptions = MarkdownStylingOptions(font: .systemFont(ofSize: 14.0))
-
- return NSAttributedString(markdownString: string, options: stylingOptions) { markdownType, _ in
- if case .bold = markdownType {
- return [.foregroundColor: UIColor.InAppNotificationBanner.titleColor]
- } else {
- return [:]
- }
+ return NSAttributedString(
+ markdownString: string,
+ options: MarkdownStylingOptions(
+ font: .preferredFont(forTextStyle: .body)
+ )
+ ) { _, _ in
+ [.foregroundColor: UIColor.InAppNotificationBanner.titleColor]
}
}
diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift
index 190e1e7281..521b5bc95f 100644
--- a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift
+++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift
@@ -328,9 +328,7 @@ class AccountDeletionContentView: UIView {
)
messageLabel.attributedText = NSAttributedString(
markdownString: text,
- options: MarkdownStylingOptions(
- font: .preferredFont(forTextStyle: .body)
- )
+ options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body))
)
}
}
diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift
index 8663d8bdb3..b297401213 100644
--- a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift
+++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift
@@ -200,9 +200,7 @@ class DeviceManagementViewController: UIViewController, RootContainment {
let attributedText = NSAttributedString(
markdownString: text,
- options: MarkdownStylingOptions(
- font: .preferredFont(forTextStyle: .body)
- )
+ options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body))
)
let presentation = AlertPresentation(
diff --git a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift
index ffdffee232..4e2bb87763 100644
--- a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift
+++ b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift
@@ -147,7 +147,7 @@ class OutOfTimeContentView: UIView {
func setBodyLabelText(_ text: String) {
bodyLabel.attributedText = NSAttributedString(
markdownString: text,
- options: MarkdownStylingOptions(font: .systemFont(ofSize: 17))
+ options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body))
)
}
}
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",