Rageshake Service (#70)
This commit is contained in:
@@ -25,7 +25,10 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
|
||||
private var userSession: UserSession!
|
||||
|
||||
private let memberDetailProviderManager: MemberDetailProviderManager
|
||||
|
||||
|
||||
private let bugReportService: BugReportServiceProtocol
|
||||
private let screenshotDetector: ScreenshotDetector
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
private var errorIndicator: UserIndicator?
|
||||
@@ -34,7 +37,14 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
|
||||
|
||||
init() {
|
||||
stateMachine = AppCoordinatorStateMachine()
|
||||
|
||||
|
||||
do {
|
||||
bugReportService = try BugReportService(withBaseUrlString: BuildSettings.bugReportServiceBaseUrlString,
|
||||
sentryEndpoint: BuildSettings.bugReportSentryEndpoint)
|
||||
} catch {
|
||||
fatalError(error.localizedDescription)
|
||||
}
|
||||
|
||||
splashViewController = SplashViewController()
|
||||
mainNavigationController = UINavigationController(rootViewController: splashViewController)
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
@@ -53,12 +63,20 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
|
||||
keychainController = KeychainController(identifier: bundleIdentifier)
|
||||
authenticationCoordinator = AuthenticationCoordinator(keychainController: keychainController,
|
||||
navigationRouter: navigationRouter)
|
||||
|
||||
screenshotDetector = ScreenshotDetector()
|
||||
screenshotDetector.callback = processScreenshotDetection
|
||||
|
||||
authenticationCoordinator.delegate = self
|
||||
|
||||
setupStateMachine()
|
||||
|
||||
let loggerConfiguration = MXLogConfiguration()
|
||||
loggerConfiguration.logLevel = .verbose
|
||||
// Redirect NSLogs to files only if we are not debugging
|
||||
if isatty(STDERR_FILENO) == 0 {
|
||||
loggerConfiguration.redirectLogsToFiles = true
|
||||
}
|
||||
MXLog.configure(loggerConfiguration)
|
||||
|
||||
// Benchmark.trackingEnabled = true
|
||||
@@ -117,6 +135,10 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
|
||||
self.tearDownUserSession()
|
||||
case (.signingOut, .failedSigningOut, _):
|
||||
self.showLogoutErrorToast()
|
||||
case (.homeScreen, .showSettingsScreen, .settingsScreen):
|
||||
self.presentSettingsScreen()
|
||||
case (.settingsScreen, .dismissedSettingsScreen, .homeScreen):
|
||||
self.tearDownDismissedSettingsScreen()
|
||||
default:
|
||||
fatalError("Unknown transition: \(context)")
|
||||
}
|
||||
@@ -162,13 +184,33 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
|
||||
switch action {
|
||||
case .logout:
|
||||
self.stateMachine.processEvent(.attemptSignOut)
|
||||
case .selectRoom(let roomIdentifier):
|
||||
case .presentRoom(let roomIdentifier):
|
||||
self.stateMachine.processEvent(.showRoomScreen(roomId: roomIdentifier))
|
||||
case .presentSettings:
|
||||
self.stateMachine.processEvent(.showSettingsScreen)
|
||||
}
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
navigationRouter.setRootModule(coordinator)
|
||||
|
||||
if bugReportService.crashedLastRun {
|
||||
showCrashPopup()
|
||||
}
|
||||
}
|
||||
|
||||
private func presentSettingsScreen() {
|
||||
let parameters = SettingsCoordinatorParameters(navigationRouter: navigationRouter,
|
||||
bugReportService: bugReportService)
|
||||
let coordinator = SettingsCoordinator(parameters: parameters)
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
navigationRouter.push(coordinator) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.stateMachine.processEvent(.dismissedSettingsScreen)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentRoomWithIdentifier(_ roomIdentifier: String) {
|
||||
@@ -206,6 +248,14 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
|
||||
|
||||
remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
private func tearDownDismissedSettingsScreen() {
|
||||
guard let coordinator = childCoordinators.last as? SettingsCoordinator else {
|
||||
fatalError("Invalid coordinator hierarchy: \(childCoordinators)")
|
||||
}
|
||||
|
||||
remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
private func showLoadingIndicator() {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: "Loading", isInteractionBlocking: true))
|
||||
@@ -216,10 +266,70 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
|
||||
}
|
||||
|
||||
private func showLoginErrorToast() {
|
||||
errorIndicator = indicatorPresenter.present(.success(label: "Failed logging in"))
|
||||
errorIndicator = indicatorPresenter.present(.error(label: "Failed logging in"))
|
||||
}
|
||||
|
||||
private func showLogoutErrorToast() {
|
||||
errorIndicator = indicatorPresenter.present(.success(label: "Failed logging out"))
|
||||
}
|
||||
|
||||
private func showCrashPopup() {
|
||||
let alert = UIAlertController(title: nil,
|
||||
message: ElementL10n.sendBugReportAppCrashed,
|
||||
preferredStyle: .alert)
|
||||
|
||||
alert.addAction(UIAlertAction(title: ElementL10n.no, style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: ElementL10n.yes, style: .default) { [weak self] _ in
|
||||
self?.presentBugReportScreen()
|
||||
})
|
||||
|
||||
navigationRouter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func processScreenshotDetection(image: UIImage?, error: Error?) {
|
||||
MXLog.debug("[AppCoordinator] processScreenshotDetection: \(String(describing: image)), error: \(String(describing: error))")
|
||||
|
||||
let alert = UIAlertController(title: ElementL10n.screenshotDetectedTitle,
|
||||
message: ElementL10n.screenshotDetectedMessage,
|
||||
preferredStyle: .alert)
|
||||
|
||||
alert.addAction(UIAlertAction(title: ElementL10n.no, style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: ElementL10n.yes, style: .default) { [weak self] _ in
|
||||
self?.presentBugReportScreen(for: image)
|
||||
})
|
||||
|
||||
navigationRouter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentBugReportScreen(for image: UIImage? = nil) {
|
||||
let parameters = BugReportCoordinatorParameters(bugReportService: bugReportService,
|
||||
screenshot: image)
|
||||
let coordinator = BugReportCoordinator(parameters: parameters)
|
||||
coordinator.completion = { [weak self, weak coordinator] in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.navigationRouter.dismissModule(animated: true)
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
let navController = UINavigationController(rootViewController: coordinator.toPresentable())
|
||||
navController.navigationBar.topItem?.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel,
|
||||
target: self,
|
||||
action: #selector(dismissBugReportScreen))
|
||||
navController.isModalInPresentation = true
|
||||
navigationRouter.present(navController, animated: true)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func dismissBugReportScreen() {
|
||||
MXLog.debug("[AppCoorrdinator] dismissBugReportScreen")
|
||||
|
||||
guard let bugReportCoordinator = childCoordinators.first(where: { $0 is BugReportCoordinator }) else {
|
||||
return
|
||||
}
|
||||
|
||||
navigationRouter.dismissModule()
|
||||
remove(childCoordinator: bugReportCoordinator)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ class AppCoordinatorStateMachine {
|
||||
case signedIn
|
||||
/// Showing the home screen
|
||||
case homeScreen
|
||||
/// Showing the settings screen
|
||||
case settingsScreen
|
||||
/// Showing a particular room's timeline
|
||||
/// - Parameter roomId: that room's identifier
|
||||
case roomScreen(roomId: String)
|
||||
@@ -52,6 +54,10 @@ class AppCoordinatorStateMachine {
|
||||
case showRoomScreen(roomId: String)
|
||||
/// The room screen has been dismissed
|
||||
case dismissedRoomScreen
|
||||
/// The settings screen has been dismissed
|
||||
case dismissedSettingsScreen
|
||||
/// Request settings screen presentation
|
||||
case showSettingsScreen
|
||||
}
|
||||
|
||||
private let stateMachine: StateMachine<State, Event>
|
||||
@@ -71,6 +77,8 @@ class AppCoordinatorStateMachine {
|
||||
|
||||
machine.addRoutes(event: .succeededSigningOut, transitions: [ .signingOut => .signedOut ])
|
||||
machine.addRoutes(event: .failedSigningOut, transitions: [ .signingOut => .homeScreen ])
|
||||
machine.addRoutes(event: .showSettingsScreen, transitions: [ .homeScreen => .settingsScreen ])
|
||||
machine.addRoutes(event: .dismissedSettingsScreen, transitions: [ .settingsScreen => .homeScreen ])
|
||||
|
||||
// Transitions with associated values need to be handled through `addRouteMapping`
|
||||
machine.addRouteMapping { event, fromState, _ in
|
||||
|
||||
20
ElementX/Sources/BuildSettings.swift
Normal file
20
ElementX/Sources/BuildSettings.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// BuildSettings.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Ismail on 2.06.2022.
|
||||
// Copyright © 2022 Element. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class BuildSettings {
|
||||
|
||||
// MARK: - Bug report
|
||||
static let bugReportServiceBaseUrlString = "https://riot.im/bugreports"
|
||||
static let bugReportSentryEndpoint = "https://f39ac49e97714316965b777d9f3d6cd8@sentry.tools.element.io/44"
|
||||
// Use the name allocated by the bug report server
|
||||
static let bugReportApplicationId = "riot-ios"
|
||||
static let bugReportUISIId = "element-auto-uisi"
|
||||
|
||||
}
|
||||
@@ -27,6 +27,7 @@ internal enum Asset {
|
||||
}
|
||||
internal enum Images {
|
||||
internal static let appLogo = ImageAsset(name: "Images/app-logo")
|
||||
internal static let closeCircle = ImageAsset(name: "Images/close_circle")
|
||||
internal static let timelineComposerSendMessage = ImageAsset(name: "Images/timelineComposerSendMessage")
|
||||
internal static let timelineScrollToBottom = ImageAsset(name: "Images/timelineScrollToBottom")
|
||||
}
|
||||
|
||||
67
ElementX/Sources/Generated/InfoPlist.swift
Normal file
67
ElementX/Sources/Generated/InfoPlist.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
// swiftlint:disable all
|
||||
// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen
|
||||
|
||||
import Foundation
|
||||
|
||||
// swiftlint:disable superfluous_disable_command
|
||||
// swiftlint:disable file_length
|
||||
|
||||
// MARK: - Plist Files
|
||||
|
||||
// swiftlint:disable identifier_name line_length type_body_length
|
||||
internal enum ElementInfoPlist {
|
||||
private static let _document = PlistDocument(path: "Info.plist")
|
||||
|
||||
internal static let cfBundleDevelopmentRegion: String = _document["CFBundleDevelopmentRegion"]
|
||||
internal static let cfBundleExecutable: String = _document["CFBundleExecutable"]
|
||||
internal static let cfBundleIdentifier: String = _document["CFBundleIdentifier"]
|
||||
internal static let cfBundleInfoDictionaryVersion: String = _document["CFBundleInfoDictionaryVersion"]
|
||||
internal static let cfBundleName: String = _document["CFBundleName"]
|
||||
internal static let cfBundlePackageType: String = _document["CFBundlePackageType"]
|
||||
internal static let cfBundleShortVersionString: String = _document["CFBundleShortVersionString"]
|
||||
internal static let cfBundleVersion: String = _document["CFBundleVersion"]
|
||||
internal static let uiLaunchStoryboardName: String = _document["UILaunchStoryboardName"]
|
||||
internal static let uiSupportedInterfaceOrientations: [String] = _document["UISupportedInterfaceOrientations"]
|
||||
}
|
||||
// swiftlint:enable identifier_name line_length type_body_length
|
||||
|
||||
// MARK: - Implementation Details
|
||||
|
||||
private func arrayFromPlist<T>(at path: String) -> [T] {
|
||||
guard let url = BundleToken.bundle.url(forResource: path, withExtension: nil),
|
||||
let data = NSArray(contentsOf: url) as? [T] else {
|
||||
fatalError("Unable to load PLIST at path: \(path)")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private struct PlistDocument {
|
||||
let data: [String: Any]
|
||||
|
||||
init(path: String) {
|
||||
guard let url = BundleToken.bundle.url(forResource: path, withExtension: nil),
|
||||
let data = NSDictionary(contentsOf: url) as? [String: Any] else {
|
||||
fatalError("Unable to load PLIST at path: \(path)")
|
||||
}
|
||||
self.data = data
|
||||
}
|
||||
|
||||
subscript<T>(key: String) -> T {
|
||||
guard let result = data[key] as? T else {
|
||||
fatalError("Property '\(key)' is not of type \(T.self)")
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable convenience_type
|
||||
private final class BundleToken {
|
||||
static let bundle: Bundle = {
|
||||
#if SWIFT_PACKAGE
|
||||
return Bundle.module
|
||||
#else
|
||||
return Bundle(for: BundleToken.self)
|
||||
#endif
|
||||
}()
|
||||
}
|
||||
// swiftlint:enable convenience_type
|
||||
@@ -10,6 +10,10 @@ import Foundation
|
||||
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
extension ElementL10n {
|
||||
/// Would you like to submit a bug report?
|
||||
public static let screenshotDetectedMessage = ElementL10n.tr("Untranslated", "screenshot_detected_message")
|
||||
/// You took a screenshot
|
||||
public static let screenshotDetectedTitle = ElementL10n.tr("Untranslated", "screenshot_detected_title")
|
||||
/// Untranslated
|
||||
public static let untranslated = ElementL10n.tr("Untranslated", "untranslated")
|
||||
/// Plural format key: "%#@VARIABLE@"
|
||||
|
||||
111
ElementX/Sources/Other/ImageAnonymizer.swift
Normal file
111
ElementX/Sources/Other/ImageAnonymizer.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// UIImage+.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Ismail on 20.05.2022.
|
||||
// Copyright © 2022 element.io. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Vision
|
||||
import UIKit
|
||||
|
||||
enum ImageAnonymizerError: Error {
|
||||
case noCgImageBased
|
||||
}
|
||||
|
||||
struct ImageAnonymizer {
|
||||
|
||||
private static var allowedTextItems: [String] = [
|
||||
"#",
|
||||
"@",
|
||||
"%",
|
||||
"&",
|
||||
"+",
|
||||
"-",
|
||||
"_",
|
||||
"\"",
|
||||
"?",
|
||||
"*"
|
||||
]
|
||||
|
||||
static func anonymizedImage(from image: UIImage,
|
||||
confidenceLevel: Float = 0.5,
|
||||
fillColor: UIColor = .red) async throws -> UIImage {
|
||||
guard let cgImage = image.cgImage else {
|
||||
throw ImageAnonymizerError.noCgImageBased
|
||||
}
|
||||
|
||||
// create a handler with cgImage
|
||||
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
||||
var observations: [VNDetectedObjectObservation] = []
|
||||
|
||||
// create a text request
|
||||
let textRequest = VNRecognizeTextRequest { request, error in
|
||||
guard let results = request.results as? [VNRecognizedTextObservation],
|
||||
error == nil else {
|
||||
return
|
||||
}
|
||||
observations.append(contentsOf: results)
|
||||
}
|
||||
textRequest.recognitionLevel = .accurate
|
||||
textRequest.revision = VNRecognizeTextRequestRevision2
|
||||
|
||||
// create a face request
|
||||
let faceRequest = VNDetectFaceRectanglesRequest { request, error in
|
||||
guard let results = request.results as? [VNFaceObservation],
|
||||
error == nil else {
|
||||
return
|
||||
}
|
||||
observations.append(contentsOf: results)
|
||||
}
|
||||
// revision3 doesn't work!
|
||||
faceRequest.revision = VNDetectFaceRectanglesRequestRevision2
|
||||
|
||||
// perform requests
|
||||
try handler.perform([
|
||||
textRequest,
|
||||
faceRequest
|
||||
])
|
||||
|
||||
return render(image: image,
|
||||
confidenceLevel: confidenceLevel,
|
||||
fillColor: fillColor,
|
||||
observations: observations)
|
||||
}
|
||||
|
||||
private static func render(image: UIImage,
|
||||
confidenceLevel: Float,
|
||||
fillColor: UIColor,
|
||||
observations: [VNDetectedObjectObservation]) -> UIImage {
|
||||
let size = image.size
|
||||
let result = UIGraphicsImageRenderer(size: size).image { rendererContext in
|
||||
// first draw self
|
||||
image.draw(in: CGRect(origin: .zero, size: size))
|
||||
// set fill color
|
||||
fillColor.setFill()
|
||||
for observation in observations {
|
||||
guard observation.confidence >= confidenceLevel else {
|
||||
// ensure observation's confidence level
|
||||
continue
|
||||
}
|
||||
if let textObservation = observation as? VNRecognizedTextObservation,
|
||||
let text = textObservation.topCandidates(1).first?.string {
|
||||
if Double(text) != nil || Self.allowedTextItems.contains(text) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
let box = observation.boundingBox
|
||||
// boc is normalized (and in starts from the lower left corner)
|
||||
// convert it to a rect in the image
|
||||
let rect = CGRect(x: box.minX * size.width,
|
||||
y: size.height - box.maxY * size.height,
|
||||
width: box.width * size.width,
|
||||
height: box.height * size.height)
|
||||
rendererContext.fill(rect)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -136,14 +136,14 @@ private var logger: SwiftyBeaver.Type = {
|
||||
// MARK: - Private
|
||||
|
||||
fileprivate static func configureLogger(_ logger: SwiftyBeaver.Type, withConfiguration configuration: MXLogConfiguration) {
|
||||
// if let subLogName = configuration.subLogName {
|
||||
// MXLogger.setSubLogName(subLogName)
|
||||
// }
|
||||
//
|
||||
// MXLogger.redirectNSLog(toFiles: configuration.redirectLogsToFiles,
|
||||
// numberOfFiles: configuration.maxLogFilesCount,
|
||||
// sizeLimit: configuration.logFilesSizeLimit)
|
||||
//
|
||||
if let subLogName = configuration.subLogName {
|
||||
MXLogger.setSubLogName(subLogName)
|
||||
}
|
||||
|
||||
MXLogger.redirectNSLog(toFiles: configuration.redirectLogsToFiles,
|
||||
numberOfFiles: configuration.maxLogFilesCount,
|
||||
sizeLimit: configuration.logFilesSizeLimit)
|
||||
|
||||
guard configuration.logLevel != .none else {
|
||||
return
|
||||
}
|
||||
114
ElementX/Sources/Other/Logging/MXLogger.h
Normal file
114
ElementX/Sources/Other/Logging/MXLogger.h
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
Copyright 2015 OpenMarket Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C
|
||||
|
||||
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 <Foundation/Foundation.h>
|
||||
|
||||
/**
|
||||
The `MXLogger` tool redirects NSLog output into a fixed pool of files.
|
||||
Another log file is used every time [MXLogger redirectNSLogToFiles:YES]
|
||||
is called. The pool contains 3 files.
|
||||
|
||||
`MXLogger` can track and log uncatched exceptions or crashes.
|
||||
*/
|
||||
@interface MXLogger : NSObject
|
||||
|
||||
/**
|
||||
Redirect NSLog output to MXLogger files.
|
||||
|
||||
It is advised to condition this redirection in '#if (!isatty(STDERR_FILENO))' block to enable
|
||||
it only when the device is not attached to the debugger.
|
||||
|
||||
@param redirectNSLogToFiles YES to enable the redirection.
|
||||
*/
|
||||
+ (void)redirectNSLogToFiles:(BOOL)redirectNSLogToFiles;
|
||||
|
||||
/**
|
||||
Redirect NSLog output to MXLogger files.
|
||||
|
||||
It is advised to condition this redirection in '#if (!isatty(STDERR_FILENO))' block to enable
|
||||
it only when the device is not attached to the debugger.
|
||||
|
||||
@param redirectNSLogToFiles YES to enable the redirection.
|
||||
@param numberOfFiles number of files to keep (default is 10).
|
||||
*/
|
||||
+ (void)redirectNSLogToFiles:(BOOL)redirectNSLogToFiles numberOfFiles:(NSUInteger)numberOfFiles;
|
||||
|
||||
/**
|
||||
Redirect NSLog output to MXLogger files.
|
||||
|
||||
@param redirectNSLogToFiles YES to enable the redirection.
|
||||
@param numberOfFiles number of files to keep (default is 10).
|
||||
@param sizeLimit size limit of log files in bytes. 0 means no limitation, the default value for other methods
|
||||
*/
|
||||
+ (void)redirectNSLogToFiles:(BOOL)redirectNSLogToFiles numberOfFiles:(NSUInteger)numberOfFiles sizeLimit:(NSUInteger)sizeLimit;
|
||||
|
||||
/**
|
||||
Delete all log files.
|
||||
*/
|
||||
+ (void)deleteLogFiles;
|
||||
|
||||
/**
|
||||
Get the list of all log files.
|
||||
|
||||
@return files of
|
||||
*/
|
||||
+ (NSArray<NSString*>*)logFiles;
|
||||
|
||||
/**
|
||||
Make `MXLogger` catch and log unmanaged exceptions or application crashes.
|
||||
|
||||
When such error happens, `MXLogger` stores the application stack trace into a file
|
||||
just before the application leaves. The path of this file is provided by [MXLogger crashLog].
|
||||
|
||||
@param logCrashes YES to enable the catch.
|
||||
*/
|
||||
+ (void)logCrashes:(BOOL)logCrashes;
|
||||
|
||||
/**
|
||||
Set the app build version.
|
||||
It will be reported in crash report.
|
||||
*/
|
||||
+ (void)setBuildVersion:(NSString*)buildVersion;
|
||||
|
||||
/**
|
||||
Set a sub name for namespacing log files.
|
||||
|
||||
A sub name must be set when running from an app extension because extensions can
|
||||
run in parallel to the app.
|
||||
It must be called before `redirectNSLogToFiles`.
|
||||
|
||||
@param subLogName the subname for log files. Files will be named as 'console-[subLogName].log'
|
||||
Default is nil.
|
||||
*/
|
||||
+ (void)setSubLogName:(NSString*)subLogName;
|
||||
|
||||
/**
|
||||
If any, get the file containing the last application crash log.
|
||||
|
||||
Only one crash log is stored at a time. The best moment for the app to handle it is the
|
||||
at its next startup.
|
||||
|
||||
@return the crash log file. nil if there is none.
|
||||
*/
|
||||
+ (NSString*)crashLog;
|
||||
|
||||
/**
|
||||
Delete the crash log file.
|
||||
*/
|
||||
+ (void)deleteCrashLog;
|
||||
|
||||
@end
|
||||
392
ElementX/Sources/Other/Logging/MXLogger.m
Normal file
392
ElementX/Sources/Other/Logging/MXLogger.m
Normal file
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
Copyright 2015 OpenMarket Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C
|
||||
|
||||
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 "MXLogger.h"
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
// stderr so it can be restored
|
||||
int stderrSave = 0;
|
||||
|
||||
static NSString *buildVersion;
|
||||
static NSString *subLogName;
|
||||
|
||||
#define MXLOGGER_CRASH_LOG @"crash.log"
|
||||
|
||||
@implementation MXLogger
|
||||
|
||||
#pragma mark - NSLog redirection
|
||||
+ (void)redirectNSLogToFiles:(BOOL)redirectNSLogToFiles
|
||||
{
|
||||
[self redirectNSLogToFiles:redirectNSLogToFiles numberOfFiles:10];
|
||||
}
|
||||
|
||||
+ (void)redirectNSLogToFiles:(BOOL)redirectNSLogToFiles numberOfFiles:(NSUInteger)numberOfFiles
|
||||
{
|
||||
[self redirectNSLogToFiles:redirectNSLogToFiles numberOfFiles:numberOfFiles sizeLimit:0];
|
||||
}
|
||||
|
||||
+ (void)redirectNSLogToFiles:(BOOL)redirectNSLogToFiles numberOfFiles:(NSUInteger)numberOfFiles sizeLimit:(NSUInteger)sizeLimit
|
||||
{
|
||||
if (redirectNSLogToFiles)
|
||||
{
|
||||
NSMutableString *log = [NSMutableString string];
|
||||
|
||||
// Default subname
|
||||
if (!subLogName)
|
||||
{
|
||||
subLogName = @"";
|
||||
}
|
||||
|
||||
// Set log location
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
NSString *logsFolderPath = [MXLogger logsFolderPath];
|
||||
|
||||
// Do a circular buffer based on X files
|
||||
for (NSInteger index = numberOfFiles - 2; index >= 0; index--)
|
||||
{
|
||||
NSString *nsLogPathOlder;
|
||||
NSString *nsLogPathCurrent;
|
||||
|
||||
if (index == 0)
|
||||
{
|
||||
nsLogPathOlder = [NSString stringWithFormat:@"console%@.1.log", subLogName];
|
||||
nsLogPathCurrent = [NSString stringWithFormat:@"console%@.log", subLogName];
|
||||
}
|
||||
else
|
||||
{
|
||||
nsLogPathOlder = [NSString stringWithFormat:@"console%@.%tu.log", subLogName, index + 1];
|
||||
nsLogPathCurrent = [NSString stringWithFormat:@"console%@.%tu.log", subLogName, index];
|
||||
}
|
||||
|
||||
nsLogPathOlder = [logsFolderPath stringByAppendingPathComponent:nsLogPathOlder];
|
||||
nsLogPathCurrent = [logsFolderPath stringByAppendingPathComponent:nsLogPathCurrent];
|
||||
|
||||
if ([fileManager fileExistsAtPath:nsLogPathCurrent])
|
||||
{
|
||||
if ([fileManager fileExistsAtPath:nsLogPathOlder])
|
||||
{
|
||||
// Temp log
|
||||
[log appendFormat:@"[MXLogger] redirectNSLogToFiles: removeItemAtPath: %@\n", nsLogPathOlder];
|
||||
|
||||
NSError *error;
|
||||
[fileManager removeItemAtPath:nsLogPathOlder error:&error];
|
||||
if (error)
|
||||
{
|
||||
[log appendFormat:@"[MXLogger] ERROR: removeItemAtPath: %@. Error: %@\n", nsLogPathOlder, error];
|
||||
}
|
||||
}
|
||||
|
||||
// Temp log
|
||||
[log appendFormat:@"[MXLogger] redirectNSLogToFiles: moveItemAtPath: %@ toPath: %@\n", nsLogPathCurrent, nsLogPathOlder];
|
||||
|
||||
NSError *error;
|
||||
[fileManager moveItemAtPath:nsLogPathCurrent toPath:nsLogPathOlder error:&error];
|
||||
if (error)
|
||||
{
|
||||
[log appendFormat:@"[MXLogger] ERROR: moveItemAtPath: %@ toPath: %@. Error: %@\n", nsLogPathCurrent, nsLogPathOlder, error];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save stderr so it can be restored.
|
||||
stderrSave = dup(STDERR_FILENO);
|
||||
|
||||
NSString *nsLogPath = [logsFolderPath stringByAppendingPathComponent:[NSString stringWithFormat:@"console%@.log", subLogName]];
|
||||
freopen([nsLogPath fileSystemRepresentation], "w+", stderr);
|
||||
|
||||
// MXLogDebug(@"[MXLogger] redirectNSLogToFiles: YES");
|
||||
if (log.length)
|
||||
{
|
||||
// We can now log into files
|
||||
// MXLogDebug(@"%@", log);
|
||||
}
|
||||
|
||||
[self removeExtraFilesFromCount:numberOfFiles];
|
||||
|
||||
if (sizeLimit > 0)
|
||||
{
|
||||
[self removeFilesAfterSizeLimit:sizeLimit];
|
||||
}
|
||||
}
|
||||
else if (stderrSave)
|
||||
{
|
||||
// Flush before restoring stderr
|
||||
fflush(stderr);
|
||||
|
||||
// Now restore stderr, so new output goes to console.
|
||||
dup2(stderrSave, STDERR_FILENO);
|
||||
close(stderrSave);
|
||||
}
|
||||
}
|
||||
|
||||
+ (void)deleteLogFiles
|
||||
{
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
for (NSString *logFile in [self logFiles])
|
||||
{
|
||||
[fileManager removeItemAtPath:logFile error:nil];
|
||||
}
|
||||
}
|
||||
|
||||
+ (NSArray<NSString*>*)logFiles
|
||||
{
|
||||
NSMutableArray *logFiles = [NSMutableArray array];
|
||||
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
NSString *logsFolderPath = [MXLogger logsFolderPath];
|
||||
|
||||
NSDirectoryEnumerator *dirEnum = [fileManager enumeratorAtPath:logsFolderPath];
|
||||
|
||||
// Find all *.log files
|
||||
NSString *file = nil;
|
||||
while ((file = [dirEnum nextObject]))
|
||||
{
|
||||
if ([[file lastPathComponent] hasPrefix:@"console"])
|
||||
{
|
||||
NSString *logPath = [logsFolderPath stringByAppendingPathComponent:file];
|
||||
[logFiles addObject:logPath];
|
||||
}
|
||||
}
|
||||
|
||||
// MXLogDebug(@"[MXLogger] logFiles: %@", logFiles);
|
||||
|
||||
return logFiles;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Exceptions and crashes
|
||||
// Exceptions uncaught by try catch block are handled here
|
||||
static void handleUncaughtException(NSException *exception)
|
||||
{
|
||||
[MXLogger logCrashes:NO];
|
||||
|
||||
// Extract running app information
|
||||
NSDictionary* infoDict = [[NSBundle mainBundle] infoDictionary];
|
||||
NSString* appVersion;
|
||||
NSString* app, *appId;
|
||||
|
||||
app = infoDict[@"CFBundleExecutable"];
|
||||
appId = infoDict[@"CFBundleIdentifier"];
|
||||
|
||||
if ([infoDict objectForKey:@"CFBundleVersion"])
|
||||
{
|
||||
appVersion = [NSString stringWithFormat:@"%@ (r%@)", [infoDict objectForKey:@"CFBundleShortVersionString"], [infoDict objectForKey:@"CFBundleVersion"]];
|
||||
}
|
||||
else
|
||||
{
|
||||
appVersion = [infoDict objectForKey:@"CFBundleShortVersionString"];
|
||||
}
|
||||
|
||||
// Build the crash log
|
||||
#if TARGET_OS_IPHONE
|
||||
NSString *model = [[UIDevice currentDevice] model];
|
||||
NSString *version = [[UIDevice currentDevice] systemVersion];
|
||||
#elif TARGET_OS_OSX
|
||||
NSString *model = @"Mac";
|
||||
NSString *version = [[NSProcessInfo processInfo] operatingSystemVersionString];
|
||||
#endif
|
||||
NSArray *backtrace = [exception callStackSymbols];
|
||||
NSString *description = [NSString stringWithFormat:@"%.0f - %@\n%@\nApplication: %@ (%@)\nApplication version: %@\nMatrix SDK version: %@\nBuild: %@\n%@ %@\n\nMain thread: %@\n%@\n",
|
||||
[[NSDate date] timeIntervalSince1970],
|
||||
[NSDate date],
|
||||
exception.description,
|
||||
app, appId,
|
||||
appVersion,
|
||||
@"",
|
||||
buildVersion,
|
||||
model, version,
|
||||
[NSThread isMainThread] ? @"YES" : @"NO",
|
||||
backtrace];
|
||||
|
||||
// Write to the crash log file
|
||||
[MXLogger deleteCrashLog];
|
||||
NSString *crashLog = crashLogPath();
|
||||
[description writeToFile:crashLog
|
||||
atomically:NO
|
||||
encoding:NSStringEncodingConversionAllowLossy
|
||||
error:nil];
|
||||
|
||||
NSLog(@"[MXLogger] handleUncaughtException:\n%@", description);
|
||||
// MXLogError(@"[MXLogger] handleUncaughtException:\n%@", description);
|
||||
}
|
||||
|
||||
// Signals emitted by the app are handled here
|
||||
static void handleSignal(int signalValue)
|
||||
{
|
||||
// Throw a custom Objective-C exception
|
||||
// The Objective-C runtime will then be able to build a readable call stack in handleUncaughtException
|
||||
[NSException raise:@"Signal detected" format:@"Signal detected: %d", signalValue];
|
||||
}
|
||||
|
||||
+ (void)logCrashes:(BOOL)logCrashes
|
||||
{
|
||||
if (logCrashes)
|
||||
{
|
||||
// Handle not managed exceptions by ourselves
|
||||
NSSetUncaughtExceptionHandler(&handleUncaughtException);
|
||||
|
||||
// Register signal event (seg fault & cie)
|
||||
signal(SIGABRT, handleSignal);
|
||||
signal(SIGILL, handleSignal);
|
||||
signal(SIGSEGV, handleSignal);
|
||||
signal(SIGFPE, handleSignal);
|
||||
signal(SIGBUS, handleSignal);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Disable crash handling
|
||||
NSSetUncaughtExceptionHandler(NULL);
|
||||
signal(SIGABRT, SIG_DFL);
|
||||
signal(SIGILL, SIG_DFL);
|
||||
signal(SIGSEGV, SIG_DFL);
|
||||
signal(SIGFPE, SIG_DFL);
|
||||
signal(SIGBUS, SIG_DFL);
|
||||
}
|
||||
}
|
||||
|
||||
+ (void)setBuildVersion:(NSString *)theBuildVersion
|
||||
{
|
||||
buildVersion = theBuildVersion;
|
||||
}
|
||||
|
||||
+ (void)setSubLogName:(NSString *)theSubLogName
|
||||
{
|
||||
subLogName = [NSString stringWithFormat:@"-%@", theSubLogName];
|
||||
}
|
||||
|
||||
// Return the path of the crash log file
|
||||
static NSString* crashLogPath(void)
|
||||
{
|
||||
return [[MXLogger logsFolderPath] stringByAppendingPathComponent:MXLOGGER_CRASH_LOG];
|
||||
}
|
||||
|
||||
+ (NSString*)crashLog
|
||||
{
|
||||
NSString *exceptionLog;
|
||||
|
||||
NSString *crashLog = crashLogPath();
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
if([fileManager fileExistsAtPath:crashLog])
|
||||
{
|
||||
exceptionLog = crashLog;
|
||||
}
|
||||
return exceptionLog;
|
||||
}
|
||||
|
||||
+ (void)deleteCrashLog
|
||||
{
|
||||
NSString *crashLog = crashLogPath();
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
if([fileManager fileExistsAtPath:crashLog])
|
||||
{
|
||||
[fileManager removeItemAtPath:crashLog error:nil];
|
||||
}
|
||||
}
|
||||
|
||||
// The folder where logs are stored
|
||||
+ (NSString*)logsFolderPath
|
||||
{
|
||||
NSString *logsFolderPath = nil;
|
||||
|
||||
// NSURL *sharedContainerURL = [[NSFileManager defaultManager] applicationGroupContainerURL];
|
||||
// if (sharedContainerURL)
|
||||
// {
|
||||
// logsFolderPath = [sharedContainerURL path];
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
NSArray<NSURL *> *paths = [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask];
|
||||
logsFolderPath = paths[0].path;
|
||||
// }
|
||||
|
||||
return logsFolderPath;
|
||||
}
|
||||
|
||||
|
||||
// If [self redirectNSLogToFiles: numberOfFiles:] is called with a lower numberOfFiles we need to do some cleanup
|
||||
+ (void)removeExtraFilesFromCount:(NSUInteger)count
|
||||
{
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
NSString *logsFolderPath = [MXLogger logsFolderPath];
|
||||
|
||||
NSUInteger index = count;
|
||||
do
|
||||
{
|
||||
NSString *fileName = [NSString stringWithFormat:@"console%@.%tu.log", subLogName, index];
|
||||
NSString *logFile = [logsFolderPath stringByAppendingPathComponent:fileName];
|
||||
|
||||
if ([fileManager fileExistsAtPath:logFile])
|
||||
{
|
||||
[fileManager removeItemAtPath:logFile error:nil];
|
||||
// MXLogDebug(@"[MXLogger] removeExtraFilesFromCount: %@. removeItemAtPath: %@\n", @(count), logFile);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
while (index++);
|
||||
}
|
||||
|
||||
+ (void)removeFilesAfterSizeLimit:(NSUInteger)sizeLimit
|
||||
{
|
||||
NSUInteger logSize = 0;
|
||||
BOOL removeFiles = NO;
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
NSString *logsFolderPath = [MXLogger logsFolderPath];
|
||||
|
||||
// Start from console.1.log. Do not consider console.log. It should be almost empty
|
||||
NSUInteger index = 0;
|
||||
while (++index)
|
||||
{
|
||||
NSString *fileName = [NSString stringWithFormat:@"console%@.%tu.log", subLogName, index];
|
||||
NSString *logFile = [logsFolderPath stringByAppendingPathComponent:fileName];
|
||||
|
||||
if ([fileManager fileExistsAtPath:logFile])
|
||||
{
|
||||
logSize += [fileManager attributesOfItemAtPath:logFile error:nil].fileSize;
|
||||
|
||||
if (logSize >= sizeLimit)
|
||||
{
|
||||
removeFiles = YES;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (removeFiles)
|
||||
{
|
||||
// MXLogDebug(@"[MXLogger] removeFilesAfterSizeLimit: Remove files from index %@ because logs are too large (%@ for a limit of %@)\n",
|
||||
// @(index),
|
||||
// [NSByteCountFormatter stringFromByteCount:logSize countStyle:NSByteCountFormatterCountStyleBinary],
|
||||
// [NSByteCountFormatter stringFromByteCount:sizeLimit countStyle:NSByteCountFormatterCountStyleBinary]);
|
||||
[self removeExtraFilesFromCount:index];
|
||||
}
|
||||
else
|
||||
{
|
||||
// MXLogDebug(@"[MXLogger] removeFilesAfterSizeLimit: No need: %@ for a limit of %@\n",
|
||||
// [NSByteCountFormatter stringFromByteCount:logSize countStyle:NSByteCountFormatterCountStyleBinary],
|
||||
// [NSByteCountFormatter stringFromByteCount:sizeLimit countStyle:NSByteCountFormatterCountStyleBinary]);
|
||||
}
|
||||
|
||||
}
|
||||
@end
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// ElementToggleStyle.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Ismail on 2.06.2022.
|
||||
// Copyright © 2022 Element. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A toggle style that uses a button, with a checked/unchecked image like a checkbox.
|
||||
struct ElementToggleStyle: ToggleStyle {
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Button { configuration.isOn.toggle() } label: {
|
||||
Image(systemName: configuration.isOn ? "checkmark.square.fill" : "square")
|
||||
.font(.title3.weight(.regular))
|
||||
.imageScale(.large)
|
||||
.foregroundColor(Color(uiColor: Asset.Colors.elementGreen.color))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,9 @@ class RoundedToastView: UIView {
|
||||
case .success:
|
||||
imageView.image = UIImage(systemName: "checkmark.circle")
|
||||
return imageView
|
||||
case .error:
|
||||
imageView.image = UIImage(systemName: "x.circle")
|
||||
return imageView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ struct ToastViewState {
|
||||
enum Style {
|
||||
case loading
|
||||
case success
|
||||
case error
|
||||
}
|
||||
|
||||
let style: Style
|
||||
|
||||
@@ -20,6 +20,7 @@ import UIKit
|
||||
enum UserIndicatorType {
|
||||
case loading(label: String, isInteractionBlocking: Bool)
|
||||
case success(label: String)
|
||||
case error(label: String)
|
||||
}
|
||||
|
||||
/// A presenter which can handle `UserIndicatorType` by creating the underlying `UserIndicator`
|
||||
@@ -72,6 +73,8 @@ class UserIndicatorTypePresenter: UserIndicatorTypePresenterProtocol {
|
||||
}
|
||||
case .success(let label):
|
||||
return successRequest(label: label)
|
||||
case .error(let label):
|
||||
return errorRequest(label: label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,4 +116,18 @@ class UserIndicatorTypePresenter: UserIndicatorTypePresenterProtocol {
|
||||
dismissal: .timeout(1.5)
|
||||
)
|
||||
}
|
||||
|
||||
private func errorRequest(label: String) -> UserIndicatorRequest {
|
||||
let presenter = ToastViewPresenter(
|
||||
viewState: .init(
|
||||
style: .error,
|
||||
label: label
|
||||
),
|
||||
presentationContext: presentationContext
|
||||
)
|
||||
return UserIndicatorRequest(
|
||||
presenter: presenter,
|
||||
dismissal: .timeout(1.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
109
ElementX/Sources/Screens/BugReport/BugReportCoordinator.swift
Normal file
109
ElementX/Sources/Screens/BugReport/BugReportCoordinator.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// Copyright 2021 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 SwiftUI
|
||||
|
||||
struct BugReportCoordinatorParameters {
|
||||
let bugReportService: BugReportServiceProtocol
|
||||
let screenshot: UIImage?
|
||||
}
|
||||
|
||||
final class BugReportCoordinator: Coordinator, Presentable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: BugReportCoordinatorParameters
|
||||
private let bugReportHostingController: UIViewController
|
||||
private var bugReportViewModel: BugReportViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
private var errorIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var completion: (() -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: BugReportCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = BugReportViewModel(bugReportService: parameters.bugReportService,
|
||||
screenshot: parameters.screenshot)
|
||||
let view = BugReport(context: viewModel.context)
|
||||
bugReportViewModel = viewModel
|
||||
bugReportHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: bugReportHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[BugReportCoordinator] did start.")
|
||||
bugReportViewModel.callback = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[BugReportCoordinator] BugReportViewModel did complete with result: \(result).")
|
||||
switch result {
|
||||
case .submitStarted:
|
||||
self.startLoading()
|
||||
case .submitFinished:
|
||||
self.stopLoading()
|
||||
self.showSuccess(label: ElementL10n.done)
|
||||
case .submitFailed(let error):
|
||||
self.stopLoading()
|
||||
self.showError(label: error.localizedDescription)
|
||||
case .cancel:
|
||||
self.completion?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
bugReportHostingController
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
/// - Parameters:
|
||||
/// - label: The label to show on the indicator.
|
||||
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
|
||||
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: label,
|
||||
isInteractionBlocking: isInteractionBlocking))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
|
||||
/// Show success indicator
|
||||
private func showSuccess(label: String) {
|
||||
errorIndicator = indicatorPresenter.present(.success(label: label))
|
||||
}
|
||||
|
||||
/// Show error indicator
|
||||
private func showError(label: String) {
|
||||
errorIndicator = indicatorPresenter.present(.error(label: label))
|
||||
}
|
||||
}
|
||||
48
ElementX/Sources/Screens/BugReport/BugReportModels.swift
Normal file
48
ElementX/Sources/Screens/BugReport/BugReportModels.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// Copyright 2021 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 Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum BugReportViewModelAction {
|
||||
case submitStarted
|
||||
case submitFinished
|
||||
case submitFailed(error: Error)
|
||||
case cancel
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct BugReportViewState: BindableState {
|
||||
var screenshot: UIImage?
|
||||
var bindings: BugReportViewStateBindings
|
||||
}
|
||||
|
||||
struct BugReportViewStateBindings {
|
||||
var reportText: String
|
||||
var sendingLogsEnabled: Bool
|
||||
}
|
||||
|
||||
enum BugReportViewAction {
|
||||
case submit
|
||||
case cancel
|
||||
case toggleSendLogs
|
||||
case removeScreenshot
|
||||
}
|
||||
87
ElementX/Sources/Screens/BugReport/BugReportViewModel.swift
Normal file
87
ElementX/Sources/Screens/BugReport/BugReportViewModel.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// Copyright 2021 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 SwiftUI
|
||||
|
||||
@available(iOS 14, *)
|
||||
typealias BugReportViewModelType = StateStoreViewModel<BugReportViewState,
|
||||
BugReportViewAction>
|
||||
@available(iOS 14, *)
|
||||
class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let bugReportService: BugReportServiceProtocol
|
||||
|
||||
// MARK: Private
|
||||
|
||||
func submitBugReport() async {
|
||||
callback?(.submitStarted)
|
||||
do {
|
||||
var files: [URL] = []
|
||||
if let screenshot = state.screenshot {
|
||||
let anonymized = try await ImageAnonymizer.anonymizedImage(from: screenshot)
|
||||
let tmpUrl = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("screenshot").appendingPathExtension("png")
|
||||
// remove old screenshot if exists
|
||||
if FileManager.default.fileExists(atPath: tmpUrl.path) {
|
||||
try FileManager.default.removeItem(at: tmpUrl)
|
||||
}
|
||||
try anonymized.dataForPNGRepresentation().write(to: tmpUrl)
|
||||
files.append(tmpUrl)
|
||||
}
|
||||
|
||||
let result = try await bugReportService.submitBugReport(text: context.reportText,
|
||||
includeLogs: context.sendingLogsEnabled,
|
||||
includeCrashLog: true,
|
||||
githubLabels: [],
|
||||
files: files)
|
||||
MXLog.info("[BugReportViewModel] submitBugReport succeeded, result: \(result.reportUrl)")
|
||||
callback?(.submitFinished)
|
||||
} catch let error {
|
||||
MXLog.error("[BugReportViewModel] submitBugReport failed: \(error)")
|
||||
callback?(.submitFailed(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((BugReportViewModelAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(bugReportService: BugReportServiceProtocol,
|
||||
screenshot: UIImage?) {
|
||||
self.bugReportService = bugReportService
|
||||
let bindings = BugReportViewStateBindings(reportText: "", sendingLogsEnabled: true)
|
||||
super.init(initialViewState: BugReportViewState(screenshot: screenshot,
|
||||
bindings: bindings))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: BugReportViewAction) async {
|
||||
switch viewAction {
|
||||
case .submit:
|
||||
await submitBugReport()
|
||||
case .cancel:
|
||||
callback?(.cancel)
|
||||
case .toggleSendLogs:
|
||||
context.sendingLogsEnabled.toggle()
|
||||
case .removeScreenshot:
|
||||
state.screenshot = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Copyright 2021 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 Foundation
|
||||
|
||||
@MainActor
|
||||
protocol BugReportViewModelProtocol {
|
||||
|
||||
var callback: ((BugReportViewModelAction) -> Void)? { get set }
|
||||
@available(iOS 14, *)
|
||||
var context: BugReportViewModelType.Context { get }
|
||||
}
|
||||
140
ElementX/Sources/Screens/BugReport/View/BugReport.swift
Normal file
140
ElementX/Sources/Screens/BugReport/View/BugReport.swift
Normal file
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// Copyright 2021 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 SwiftUI
|
||||
|
||||
struct BugReport: View {
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
private var horizontalPadding: CGFloat {
|
||||
horizontalSizeClass == .regular ? 50 : 16
|
||||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var context: BugReportViewModel.Context
|
||||
|
||||
// MARK: Views
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
VStack {
|
||||
ScrollView {
|
||||
mainContent
|
||||
.padding(.top, 50)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
}
|
||||
.introspectScrollView { scrollView in
|
||||
scrollView.keyboardDismissMode = .onDrag
|
||||
}
|
||||
|
||||
buttons
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
|
||||
}
|
||||
.navigationTitle(ElementL10n.titleActivityBugReport)
|
||||
}
|
||||
}
|
||||
|
||||
/// The main content of the view to be shown in a scroll view.
|
||||
var mainContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(ElementL10n.sendBugReportDescription)
|
||||
.accessibilityIdentifier("reportBugDescription")
|
||||
ZStack(alignment: .topLeading) {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color(UIColor.secondarySystemBackground))
|
||||
|
||||
if context.reportText.isEmpty {
|
||||
Text(ElementL10n.sendBugReportPlaceholder)
|
||||
.foregroundColor(Color(UIColor.placeholderText))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
TextEditor(text: $context.reportText)
|
||||
.padding(4)
|
||||
.background(Color.clear)
|
||||
.cornerRadius(8)
|
||||
.accessibilityIdentifier("reportTextView")
|
||||
.introspectTextView { textView in
|
||||
textView.backgroundColor = .clear
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 300)
|
||||
.font(.body)
|
||||
Text(ElementL10n.sendBugReportLogsDescription)
|
||||
.accessibilityIdentifier("sendLogsDescription")
|
||||
HStack(spacing: 12) {
|
||||
Toggle(ElementL10n.sendBugReportIncludeLogs, isOn: $context.sendingLogsEnabled)
|
||||
.toggleStyle(ElementToggleStyle())
|
||||
.accessibilityIdentifier("sendLogsToggle")
|
||||
Text(ElementL10n.sendBugReportIncludeLogs).accessibilityIdentifier("sendLogsText")
|
||||
}
|
||||
.onTapGesture {
|
||||
context.send(viewAction: .toggleSendLogs)
|
||||
}
|
||||
screenshot
|
||||
}
|
||||
}
|
||||
|
||||
/// The action buttons shown at the bottom of the view.
|
||||
var buttons: some View {
|
||||
VStack {
|
||||
Button { context.send(viewAction: .submit) } label: {
|
||||
Text(ElementL10n.actionSend)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.disabled(context.reportText.count < 5)
|
||||
.accessibilityIdentifier("sendButton")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var screenshot: some View {
|
||||
if let screenshot = context.viewState.screenshot {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: screenshot)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 100)
|
||||
.accessibilityIdentifier("screenshotImage")
|
||||
Button { context.send(viewAction: .removeScreenshot) } label: {
|
||||
Image(uiImage: Asset.Images.closeCircle.image)
|
||||
}
|
||||
.offset(x: 10, y: -10)
|
||||
.accessibilityIdentifier("removeScreenshotButton")
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct BugReport_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: Asset.Images.appLogo.image)
|
||||
BugReport(context: viewModel.context)
|
||||
.previewInterfaceOrientation(.portrait)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ struct HomeScreenCoordinatorParameters {
|
||||
|
||||
enum HomeScreenCoordinatorAction {
|
||||
case logout
|
||||
case selectRoom(roomIdentifier: String)
|
||||
case presentRoom(roomIdentifier: String)
|
||||
case presentSettings
|
||||
}
|
||||
|
||||
final class HomeScreenCoordinator: Coordinator, Presentable {
|
||||
@@ -65,7 +66,9 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
|
||||
case .logout:
|
||||
self.callback?(.logout)
|
||||
case .selectRoom(let roomIdentifier):
|
||||
self.callback?(.selectRoom(roomIdentifier: roomIdentifier))
|
||||
self.callback?(.presentRoom(roomIdentifier: roomIdentifier))
|
||||
case .tapUserAvatar:
|
||||
self.callback?(.presentSettings)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,14 @@ import UIKit
|
||||
enum HomeScreenViewModelAction {
|
||||
case logout
|
||||
case selectRoom(roomIdentifier: String)
|
||||
case tapUserAvatar
|
||||
}
|
||||
|
||||
enum HomeScreenViewAction {
|
||||
case logout
|
||||
case loadRoomData(roomIdentifier: String)
|
||||
case selectRoom(roomIdentifier: String)
|
||||
case tapUserAvatar
|
||||
}
|
||||
|
||||
struct HomeScreenViewState: BindableState {
|
||||
|
||||
@@ -51,6 +51,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
loadRoomDataForIdentifier(roomIdentifier)
|
||||
case .selectRoom(let roomIdentifier):
|
||||
callback?(.selectRoom(roomIdentifier: roomIdentifier))
|
||||
case .tapUserAvatar:
|
||||
callback?(.tapUserAvatar)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,11 +75,13 @@ struct HomeScreen: View {
|
||||
HStack {
|
||||
ZStack {
|
||||
if let avatar = context.viewState.userAvatar {
|
||||
Image(uiImage: avatar)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 40, height: 40, alignment: .center)
|
||||
.mask(Circle())
|
||||
Button { context.send(viewAction: .tapUserAvatar) } label: {
|
||||
Image(uiImage: avatar)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 40, height: 40, alignment: .center)
|
||||
.mask(Circle())
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
@@ -89,9 +91,12 @@ struct HomeScreen: View {
|
||||
|
||||
ZStack {
|
||||
if let displayName = context.viewState.userDisplayName {
|
||||
Text("Hello, \(displayName)!")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.bold)
|
||||
Button { context.send(viewAction: .tapUserAvatar) } label: {
|
||||
Text("Hello, \(displayName)!")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
109
ElementX/Sources/Screens/Settings/SettingsCoordinator.swift
Normal file
109
ElementX/Sources/Screens/Settings/SettingsCoordinator.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// Copyright 2021 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 SwiftUI
|
||||
|
||||
struct SettingsCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
let bugReportService: BugReportServiceProtocol
|
||||
}
|
||||
|
||||
final class SettingsCoordinator: Coordinator, Presentable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: SettingsCoordinatorParameters
|
||||
private let settingsHostingController: UIViewController
|
||||
private var settingsViewModel: SettingsViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: SettingsCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = SettingsViewModel()
|
||||
let view = Settings(context: viewModel.context)
|
||||
settingsViewModel = viewModel
|
||||
settingsHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: settingsHostingController)
|
||||
|
||||
settingsViewModel.callback = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[SettingsCoordinator] SettingsViewModel did complete with result: \(result).")
|
||||
switch result {
|
||||
case .reportBug:
|
||||
self.presentBugReportScreen()
|
||||
case .crash:
|
||||
self.parameters.bugReportService.crash()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
settingsHostingController
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
/// - Parameters:
|
||||
/// - label: The label to show on the indicator.
|
||||
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
|
||||
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
|
||||
private func presentBugReportScreen() {
|
||||
let params = BugReportCoordinatorParameters(bugReportService: parameters.bugReportService,
|
||||
screenshot: nil)
|
||||
let coordinator = BugReportCoordinator(parameters: params)
|
||||
coordinator.completion = { [weak self, weak coordinator] in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.parameters.navigationRouter.popModule(animated: true)
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
self.parameters.navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
}
|
||||
42
ElementX/Sources/Screens/Settings/SettingsModels.swift
Normal file
42
ElementX/Sources/Screens/Settings/SettingsModels.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// Copyright 2021 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 Foundation
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum SettingsViewModelAction {
|
||||
case reportBug
|
||||
case crash
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct SettingsViewState: BindableState {
|
||||
var crashButtonVisible: Bool
|
||||
var bindings: SettingsViewStateBindings
|
||||
}
|
||||
|
||||
struct SettingsViewStateBindings {
|
||||
|
||||
}
|
||||
|
||||
enum SettingsViewAction {
|
||||
case reportBug
|
||||
case crash
|
||||
}
|
||||
50
ElementX/Sources/Screens/Settings/SettingsViewModel.swift
Normal file
50
ElementX/Sources/Screens/Settings/SettingsViewModel.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// Copyright 2021 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 SwiftUI
|
||||
|
||||
@available(iOS 14, *)
|
||||
typealias SettingsViewModelType = StateStoreViewModel<SettingsViewState,
|
||||
SettingsViewAction>
|
||||
@available(iOS 14, *)
|
||||
class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((SettingsViewModelAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init() {
|
||||
let bindings = SettingsViewStateBindings()
|
||||
super.init(initialViewState: .init(crashButtonVisible: true, bindings: bindings))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: SettingsViewAction) async {
|
||||
switch viewAction {
|
||||
case .reportBug:
|
||||
callback?(.reportBug)
|
||||
case .crash:
|
||||
callback?(.crash)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Copyright 2021 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 Foundation
|
||||
|
||||
@MainActor
|
||||
protocol SettingsViewModelProtocol {
|
||||
|
||||
var callback: ((SettingsViewModelAction) -> Void)? { get set }
|
||||
@available(iOS 14, *)
|
||||
var context: SettingsViewModelType.Context { get }
|
||||
}
|
||||
61
ElementX/Sources/Screens/Settings/View/Settings.swift
Normal file
61
ElementX/Sources/Screens/Settings/View/Settings.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// Copyright 2021 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 SwiftUI
|
||||
|
||||
struct Settings: View {
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var context: SettingsViewModel.Context
|
||||
|
||||
// MARK: Views
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Button { context.send(viewAction: .reportBug) } label: {
|
||||
Text(ElementL10n.sendBugReport)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.accessibilityIdentifier("reportBugButton")
|
||||
|
||||
if context.viewState.crashButtonVisible {
|
||||
Button { context.send(viewAction: .crash) } label: {
|
||||
Text("Crash the app")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.accessibilityIdentifier("crashButton")
|
||||
}
|
||||
}
|
||||
.navigationTitle(ElementL10n.settings)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct Settings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
let viewModel = SettingsViewModel()
|
||||
Settings(context: viewModel.context)
|
||||
.previewInterfaceOrientation(.portrait)
|
||||
}
|
||||
}
|
||||
}
|
||||
223
ElementX/Sources/Services/BugReport/BugReportService.swift
Normal file
223
ElementX/Sources/Services/BugReport/BugReportService.swift
Normal file
@@ -0,0 +1,223 @@
|
||||
//
|
||||
// BugReportService.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Ismail on 16.05.2022.
|
||||
// Copyright © 2022 element.io. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
import GZIP
|
||||
import Sentry
|
||||
|
||||
enum BugReportServiceError: Error {
|
||||
case invalidBaseUrlString
|
||||
case invalidSentryEndpoint
|
||||
}
|
||||
|
||||
class BugReportService: BugReportServiceProtocol {
|
||||
private let baseURL: URL
|
||||
private let sentryEndpoint: String
|
||||
private let applicationId: String
|
||||
private let session: URLSession
|
||||
|
||||
init(withBaseUrlString baseUrlString: String,
|
||||
sentryEndpoint: String,
|
||||
applicationId: String = BuildSettings.bugReportApplicationId,
|
||||
session: URLSession = .shared) throws {
|
||||
guard let url = URL(string: baseUrlString) else {
|
||||
throw BugReportServiceError.invalidBaseUrlString
|
||||
}
|
||||
guard !sentryEndpoint.isEmpty else {
|
||||
throw BugReportServiceError.invalidSentryEndpoint
|
||||
}
|
||||
self.baseURL = url
|
||||
self.sentryEndpoint = sentryEndpoint
|
||||
self.applicationId = applicationId
|
||||
self.session = session
|
||||
|
||||
// enable SentrySDK
|
||||
SentrySDK.start { options in
|
||||
options.dsn = sentryEndpoint
|
||||
#if DEBUG
|
||||
options.debug = true
|
||||
#endif
|
||||
|
||||
// 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 = { event in
|
||||
MXLog.debug("Sentry detected application was crashed: \(event)")
|
||||
}
|
||||
}
|
||||
|
||||
// also enable logging crashes, to send them with bug reports
|
||||
MXLogger.logCrashes(true)
|
||||
// set build version for logger
|
||||
MXLogger.setBuildVersion(ElementInfoPlist.cfBundleShortVersionString)
|
||||
}
|
||||
|
||||
// MARK: - BugReportServiceProtocol
|
||||
|
||||
var crashedLastRun: Bool {
|
||||
return SentrySDK.crashedLastRun
|
||||
}
|
||||
|
||||
func crash() {
|
||||
SentrySDK.crash()
|
||||
}
|
||||
|
||||
func submitBugReport(text: String,
|
||||
includeLogs: Bool,
|
||||
includeCrashLog: Bool,
|
||||
githubLabels: [String],
|
||||
files: [URL]) async throws -> SubmitBugReportResponse {
|
||||
MXLog.debug("[BugReportService] submitBugReport")
|
||||
|
||||
var params = [
|
||||
MultipartFormData(key: "text", type: .text(value: text))
|
||||
]
|
||||
params.append(contentsOf: defaultParams)
|
||||
for label in githubLabels {
|
||||
params.append(MultipartFormData(key: "label", type: .text(value: label)))
|
||||
}
|
||||
let zippedFiles = try await zipFiles(includeLogs: includeLogs,
|
||||
includeCrashLog: includeCrashLog)
|
||||
// log or compressed-log
|
||||
if !zippedFiles.isEmpty {
|
||||
for url in zippedFiles {
|
||||
params.append(MultipartFormData(key: "compressed-log", type: .file(url: url)))
|
||||
}
|
||||
}
|
||||
for url in files {
|
||||
params.append(MultipartFormData(key: "file", type: .file(url: url)))
|
||||
}
|
||||
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var body = Data()
|
||||
for param in params {
|
||||
body.appendString(string: "--\(boundary)\r\n")
|
||||
body.appendString(string: "Content-Disposition:form-data; name=\"\(param.key)\"")
|
||||
switch param.type {
|
||||
case .text(let value):
|
||||
body.appendString(string: "\r\n\r\n\(value)\r\n")
|
||||
case .file(let url):
|
||||
body.appendString(string: "; filename=\"\(url.lastPathComponent)\"\r\n")
|
||||
body.appendString(string: "Content-Type: \"content-type header\"\r\n\r\n")
|
||||
body.append(try Data(contentsOf: url))
|
||||
body.appendString(string: "\r\n")
|
||||
}
|
||||
}
|
||||
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, _) = 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()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var defaultParams: [MultipartFormData] {
|
||||
[
|
||||
MultipartFormData(key: "user_agent", type: .text(value: "iOS")),
|
||||
MultipartFormData(key: "app", type: .text(value: applicationId)),
|
||||
MultipartFormData(key: "version", type: .text(value: version)),
|
||||
MultipartFormData(key: "os", type: .text(value: os)),
|
||||
MultipartFormData(key: "client", type: .text(value: "Element-X"))
|
||||
]
|
||||
}
|
||||
|
||||
private var os: String {
|
||||
"\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)"
|
||||
}
|
||||
|
||||
private var version: String {
|
||||
ElementInfoPlist.cfBundleShortVersionString
|
||||
}
|
||||
|
||||
private func zipFiles(includeLogs: Bool,
|
||||
includeCrashLog: Bool) async throws -> [URL] {
|
||||
MXLog.debug("[BugReportService] zipFiles: includeLogs: \(includeLogs), includeCrashLog: \(includeCrashLog)")
|
||||
|
||||
var filesToCompress: [URL] = []
|
||||
if includeLogs, let logFiles = MXLogger.logFiles() {
|
||||
let urls = logFiles.compactMap { URL(fileURLWithPath: $0) }
|
||||
filesToCompress.append(contentsOf: urls)
|
||||
}
|
||||
if includeCrashLog, let crashLogFile = MXLogger.crashLog() {
|
||||
filesToCompress.append(URL(fileURLWithPath: crashLogFile))
|
||||
}
|
||||
|
||||
var totalSize: Int = 0
|
||||
var totalZippedSize: Int = 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.debug("[BugReportService] 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MultipartFormData {
|
||||
let key: String
|
||||
let type: MultipartFormDataType
|
||||
}
|
||||
|
||||
private enum MultipartFormDataType {
|
||||
case text(value: String)
|
||||
case file(url: URL)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// BugReportServiceProtocol.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Ismail on 16.05.2022.
|
||||
// Copyright © 2022 element.io. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct SubmitBugReportResponse: Decodable {
|
||||
var reportUrl: String
|
||||
}
|
||||
|
||||
protocol BugReportServiceProtocol {
|
||||
|
||||
var crashedLastRun: Bool { get }
|
||||
|
||||
func crash()
|
||||
|
||||
func submitBugReport(text: String,
|
||||
includeLogs: Bool,
|
||||
includeCrashLog: Bool,
|
||||
githubLabels: [String],
|
||||
files: [URL]) async throws -> SubmitBugReportResponse
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// MockBugReportService.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Ismail on 16.05.2022.
|
||||
// Copyright © 2022 element.io. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class MockBugReportService: BugReportServiceProtocol {
|
||||
|
||||
func submitBugReport(text: String,
|
||||
includeLogs: Bool,
|
||||
includeCrashLog: Bool,
|
||||
githubLabels: [String],
|
||||
files: [URL]) async throws -> SubmitBugReportResponse {
|
||||
return SubmitBugReportResponse(reportUrl: "https://www.example/com/123")
|
||||
}
|
||||
|
||||
var crashedLastRun: Bool = false
|
||||
|
||||
func crash() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
}
|
||||
110
ElementX/Sources/Services/BugReport/ScreenshotDetector.swift
Normal file
110
ElementX/Sources/Services/BugReport/ScreenshotDetector.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// ScreenshotObserver.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Ismail on 31.05.2022.
|
||||
// Copyright © 2022 element.io. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
enum ScreenshotDetectorError: String, Error {
|
||||
case loadFailed
|
||||
case notAuthorized
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class ScreenshotDetector {
|
||||
|
||||
var callback: (@MainActor (UIImage?, Error?) -> Void)?
|
||||
|
||||
/// Flag to whether ask for photos authorization by default if needed.
|
||||
var autoRequestPHAuthorization = true
|
||||
|
||||
init() {
|
||||
startObservingScreenshots()
|
||||
}
|
||||
|
||||
private func startObservingScreenshots() {
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(userDidTakeScreenshot),
|
||||
name: UIApplication.userDidTakeScreenshotNotification,
|
||||
object: nil)
|
||||
}
|
||||
|
||||
@objc private func userDidTakeScreenshot() {
|
||||
let authStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
if authStatus == .authorized {
|
||||
findScreenshot()
|
||||
} else if authStatus == .notDetermined && autoRequestPHAuthorization {
|
||||
Task {
|
||||
self.handleAuthStatus(await PHPhotoLibrary.requestAuthorization(for: .readWrite))
|
||||
}
|
||||
} else {
|
||||
fail(withError: ScreenshotDetectorError.notAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAuthStatus(_ status: PHAuthorizationStatus) {
|
||||
if status == .authorized {
|
||||
findScreenshot()
|
||||
} else {
|
||||
fail(withError: ScreenshotDetectorError.notAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
private func findScreenshot() {
|
||||
if let asset = PHAsset.fetchLastScreenshot() {
|
||||
let imageManager = PHImageManager()
|
||||
imageManager.requestImage(for: asset,
|
||||
targetSize: PHImageManagerMaximumSize,
|
||||
contentMode: .default,
|
||||
options: PHImageRequestOptions.highQualitySyncLocal) { [weak self] image, _ in
|
||||
guard let image = image else {
|
||||
self?.fail(withError: ScreenshotDetectorError.loadFailed)
|
||||
return
|
||||
}
|
||||
self?.succeed(withImage: image)
|
||||
}
|
||||
} else {
|
||||
fail(withError: ScreenshotDetectorError.loadFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func succeed(withImage image: UIImage) {
|
||||
callback?(image, nil)
|
||||
}
|
||||
|
||||
func fail(withError error: Error) {
|
||||
callback?(nil, error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension PHAsset {
|
||||
|
||||
static func fetchLastScreenshot() -> PHAsset? {
|
||||
let options = PHFetchOptions()
|
||||
|
||||
options.fetchLimit = 1
|
||||
options.includeAssetSourceTypes = [.typeUserLibrary]
|
||||
options.wantsIncrementalChangeDetails = false
|
||||
options.predicate = NSPredicate(format: "(mediaSubtype & %d) != 0", PHAssetMediaSubtype.photoScreenshot.rawValue)
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||
|
||||
return PHAsset.fetchAssets(with: .image, options: options).firstObject
|
||||
}
|
||||
}
|
||||
|
||||
private extension PHImageRequestOptions {
|
||||
|
||||
static var highQualitySyncLocal: PHImageRequestOptions {
|
||||
let options = PHImageRequestOptions()
|
||||
options.deliveryMode = .highQualityFormat
|
||||
options.isNetworkAccessAllowed = false
|
||||
options.isSynchronous = true
|
||||
return options
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,14 @@ class UITestsAppCoordinator: Coordinator {
|
||||
}
|
||||
|
||||
private func mockScreens() -> [MockScreen] {
|
||||
[MockScreen(id: "Login screen", coordinator: LoginScreenCoordinator(parameters: .init())),
|
||||
MockScreen(id: "Simple Screen - Regular", coordinator: TemplateSimpleScreenCoordinator(parameters: .init(promptType: .regular))),
|
||||
MockScreen(id: "Simple Screen - Upgrade", coordinator: TemplateSimpleScreenCoordinator(parameters: .init(promptType: .upgrade)))]
|
||||
[
|
||||
MockScreen(id: "Login screen", coordinator: LoginScreenCoordinator(parameters: .init())),
|
||||
MockScreen(id: "Simple Screen - Regular", coordinator: TemplateSimpleScreenCoordinator(parameters: .init(promptType: .regular))),
|
||||
MockScreen(id: "Simple Screen - Upgrade", coordinator: TemplateSimpleScreenCoordinator(parameters: .init(promptType: .upgrade))),
|
||||
MockScreen(id: "Settings screen", coordinator: SettingsCoordinator(parameters: .init(navigationRouter: NavigationRouter(navigationController: UINavigationController()), bugReportService: MockBugReportService()))),
|
||||
MockScreen(id: "Bug report screen", coordinator: BugReportCoordinator(parameters: .init(bugReportService: MockBugReportService(), screenshot: nil))),
|
||||
MockScreen(id: "Bug report screen with screenshot", coordinator: BugReportCoordinator(parameters: .init(bugReportService: MockBugReportService(), screenshot: Asset.Images.appLogo.image)))
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user