// // Copyright 2022 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // import Combine import Foundation import GZIP import Sentry import UIKit class BugReportService: NSObject, BugReportServiceProtocol { private let baseURL: URL private let sentryURL: URL private let applicationId: String private let session: URLSession private var lastCrashEventId: String? private let progressSubject = PassthroughSubject() private var cancellables = Set() init(withBaseURL baseURL: URL, sentryURL: URL, applicationId: String = ServiceLocator.shared.settings.bugReportApplicationId, session: URLSession = .shared) { self.baseURL = baseURL self.sentryURL = sentryURL self.applicationId = applicationId self.session = session super.init() // enable SentrySDK SentrySDK.start { options in #if DEBUG options.enabled = false #endif options.dsn = sentryURL.absoluteString // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // We recommend adjusting this value in production. options.tracesSampleRate = 1.0 options.beforeSend = { event in MXLog.error("Sentry detected crash: \(event)") return event } options.onCrashedLastRun = { [weak self] event in MXLog.error("Sentry detected application was crashed: \(event)") self?.lastCrashEventId = event.eventId.sentryIdString } } // also enable logging crashes, to send them with bug reports MXLogger.logCrashes(true) // set build version for logger MXLogger.buildVersion = InfoPlistReader.main.bundleShortVersionString } // MARK: - BugReportServiceProtocol var crashedLastRun: Bool { SentrySDK.crashedLastRun } func crash() { SentrySDK.crash() } func submitBugReport(_ bugReport: BugReport, progressListener: ProgressListener?) async throws -> SubmitBugReportResponse { var params = [MultipartFormData(key: "text", type: .text(value: bugReport.text))] params.append(contentsOf: defaultParams) for label in bugReport.githubLabels { params.append(MultipartFormData(key: "label", type: .text(value: label))) } let zippedFiles = try await zipFiles(includeLogs: bugReport.includeLogs, includeCrashLog: bugReport.includeCrashLog) // log or compressed-log if !zippedFiles.isEmpty { for url in zippedFiles { params.append(MultipartFormData(key: "compressed-log", type: .file(url: url))) } } if let crashEventId = lastCrashEventId { params.append(MultipartFormData(key: "crash_report", type: .text(value: ""))) } for url in bugReport.files { params.append(MultipartFormData(key: "file", type: .file(url: url))) } let boundary = "Boundary-\(UUID().uuidString)" var body = Data() for param in params { try body.appendParam(param, boundary: boundary) } body.appendString(string: "--\(boundary)--\r\n") var request = URLRequest(url: baseURL.appendingPathComponent("submit")) request.addValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" request.httpBody = body as Data let data: Data if let progressListener { progressSubject .receive(on: DispatchQueue.main) .weakAssign(to: \.value, on: progressListener.progressSubject) .store(in: &cancellables) (data, _) = try await session.data(for: request, delegate: self) } else { (data, _) = try await session.data(for: request) } // Parse the JSON data let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase let result = try decoder.decode(SubmitBugReportResponse.self, from: data) if !result.reportUrl.isEmpty { MXLogger.deleteCrashLog() lastCrashEventId = nil } MXLog.info("Feedback submitted.") return result } // MARK: - Private private var defaultParams: [MultipartFormData] { let (localTime, utcTime) = localAndUTCTime(for: Date()) return [ MultipartFormData(key: "user_agent", type: .text(value: "iOS")), MultipartFormData(key: "app", type: .text(value: applicationId)), MultipartFormData(key: "version", type: .text(value: InfoPlistReader.main.bundleShortVersionString)), MultipartFormData(key: "build", type: .text(value: InfoPlistReader.main.bundleVersion)), MultipartFormData(key: "os", type: .text(value: os)), MultipartFormData(key: "resolved_language", type: .text(value: Bundle.preferredLanguages[0])), MultipartFormData(key: "user_language", type: .text(value: Bundle.elementLanguage ?? "null")), MultipartFormData(key: "fallback_language", type: .text(value: Bundle.elementFallbackLanguage ?? "null")), MultipartFormData(key: "local_time", type: .text(value: localTime)), MultipartFormData(key: "utc_time", type: .text(value: utcTime)) ] } private func localAndUTCTime(for date: Date) -> (String, String) { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" let localTime = dateFormatter.string(from: date) dateFormatter.timeZone = TimeZone(identifier: "UTC") let utcTime = dateFormatter.string(from: date) return (localTime, utcTime) } private var os: String { "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" } private func zipFiles(includeLogs: Bool, includeCrashLog: Bool) async throws -> [URL] { MXLog.info("zipFiles: includeLogs: \(includeLogs), includeCrashLog: \(includeCrashLog)") var filesToCompress: [URL] = [] if includeLogs { filesToCompress.append(contentsOf: MXLogger.logFiles) } if includeCrashLog, let crashLogFile = MXLogger.crashLog { filesToCompress.append(crashLogFile) } var totalSize = 0 var totalZippedSize = 0 var zippedFiles: [URL] = [] for url in filesToCompress { let zippedFileURL = URL.temporaryDirectory .appendingPathComponent(url.lastPathComponent) // remove old zipped file if exists try? FileManager.default.removeItem(at: zippedFileURL) let rawData = try Data(contentsOf: url) if rawData.isEmpty { continue } guard let zippedData = (rawData as NSData).gzipped() else { continue } totalSize += rawData.count totalZippedSize += zippedData.count try zippedData.write(to: zippedFileURL) zippedFiles.append(zippedFileURL) } MXLog.info("zipFiles: totalSize: \(totalSize), totalZippedSize: \(totalZippedSize)") return zippedFiles } } private extension Data { mutating func appendString(string: String, encoding: String.Encoding = .utf8) { if let data = string.data(using: encoding) { append(data) } } mutating func appendParam(_ param: MultipartFormData, boundary: String) throws { appendString(string: "--\(boundary)\r\n") appendString(string: "Content-Disposition:form-data; name=\"\(param.key)\"") switch param.type { case .text(let value): appendString(string: "\r\n\r\n\(value)\r\n") case .file(let url): appendString(string: "; filename=\"\(url.lastPathComponent)\"\r\n") appendString(string: "Content-Type: \"content-type header\"\r\n\r\n") append(try Data(contentsOf: url)) appendString(string: "\r\n") } } } private struct MultipartFormData { let key: String let type: MultipartFormDataType } private enum MultipartFormDataType { case text(value: String) case file(url: URL) } extension BugReportService: URLSessionTaskDelegate { func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { task.progress.publisher(for: \.fractionCompleted) .sink { [weak self] value in self?.progressSubject.send(value) } .store(in: &cancellables) } }