Path: blob/main/Signal/src/ViewControllers/AppSettings/ComposeSupportEmailOperation.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import MessageUI
import SignalServiceKit
import SignalUI
struct SupportEmailModel {
enum LogPolicy {
/// Attempt to upload the logs and include the resulting URL in the email body
/// If the upload fails for one reason or another, continue anyway
case attemptUpload(DebugLogDumper)
/// Upload the logs. If they fail to upload, fail the operation
case requireUpload(DebugLogDumper)
/// Don't upload new logs, instead use the provided link
case link(URL)
}
/// An unlocalized string used for filtering by support
let supportFilter: String
let localizedSubject: String
let deviceType: String
let deviceIdentifier: String
let iosVersion: String
let signalAppVersion: String
let locale: String
let userDescription: String
let emojiMood: EmojiMoodPickerView.Mood?
let debugLogPolicy: LogPolicy?
let hasRecentChallenge: Bool
fileprivate var resolvedDebugString: String?
init(
userDescription: String?,
emojiMood: EmojiMoodPickerView.Mood?,
supportFilter: String?,
debugLogPolicy: LogPolicy?,
hasRecentChallenge: Bool,
) {
self.localizedSubject = OWSLocalizedString(
"SUPPORT_EMAIL_SUBJECT",
comment: "Localized subject for support request emails",
)
self.deviceType = UIDevice.current.model
self.deviceIdentifier = String(sysctlKey: "hw.machine")?.replacingOccurrences(of: UIDevice.current.model, with: "") ?? "Unknown"
self.iosVersion = AppVersionImpl.shared.iosVersionString
self.signalAppVersion = AppVersionImpl.shared.currentAppVersion
self.locale = Locale.current.identifier
self.userDescription = userDescription ?? OWSLocalizedString(
"SUPPORT_EMAIL_DEFAULT_DESCRIPTION",
comment: "Default prompt for user description in support email requests",
)
self.emojiMood = emojiMood
self.supportFilter = supportFilter ?? "Signal iOS Support Request"
self.debugLogPolicy = debugLogPolicy
self.hasRecentChallenge = hasRecentChallenge
}
}
// MARK: -
final class ComposeSupportEmailOperation: NSObject {
enum EmailError: LocalizedError, UserErrorDescriptionProvider {
case logUploadTimedOut
case logUploadFailure(underlyingError: LocalizedError?)
case invalidURL
case failedToOpenURL
var errorDescription: String? {
localizedDescription
}
var localizedDescription: String {
switch self {
case .logUploadTimedOut:
return OWSLocalizedString(
"ERROR_DESCRIPTION_REQUEST_TIMED_OUT",
comment: "Error indicating that a socket request timed out.",
)
case let .logUploadFailure(underlyingError):
return underlyingError?.errorDescription ??
OWSLocalizedString(
"ERROR_DESCRIPTION_LOG_UPLOAD_FAILED",
comment: "Generic error indicating that log upload failed",
)
case .invalidURL:
return OWSLocalizedString(
"ERROR_DESCRIPTION_INVALID_SUPPORT_EMAIL",
comment: "Error indicating that a support mailto link could not be created.",
)
case .failedToOpenURL:
return OWSLocalizedString(
"ERROR_DESCRIPTION_COULD_NOT_LAUNCH_EMAIL",
comment: "Error indicating that openURL for a mailto: URL failed.",
)
}
}
}
static var canSendEmails: Bool {
return UIApplication.shared.canOpenURL(MailtoLink(to: "", subject: "", body: "").url!)
}
private var model: SupportEmailModel
private var isCancelled: Bool = false
class func sendEmailWithDefaultErrorHandling(supportFilter: String, logUrl: URL, hasRecentChallenge: Bool) async {
do {
try await sendEmail(supportFilter: supportFilter, logUrl: logUrl, hasRecentChallenge: hasRecentChallenge)
} catch {
OWSActionSheets.showErrorAlert(message: error.userErrorDescription)
}
}
class func sendEmail(supportFilter: String, logUrl: URL?, hasRecentChallenge: Bool) async throws {
let model = SupportEmailModel(
userDescription: nil,
emojiMood: nil,
supportFilter: supportFilter,
debugLogPolicy: logUrl.map { .link($0) },
hasRecentChallenge: hasRecentChallenge,
)
try await sendEmail(model: model)
}
class func sendEmail(model: SupportEmailModel) async throws(EmailError) {
return try await ComposeSupportEmailOperation(model: model).perform()
}
init(model: SupportEmailModel) {
self.model = model
super.init()
}
func perform() async throws(EmailError) {
if Task.isCancelled {
return
}
guard Self.canSendEmails else {
// If we can't send emails, fail early
throw EmailError.failedToOpenURL
}
let debugUrlString: String?
switch model.debugLogPolicy {
case nil:
debugUrlString = nil
case .link(let url):
debugUrlString = url.absoluteString
case .attemptUpload(let dumper):
do {
debugUrlString = try await uploadDebugLogWithTimeout(dumper: dumper).absoluteString
} catch {
debugUrlString = "[Support note: Log upload failed — \(error.userErrorDescription)]"
}
case .requireUpload(let dumper):
do {
debugUrlString = try await uploadDebugLogWithTimeout(dumper: dumper).absoluteString
} catch {
throw EmailError.logUploadFailure(underlyingError: (error as? LocalizedError))
}
}
self.model.resolvedDebugString = debugUrlString
guard let emailURL else {
throw EmailError.invalidURL
}
if Task.isCancelled {
return
}
let result = await UIApplication.shared.open(emailURL)
guard result else {
throw EmailError.failedToOpenURL
}
}
private func uploadDebugLogWithTimeout(dumper: DebugLogDumper) async throws -> URL {
do {
return try await withCooperativeTimeout(seconds: 60) {
do throws(DebugLogs.UploadDebugLogError) {
return try await DebugLogs.uploadLogs(dumper: dumper)
} catch {
// FIXME: Should we do something with the local log file?
if let logArchiveOrDirectoryPath = error.logArchiveOrDirectoryPath {
_ = OWSFileSystem.deleteFile(logArchiveOrDirectoryPath)
}
throw DebugLogsUploadError(localizedDescription: error.localizedErrorMessage)
}
}
} catch is CooperativeTimeoutError {
throw EmailError.logUploadTimedOut
}
}
private var emailURL: URL? {
let linkBuilder = MailtoLink(
to: "[email protected]",
subject: model.localizedSubject,
body: emailBody,
)
return linkBuilder.url
}
private var emailBody: String {
// Items in this array will be separated by newlines
// Return nil to omit the item
let bodyComponents: [String?] = [
model.userDescription,
"",
OWSLocalizedString(
"SUPPORT_EMAIL_INFO_DIVIDER",
comment: "Localized divider for support request emails internal information",
),
String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"SUPPORT_EMAIL_FILTER_LABEL_FORMAT",
comment: "Localized label for support request email filter string. Embeds {{filter text}}.",
),
model.supportFilter,
),
String(
format: "Challenge Received: %@",
model.hasRecentChallenge ? "yes" : "no",
),
String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"SUPPORT_EMAIL_HARDWARE_LABEL_FORMAT",
comment: "Localized label for support request email hardware string (e.g. iPhone or iPad). Embeds {{hardware text}}.",
),
model.deviceType,
),
String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"SUPPORT_EMAIL_HID_LABEL_FORMAT",
comment: "Localized label for support request email HID string (e.g. 12,1). Embeds {{hid text}}.",
),
model.deviceIdentifier,
),
String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"SUPPORT_EMAIL_IOS_VERSION_LABEL_FORMAT",
comment: "Localized label for support request email iOS Version string (e.g. 13.4). Embeds {{ios version}}.",
),
model.iosVersion,
),
"Signal Version: \(model.signalAppVersion)",
{
if let debugURLString = model.resolvedDebugString {
return String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"SUPPORT_EMAIL_LOG_URL_LABEL_FORMAT",
comment: "Localized label for support request email debug log URL. Embeds {{debug log url}}.",
),
debugURLString,
)
} else { return nil }
}(),
String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"SUPPORT_EMAIL_LOCALE_LABEL_FORMAT",
comment: "Localized label for support request email locale string. Embeds {{locale}}.",
),
model.locale,
),
"",
model.emojiMood?.stringRepresentation,
model.emojiMood?.emojiRepresentation,
]
return bodyComponents
.compactMap { $0 }
.joined(separator: "\r\n")
}
}
struct DebugLogsUploadError: Error, LocalizedError, UserErrorDescriptionProvider {
let localizedDescription: String
var errorDescription: String? {
localizedDescription
}
}