* created a progress tracker class and passed it in the user notification to be observed by the progress view * improved the publishing by dispatching it on RunLoop.main * bug report struct created and progress tracker class moved into the Other folder * some swiftlint adjustments * fixed tests * fixed another test BugReportServiceTests * changelog 495 - change * added a mock preview * fixing some linting suggestions * no need to use KVO, achieve the same result using a publisher * some refactors to address PR comments * some code improvements * fixed the issue that prevented the avatar of the room to be displayed in the mocks, and updated the tests * Revert "fixed the issue that prevented the avatar of the room to be displayed in the mocks, and updated the tests" This reverts commit 113d6091d91a3aac1f9a59ff6c5e07610ed59859.
261 lines
9.3 KiB
Swift
261 lines
9.3 KiB
Swift
//
|
|
// 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<Double, Never>()
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
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: "<https://sentry.tools.element.io/organizations/element/issues/?project=44&query=\(crashEventId)>")))
|
|
}
|
|
|
|
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)
|
|
.assign(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(fileURLWithPath: NSTemporaryDirectory())
|
|
.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)
|
|
}
|
|
}
|