Signal-iOS/Signal/src/ViewControllers/AppSettings/ComposeSupportEmailOperation.swift
Michelle Linington 0909abd0c1 Implementing design feedback
- Updated some error strings
- Adjusted log upload timeout interval
- Color adjustments, improvements to theme update handling
- Improvements to content offset focusing
- Better handling of dynamic type in cell rows
- Fixed keyboard behavior on iPad form sheet presentation
2020-07-30 13:02:00 -07:00

184 lines
6.6 KiB
Swift

//
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
//
import SignalServiceKit
import MessageUI
import PromiseKit
struct SupportEmailModel {
enum LogPolicy {
/// Do not upload logs
case none
/// 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
/// Upload the logs. If they fail to upload, fail the operation
case requireUpload
/// Don't upload new logs, instead use the provided link
case link(URL)
}
/// Should be the unlocalized English string localizedSubject
var subject: String = "Signal iOS Support Request"
var localizedSubject: String = NSLocalizedString("SUPPORT_EMAIL_SUBJECT",
comment: "Localized subject for support request emails")
var device: String = AppVersion.hardwareInfoString
var osBuild: String = AppVersion.iOSVersionString
var signalBuild: String = AppVersion.sharedInstance().currentAppVersion
var locale: String = NSLocale.current.identifier
var userDescription: String?
var emojiMood: EmojiMoodPickerView.Mood?
var debugLogPolicy: LogPolicy = .none
fileprivate var resolvedDebugString: String?
}
class ComposeSupportEmailOperation {
enum EmailError: LocalizedError {
case logUploadTimedOut
case logUploadFailure(underlyingError: LocalizedError?)
case invalidURL
case failedToOpenURL
public var errorDescription: String? {
switch self {
case .logUploadTimedOut:
return NSLocalizedString("ERROR_DESCRIPTION_REQUEST_TIMED_OUT",
comment: "Error indicating that a socket request timed out.")
case let .logUploadFailure(underlyingError):
return underlyingError?.errorDescription ??
NSLocalizedString("ERROR_DESCRIPTION_LOG_UPLOAD_FAILED",
comment: "Generic error indicating that log upload failed")
case .invalidURL:
return NSLocalizedString("ERROR_DESCRIPTION_INVALID_SUPPORT_EMAIL",
comment: "Error indicating that a support mailto link could not be created.")
case .failedToOpenURL:
return NSLocalizedString("ERROR_DESCRIPTION_COULD_NOT_LAUNCH_EMAIL",
comment: "Error indicating that openURL for a mailto: URL failed.")
}
}
}
static var canSendEmails: Bool {
return MFMailComposeViewController.canSendMail()
}
private var model: SupportEmailModel
private var isCancelled: Bool = false
init(model: SupportEmailModel) {
self.model = model
}
func perform(on workQueue: DispatchQueue = .sharedUtility) -> Promise<Void> {
guard !isCancelled else {
// If we're cancelled, return an empty success
return Promise()
}
guard Self.canSendEmails else {
// If we can't send emails, fail early
return Promise(error: EmailError.failedToOpenURL)
}
return firstly { () -> Promise<String?> in
// Returns an appropriate string for the debug logs
// If we're not uploading, returns nil
switch model.debugLogPolicy {
case .none:
return .value(nil)
case let .link(url):
return .value(url.absoluteString)
case .attemptUpload, .requireUpload:
return Pastelog.uploadLog().map { $0.absoluteString }
}
}.timeout(seconds: 60, timeoutErrorBlock: { () -> Error in
// If we haven't finished uploading logs in 10s, give up
return EmailError.logUploadTimedOut
})
.recover(on: workQueue) { error -> Promise<String?> in
// Suppress the error unless we're required to provide logs
if case .requireUpload = self.model.debugLogPolicy {
let emailError = EmailError.logUploadFailure(underlyingError: (error as? LocalizedError))
return Promise(error: emailError)
} else {
return .value("[Support note: Log upload failed — \(error.localizedDescription)]")
}
}.then(on: workQueue) { (debugURLString) -> Promise<URL> in
self.model.resolvedDebugString = debugURLString
if let url = self.emailURL {
return .value(url)
} else {
return Promise(error: EmailError.invalidURL)
}
}.then(on: .main) { (emailURL: URL) -> Promise<Void> in
(self.isCancelled == false) ? self.open(mailURL: emailURL) : Promise()
}
}
/// If invoked before the operation completes, will prevent the operation from opening email
/// Must be called from main queue. Note: This doesn't really *stop* the operation so much as
/// render it invisible to the user.
func cancel() {
AssertIsOnMainThread()
isCancelled = true
}
private func open(mailURL url: URL) -> Promise<Void> {
Promise { (resolver) in
UIApplication.shared.open(url, options: [:]) { (success) in
if success {
resolver.fulfill_()
} else {
resolver.reject(EmailError.failedToOpenURL)
}
}
}
}
private var emailURL: URL? {
let linkBuilder = MailtoLink(to: SupportConstants.supportEmail,
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,
"",
"--- Support Info ---",
"Subject: \(model.subject)",
"Device info: \(model.device)",
"iOS version: \(model.osBuild)",
"Signal version: \(model.signalBuild)",
"Locale: \(model.locale)",
{
if let debugURLString = model.resolvedDebugString {
return "Debug log: \(debugURLString)"
} else { return nil }
}(),
"",
model.emojiMood?.stringRepresentation,
model.emojiMood?.emojiRepresentation
]
return bodyComponents
.compactMap { $0 }
.joined(separator: "\n")
}
}