From da367aaf514c7edb3143c6bf69932fc23c5da0ce Mon Sep 17 00:00:00 2001 From: Adwin Ronald Ross Date: Wed, 29 Jan 2025 17:56:37 +0530 Subject: [PATCH] chore(ios): add attachment on crash --- ios/DemoApp/Info.plist | 2 + ios/MeasureSDK.xcodeproj/project.pbxproj | 16 ++++ .../Config/BaseConfigProvider.swift | 8 ++ ios/MeasureSDK/Config/Config.swift | 4 + ios/MeasureSDK/Config/InternalConfig.swift | 5 ++ .../CoreData/Entities/EventEntity.swift | 16 +++- .../CrashReporter/CrashReportingManager.swift | 16 +++- .../CrashReporter/CrashScreenshotSaver.swift | 51 ++++++++++++ ios/MeasureSDK/Events/Attachment.swift | 10 +++ .../Events/AttachmentProcessor.swift | 57 +++++++++++++ ios/MeasureSDK/Events/AttachmentType.swift | 1 + ios/MeasureSDK/Events/EventProcessor.swift | 40 ++++++--- ios/MeasureSDK/Exporter/EventSerializer.swift | 28 ++++++- ios/MeasureSDK/Exporter/HttpClient.swift | 5 ++ ios/MeasureSDK/Exporter/HttpModels.swift | 1 + ios/MeasureSDK/Exporter/NetworkClient.swift | 18 +++- .../Lifecycle/LifecycleCollector.swift | 9 +- ios/MeasureSDK/MeasureInitializer.swift | 31 ++++++- ios/MeasureSDK/MeasureInternal.swift | 7 ++ ios/MeasureSDK/Utils/Constants.swift | 3 + .../Utils/Extensions/UIColor+Extension.swift | 37 +++++++++ .../Utils/ScreenshotGenerator.swift | 82 +++++++++++++++++++ ios/MeasureSDK/Utils/SystemFileManager.swift | 59 +++++++++++++ 23 files changed, 483 insertions(+), 23 deletions(-) create mode 100644 ios/MeasureSDK/CrashReporter/CrashScreenshotSaver.swift create mode 100644 ios/MeasureSDK/Events/AttachmentProcessor.swift create mode 100644 ios/MeasureSDK/Utils/Extensions/UIColor+Extension.swift create mode 100644 ios/MeasureSDK/Utils/ScreenshotGenerator.swift diff --git a/ios/DemoApp/Info.plist b/ios/DemoApp/Info.plist index acb8c763f..68b6bf99e 100644 --- a/ios/DemoApp/Info.plist +++ b/ios/DemoApp/Info.plist @@ -2,6 +2,8 @@ + NSPhotoLibraryUsageDescription + Some description to explain why access is required UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/ios/MeasureSDK.xcodeproj/project.pbxproj b/ios/MeasureSDK.xcodeproj/project.pbxproj index 234319d12..63dda222d 100644 --- a/ios/MeasureSDK.xcodeproj/project.pbxproj +++ b/ios/MeasureSDK.xcodeproj/project.pbxproj @@ -51,6 +51,8 @@ 5229D16E2CCB533C00EFFE44 /* RecentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5229D16D2CCB533C00EFFE44 /* RecentSession.swift */; }; 522BA9D42D36579100DBF4A3 /* DataCleanupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522BA9D32D36579000DBF4A3 /* DataCleanupService.swift */; }; 522BA9D62D37C2A000DBF4A3 /* DataCleanupServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522BA9D52D37C2A000DBF4A3 /* DataCleanupServiceTests.swift */; }; + 522BA9DA2D392BCD00DBF4A3 /* ScreenshotGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522BA9D92D392BCD00DBF4A3 /* ScreenshotGenerator.swift */; }; + 522BA9DC2D3A4DE500DBF4A3 /* AttachmentProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522BA9DB2D3A4DE500DBF4A3 /* AttachmentProcessor.swift */; }; 523287692C85E07B000EE268 /* LifecycleObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523287682C85E07B000EE268 /* LifecycleObserverTests.swift */; }; 523287732C86195E000EE268 /* SessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523287722C86195E000EE268 /* SessionManagerTests.swift */; }; 523287752C8619C4000EE268 /* MockIdProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523287742C8619C4000EE268 /* MockIdProvider.swift */; }; @@ -209,6 +211,8 @@ 52FA6A8B2CE21EDF0091F089 /* MockMemoryUsageCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FA6A8A2CE21EDF0091F089 /* MockMemoryUsageCalculator.swift */; }; 52FA6A8D2CE222030091F089 /* CpuUsageCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FA6A8C2CE222030091F089 /* CpuUsageCalculatorTests.swift */; }; 52FA6A8F2CE222360091F089 /* MemoryUsageCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FA6A8E2CE222360091F089 /* MemoryUsageCalculatorTests.swift */; }; + 52FD08D62D48FF9600897EA2 /* CrashScreenshotSaver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FD08D52D48FF9600897EA2 /* CrashScreenshotSaver.swift */; }; + 52FD08D82D4A432000897EA2 /* UIColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FD08D72D4A431F00897EA2 /* UIColor+Extension.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -373,6 +377,8 @@ 5229D16D2CCB533C00EFFE44 /* RecentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSession.swift; sourceTree = ""; }; 522BA9D32D36579000DBF4A3 /* DataCleanupService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCleanupService.swift; sourceTree = ""; }; 522BA9D52D37C2A000DBF4A3 /* DataCleanupServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCleanupServiceTests.swift; sourceTree = ""; }; + 522BA9D92D392BCD00DBF4A3 /* ScreenshotGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotGenerator.swift; sourceTree = ""; }; + 522BA9DB2D3A4DE500DBF4A3 /* AttachmentProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentProcessor.swift; sourceTree = ""; }; 523287682C85E07B000EE268 /* LifecycleObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifecycleObserverTests.swift; sourceTree = ""; }; 523287722C86195E000EE268 /* SessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManagerTests.swift; sourceTree = ""; }; 523287742C8619C4000EE268 /* MockIdProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdProvider.swift; sourceTree = ""; }; @@ -528,6 +534,8 @@ 52FA6A8A2CE21EDF0091F089 /* MockMemoryUsageCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMemoryUsageCalculator.swift; sourceTree = ""; }; 52FA6A8C2CE222030091F089 /* CpuUsageCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CpuUsageCalculatorTests.swift; sourceTree = ""; }; 52FA6A8E2CE222360091F089 /* MemoryUsageCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryUsageCalculatorTests.swift; sourceTree = ""; }; + 52FD08D52D48FF9600897EA2 /* CrashScreenshotSaver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashScreenshotSaver.swift; sourceTree = ""; }; + 52FD08D72D4A431F00897EA2 /* UIColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extension.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -605,6 +613,7 @@ 52A1A9472CA31E8E00461103 /* NSManagedObjectContext+Extension.swift */, 52AE72072CABAEAB00F2830A /* NSObject+Extension.swift */, 5225D0342D0AEB1A00FD240D /* String+Extension.swift */, + 52FD08D72D4A431F00897EA2 /* UIColor+Extension.swift */, 5202BE432C89600200A3496E /* UIDevice+Extension.swift */, 52D51A5B2CCE77A5008F30A6 /* UIViewController+Extension.swift */, 52AE72062CABAEAB00F2830A /* UIWindow+Extension.swift */, @@ -635,6 +644,7 @@ isa = PBXGroup; children = ( 5202BE732C8B117900A3496E /* Attachment.swift */, + 522BA9DB2D3A4DE500DBF4A3 /* AttachmentProcessor.swift */, 5202BE742C8B117900A3496E /* AttachmentType.swift */, 52B2A8782D1A89EF00C6B5CF /* CustomEventCollector.swift */, 52B2A8762D1A790200C6B5CF /* CustomEventData.swift */, @@ -831,6 +841,7 @@ 52CC63C22C9C609F00F7CA0A /* CrashDataWriter.swift */, 52CC63C62C9D870B00F7CA0A /* CrashReport.swift */, 52A853362C983FFC00B2A39F /* CrashReportingManager.swift */, + 52FD08D52D48FF9600897EA2 /* CrashScreenshotSaver.swift */, 52CC63C82C9DE71300F7CA0A /* SystemCrashReporter.swift */, ); path = CrashReporter; @@ -861,6 +872,7 @@ 528EAB8C2C80824200CB1574 /* Logger.swift */, 52A1A9452CA1786A00461103 /* MeasureQueue.swift */, 528EAB972C845AF400CB1574 /* Randomizer.swift */, + 522BA9D92D392BCD00DBF4A3 /* ScreenshotGenerator.swift */, 52EB380B2C8C7334002D63EC /* SignPost.swift */, 52FA6A7F2CE212320091F089 /* SysCtl.swift */, 52CC63C42C9D714200F7CA0A /* SystemFileManager.swift */, @@ -1392,6 +1404,7 @@ 528EAB8B2C807BA800CB1574 /* IdProvider.swift in Sources */, 5243D7C72CEB87C700401D92 /* LaunchCallbacks.swift in Sources */, 5202BE3E2C895FC800A3496E /* InstallationIdAttributeProcessor.swift in Sources */, + 52FD08D82D4A432000897EA2 /* UIColor+Extension.swift in Sources */, 52CD91242C7C3D0F000189BA /* MeasureInternal.swift in Sources */, 52A1A94D2CA3D3CA00461103 /* EventEntity.swift in Sources */, 5242B2A02C7F4AF600BE19F7 /* ClientInfo.swift in Sources */, @@ -1405,6 +1418,7 @@ 5225D0352D0AEB1A00FD240D /* String+Extension.swift in Sources */, 524576772CC1366E00B288E5 /* EventExporter.swift in Sources */, 5202BE472C89600200A3496E /* UIDevice+Extension.swift in Sources */, + 52FD08D62D48FF9600897EA2 /* CrashScreenshotSaver.swift in Sources */, 52AE72012CABAE9000F2830A /* GestureCollector.swift in Sources */, 5202BE3B2C895FC800A3496E /* AttributeProcessor.swift in Sources */, 522BA9D42D36579100DBF4A3 /* DataCleanupService.swift in Sources */, @@ -1423,6 +1437,7 @@ 5202BE482C89600200A3496E /* UserDefaultStorage.swift in Sources */, 5225D0502D0FECFF00FD240D /* InputStream+Extension.swift in Sources */, 5202BE7A2C8B117900A3496E /* AttachmentType.swift in Sources */, + 522BA9DA2D392BCD00DBF4A3 /* ScreenshotGenerator.swift in Sources */, 528EAB8D2C80824200CB1574 /* Logger.swift in Sources */, 528EAB8F2C81B4C700CB1574 /* TimeProvider.swift in Sources */, 52CD91222C7C318C000189BA /* ConfigLoader.swift in Sources */, @@ -1487,6 +1502,7 @@ 52A1A9462CA1786A00461103 /* MeasureQueue.swift in Sources */, 52AE72092CABAEAB00F2830A /* NSObject+Extension.swift in Sources */, 5225D0312D088B7100FD240D /* NetworkInterceptorProtocol.swift in Sources */, + 522BA9DC2D3A4DE500DBF4A3 /* AttachmentProcessor.swift in Sources */, 52CD91202C7B39AE000189BA /* Config.swift in Sources */, 52AE72082CABAEAB00F2830A /* UIWindow+Extension.swift in Sources */, 52D51A5E2CCE7BE8008F30A6 /* LifecycleManager.swift in Sources */, diff --git a/ios/MeasureSDK/Config/BaseConfigProvider.swift b/ios/MeasureSDK/Config/BaseConfigProvider.swift index a07333869..b25122def 100644 --- a/ios/MeasureSDK/Config/BaseConfigProvider.swift +++ b/ios/MeasureSDK/Config/BaseConfigProvider.swift @@ -33,6 +33,14 @@ final class BaseConfigProvider: ConfigProvider { self.cachedConfig = configLoader.getCachedConfig() } + var screenshotMaskHexColor: String { + return getMergedConfig(\.screenshotMaskHexColor) + } + + var screenshotCompressionQuality: CGFloat { + return getMergedConfig(\.screenshotCompressionQuality) + } + var eventTypeExportAllowList: [EventType] { return getMergedConfig(\.eventTypeExportAllowList) } diff --git a/ios/MeasureSDK/Config/Config.swift b/ios/MeasureSDK/Config/Config.swift index 036d75c68..1798f4687 100644 --- a/ios/MeasureSDK/Config/Config.swift +++ b/ios/MeasureSDK/Config/Config.swift @@ -36,6 +36,8 @@ struct Config: InternalConfig, MeasureConfig { let maxUserDefinedAttributeValueLength: Int let maxUserDefinedAttributesPerEvent: Int let eventTypeExportAllowList: [EventType] + let screenshotMaskHexColor: String + let screenshotCompressionQuality: CGFloat internal init(enableLogging: Bool = DefaultConfig.enableLogging, trackScreenshotOnCrash: Bool = DefaultConfig.trackScreenshotOnCrash, @@ -71,5 +73,7 @@ struct Config: InternalConfig, MeasureConfig { .lifecycleSwiftUI, .lifecycleViewController, .screenView] + self.screenshotMaskHexColor = "222222" + self.screenshotCompressionQuality = 0.25 } } diff --git a/ios/MeasureSDK/Config/InternalConfig.swift b/ios/MeasureSDK/Config/InternalConfig.swift index 38abc68a2..652152a75 100644 --- a/ios/MeasureSDK/Config/InternalConfig.swift +++ b/ios/MeasureSDK/Config/InternalConfig.swift @@ -63,4 +63,9 @@ protocol InternalConfig { /// All `EventType`s that are always exported, regardless of other filters like session sampling rate and whether the session crashed or not. var eventTypeExportAllowList: [EventType] { get } + /// The color of the mask to apply to the screenshot. The value should be a hex color string. For example, "#222222". + var screenshotMaskHexColor: String { get } + + /// The compression quality of the screenshot. Must be between 0 and 1, where 0 is lowest quality and smallest size while 1 is highest quality and largest size. + var screenshotCompressionQuality: CGFloat { get } } diff --git a/ios/MeasureSDK/CoreData/Entities/EventEntity.swift b/ios/MeasureSDK/CoreData/Entities/EventEntity.swift index deae7d6c8..ce287135a 100644 --- a/ios/MeasureSDK/CoreData/Entities/EventEntity.swift +++ b/ios/MeasureSDK/CoreData/Entities/EventEntity.swift @@ -44,7 +44,7 @@ struct EventEntity { // swiftlint:disable:this type_body_length self.type = event.type.rawValue self.userTriggered = event.userTriggered self.timestampInMillis = event.timestampInMillis ?? 0 - self.attachmentSize = 0 + self.attachmentSize = event.attachments?.reduce(0) { $0 + $1.size } ?? 0 self.batchId = nil self.userDefinedAttributes = event.userDefinedAttributes self.needsReporting = needsReporting @@ -466,9 +466,21 @@ struct EventEntity { // swiftlint:disable:this type_body_length timestampInMillis: self.timestampInMillis, type: EventType(rawValue: self.type) ?? .exception, data: decodedData, - attachments: decodedAttachments ?? [Attachment](), + attachments: decodedAttachments, attributes: decodedAttributes, userTriggered: self.userTriggered, userDefinedAttributes: self.userDefinedAttributes) } + + func getAttachments() -> [Attachment]? { + if let attachmentData = self.attachments { + do { + return try JSONDecoder().decode([Attachment].self, from: attachmentData) + } catch { + return nil + } + } else { + return nil + } + } } diff --git a/ios/MeasureSDK/CrashReporter/CrashReportingManager.swift b/ios/MeasureSDK/CrashReporter/CrashReportingManager.swift index 4cbe2faf4..db082c35f 100644 --- a/ios/MeasureSDK/CrashReporter/CrashReportingManager.swift +++ b/ios/MeasureSDK/CrashReporter/CrashReportingManager.swift @@ -29,15 +29,19 @@ final class CrashReportingManager: CrashReportManager { private let logger: Logger private let eventProcessor: EventProcessor private let crashDataPersistence: CrashDataPersistence + private let systemFileManager: SystemFileManager + private let idProvider: IdProvider let hasPendingCrashReport: Bool - init(logger: Logger, eventProcessor: EventProcessor, crashDataPersistence: CrashDataPersistence, crashReporter: SystemCrashReporter) { + init(logger: Logger, eventProcessor: EventProcessor, crashDataPersistence: CrashDataPersistence, crashReporter: SystemCrashReporter, systemFileManager: SystemFileManager, idProvider: IdProvider) { self.logger = logger self.eventProcessor = eventProcessor self.crashDataPersistence = crashDataPersistence self.crashReporter = crashReporter self.crashReporter.setCrashCallback(measureCrashCallback) self.hasPendingCrashReport = crashReporter.hasPendingCrashReport + self.systemFileManager = systemFileManager + self.idProvider = idProvider } func enableCrashReporting() { @@ -58,6 +62,14 @@ final class CrashReportingManager: CrashReportManager { return } + var attachments: [Attachment]? + if let latestScreenshot = systemFileManager.retrieveFile(name: crashScreenshotName, folderName: crashScreenshotDirectoryName, directory: .documentDirectory) { + attachments = [Attachment(name: crashScreenshotName, + type: .screenshot, + size: Int64(latestScreenshot.count), + id: idProvider.createId(), + bytes: latestScreenshot)] + } let crashDataAttributes = crashDataPersistence.readCrashData() if let attributes = crashDataAttributes.attribute, let sessionId = crashDataPersistence.sessionId { exception.foreground = crashDataPersistence.isForeground @@ -66,7 +78,7 @@ final class CrashReportingManager: CrashReportManager { type: .exception, attributes: attributes, sessionId: sessionId, - attachments: nil, + attachments: attachments, userDefinedAttributes: nil) } diff --git a/ios/MeasureSDK/CrashReporter/CrashScreenshotSaver.swift b/ios/MeasureSDK/CrashReporter/CrashScreenshotSaver.swift new file mode 100644 index 000000000..4be6d5c94 --- /dev/null +++ b/ios/MeasureSDK/CrashReporter/CrashScreenshotSaver.swift @@ -0,0 +1,51 @@ +// +// CrashScreenshotSaver.swift +// MeasureSDK +// +// Created by Adwin Ross on 28/01/25. +// + +import Foundation + +protocol CrashScreenshotSaver { + func onViewDidAppearCalled() + func enable() +} + +final class BaseCrashScreenshotSaver: CrashScreenshotSaver { + private let logger: Logger + private var lastUpdateTime: Date? + private let screenshotGenerator: ScreenshotGenerator + private let systemFileManager: SystemFileManager + private var isEnabled = false + + init(logger: Logger, screenshotGenerator: ScreenshotGenerator, systemFileManager: SystemFileManager) { + self.logger = logger + self.screenshotGenerator = screenshotGenerator + self.systemFileManager = systemFileManager + } + + func enable() { + isEnabled = true + } + + func onViewDidAppearCalled() { + guard isEnabled else { return } + + let now = Date() + + if let lastUpdateTime = self.lastUpdateTime, now.timeIntervalSince(lastUpdateTime) < 1.0 { + return + } + + self.lastUpdateTime = now + + guard let screenshot = screenshotGenerator.generate(), let screenshotData = screenshot.pngData() else { + return + } + + if let filepath = systemFileManager.saveFile(data: screenshotData, name: crashScreenshotName, folderName: crashScreenshotDirectoryName, directory: .documentDirectory) { + logger.log(level: .debug, message: "Screenshot saved at \(filepath).", error: nil, data: nil) + } + } +} diff --git a/ios/MeasureSDK/Events/Attachment.swift b/ios/MeasureSDK/Events/Attachment.swift index 2cc4f9f32..f6531514c 100644 --- a/ios/MeasureSDK/Events/Attachment.swift +++ b/ios/MeasureSDK/Events/Attachment.swift @@ -20,8 +20,16 @@ struct Attachment: Codable { /// An optional path to the attachment (not encoded). var path: String? + /// Size of the attachment in bytes + var size: Int64 + + /// A unique id for the image + var id: String + init(name: String, type: AttachmentType, + size: Int64, + id: String, bytes: Data? = nil, path: String? = nil) { precondition(bytes != nil || path != nil, "Failed to create Attachment. Either bytes or path must be provided") @@ -31,5 +39,7 @@ struct Attachment: Codable { self.type = type self.bytes = bytes self.path = path + self.size = size + self.id = id } } diff --git a/ios/MeasureSDK/Events/AttachmentProcessor.swift b/ios/MeasureSDK/Events/AttachmentProcessor.swift new file mode 100644 index 000000000..d4044f934 --- /dev/null +++ b/ios/MeasureSDK/Events/AttachmentProcessor.swift @@ -0,0 +1,57 @@ +// +// AttachmentProcessor.swift +// MeasureSDK +// +// Created by Adwin Ross on 17/01/25. +// + +import Foundation + +enum AttachmentStorageType { + case data + case fileStorage +} + +protocol AttachmentProcessor { + func getAttachmentObject(for image: UIImage, name: String, storageType: AttachmentStorageType, attachmentType: AttachmentType, compressionRatio: CGFloat) -> Attachment? +} + +final class BaseAttachmentProcessor: AttachmentProcessor { + private let fileManager: SystemFileManager + private let logger: Logger + private let idProvider: IdProvider + + init(logger: Logger, fileManager: SystemFileManager, idProvider: IdProvider) { + self.fileManager = fileManager + self.logger = logger + self.idProvider = idProvider + } + + func getAttachmentObject(for image: UIImage, + name: String, + storageType: AttachmentStorageType, + attachmentType: AttachmentType, + compressionRatio: CGFloat) -> Attachment? { + guard compressionRatio >= 0, compressionRatio <= 1 else { + logger.internalLog(level: .error, message: "Invalid compression ratio: \(compressionRatio). Must be between 0 and 1.", error: nil, data: nil) + return nil + } + + guard let imageData = image.jpegData(compressionQuality: compressionRatio) else { + logger.internalLog(level: .error, message: "Failed to compress image with ratio: \(compressionRatio).", error: nil, data: nil) + return nil + } + + let uuid = idProvider.createId() + switch storageType { + case .data: + return Attachment(name: name, type: attachmentType, size: Int64(imageData.count), id: uuid, bytes: imageData, path: nil) + case .fileStorage: + guard let fileURL = fileManager.saveFile(data: imageData, name: name, folderName: "attachments", directory: .documentDirectory) else { + logger.internalLog(level: .error, message: "Failed to save compressed image to file storage.", error: nil, data: nil) + return nil + } + return Attachment(name: name, type: attachmentType, size: Int64(imageData.count), id: uuid, bytes: nil, path: fileURL.path) + } + } +} diff --git a/ios/MeasureSDK/Events/AttachmentType.swift b/ios/MeasureSDK/Events/AttachmentType.swift index a19988534..6482640e4 100644 --- a/ios/MeasureSDK/Events/AttachmentType.swift +++ b/ios/MeasureSDK/Events/AttachmentType.swift @@ -9,4 +9,5 @@ import Foundation enum AttachmentType: String, Codable { case screenshot + case layoutSnapshot } diff --git a/ios/MeasureSDK/Events/EventProcessor.swift b/ios/MeasureSDK/Events/EventProcessor.swift index 18d137954..26b8fd36b 100644 --- a/ios/MeasureSDK/Events/EventProcessor.swift +++ b/ios/MeasureSDK/Events/EventProcessor.swift @@ -41,17 +41,19 @@ final class BaseEventProcessor: EventProcessor { private let timeProvider: TimeProvider private var crashDataPersistence: CrashDataPersistence private let eventStore: EventStore + private let attachmentProcessor: AttachmentProcessor + private let screenshotGenerator: ScreenshotGenerator - init( - logger: Logger, - idProvider: IdProvider, - sessionManager: SessionManager, - attributeProcessors: [AttributeProcessor], - configProvider: ConfigProvider, - timeProvider: TimeProvider, - crashDataPersistence: CrashDataPersistence, - eventStore: EventStore - ) { + init(logger: Logger, + idProvider: IdProvider, + sessionManager: SessionManager, + attributeProcessors: [AttributeProcessor], + configProvider: ConfigProvider, + timeProvider: TimeProvider, + crashDataPersistence: CrashDataPersistence, + eventStore: EventStore, + attachmentProcessor: AttachmentProcessor, + screenshotGenerator: ScreenshotGenerator) { self.logger = logger self.idProvider = idProvider self.sessionManager = sessionManager @@ -60,6 +62,8 @@ final class BaseEventProcessor: EventProcessor { self.timeProvider = timeProvider self.crashDataPersistence = crashDataPersistence self.eventStore = eventStore + self.attachmentProcessor = attachmentProcessor + self.screenshotGenerator = screenshotGenerator } func track( // swiftlint:disable:this function_parameter_count @@ -134,7 +138,7 @@ final class BaseEventProcessor: EventProcessor { let eventEntity = EventEntity(event, needsReporting: needsReporting) eventStore.insertEvent(event: eventEntity) sessionManager.onEventTracked(eventEntity) - logger.log(level: .debug, message: "Event processed: \(type), \(event.id)", error: nil, data: data) + logger.log(level: .debug, message: "Event processed: \(type), \(event.id) \(attachments)", error: nil, data: data) } private func createEvent( // swiftlint:disable:this function_parameter_count @@ -162,4 +166,18 @@ final class BaseEventProcessor: EventProcessor { userDefinedAttributes: userDefinedAttributes ) } + + private func getCurrentScreenshot() -> Attachment? { + SignPost.trace(label: "msr-take-screenshot") { + if let screenshot = screenshotGenerator.generate() { + let attachment = self.attachmentProcessor.getAttachmentObject(for: screenshot, + name: screenshotName, + storageType: .fileStorage, + attachmentType: .screenshot, + compressionRatio: 0.2) + return attachment + } + return nil + } + } } diff --git a/ios/MeasureSDK/Exporter/EventSerializer.swift b/ios/MeasureSDK/Exporter/EventSerializer.swift index c43304268..8d399681c 100644 --- a/ios/MeasureSDK/Exporter/EventSerializer.swift +++ b/ios/MeasureSDK/Exporter/EventSerializer.swift @@ -219,7 +219,7 @@ struct EventSerializer { // swiftlint:disable:this type_body_length var result = "{" result += "\"binary_name\":\"\(escapeString(stackFrame.binaryName))\"," result += "\"binary_address\":\"\(escapeString(stackFrame.binaryAddress))\"," - result += "\"offset\":\"\(escapeString(stackFrame.offset))\"," + result += "\"offset\":\(escapeString(stackFrame.offset))," result += "\"frame_index\":\(stackFrame.frameIndex)," result += "\"symbol_address\":\"\(escapeString(stackFrame.symbolAddress))\"," result += "\"in_app\":\(stackFrame.inApp)" @@ -435,6 +435,29 @@ struct EventSerializer { // swiftlint:disable:this type_body_length return result } + private func getSerialisedAttachments(for event: EventEntity) -> String? { + if let attachmentData = event.attachments { + do { + let decodedAttachments = try JSONDecoder().decode([Attachment].self, from: attachmentData) + var result = "[" + for attachment in decodedAttachments { + result += "{" + result += "\"id\":\"\(attachment.id)\"," + result += "\"name\":\"blob-\(attachment.id)\"," + result += "\"type\":\"\(attachment.type.rawValue)\"" + result += "}," + } + result = String(result.dropLast()) + result += "]" + return result + } catch { + return nil + } + } else { + return nil + } + } + private func getSerialisedAttributes(for event: EventEntity) -> String? { let decodedAttributes: Attributes? if let attributeData = event.attributes { @@ -492,6 +515,9 @@ struct EventSerializer { // swiftlint:disable:this type_body_length if let userDefinedAttributes = event.userDefinedAttributes { result += "\"user_defined_attribute\":\(userDefinedAttributes)," } + if let attachments = getSerialisedAttachments(for: event) { + result += "\"attachments\":\(attachments)," + } result += "\"user_triggered\":\(event.userTriggered)" result += "}" return result diff --git a/ios/MeasureSDK/Exporter/HttpClient.swift b/ios/MeasureSDK/Exporter/HttpClient.swift index cf34d5ebe..aab8ea3cb 100644 --- a/ios/MeasureSDK/Exporter/HttpClient.swift +++ b/ios/MeasureSDK/Exporter/HttpClient.swift @@ -94,6 +94,11 @@ final class BaseHttpClient: HttpClient { body.append(Data("--\(boundary)\r\n".utf8)) body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8)) body.append(Data("\(value)\r\n".utf8)) + case let .fileData(name, filename, data): + body.append(Data("--\(boundary)\r\n".utf8)) + body.append(Data("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n\r\n".utf8)) + body.append(data) + body.append(Data("\r\n".utf8)) } } body.append(Data("--\(boundary)--\r\n".utf8)) diff --git a/ios/MeasureSDK/Exporter/HttpModels.swift b/ios/MeasureSDK/Exporter/HttpModels.swift index ce3a282f4..06df919e5 100644 --- a/ios/MeasureSDK/Exporter/HttpModels.swift +++ b/ios/MeasureSDK/Exporter/HttpModels.swift @@ -9,6 +9,7 @@ import Foundation enum MultipartData { case formField(name: String, value: String) + case fileData(name: String, filename: String, data: Data) } enum HttpResponse { diff --git a/ios/MeasureSDK/Exporter/NetworkClient.swift b/ios/MeasureSDK/Exporter/NetworkClient.swift index 0aebadb24..82a0f25be 100644 --- a/ios/MeasureSDK/Exporter/NetworkClient.swift +++ b/ios/MeasureSDK/Exporter/NetworkClient.swift @@ -16,16 +16,30 @@ final class BaseNetworkClient: NetworkClient { private let apiKey: String private let httpClient: HttpClient private let eventSerializer: EventSerializer + private let systemFileManager: SystemFileManager - init(client: Client, httpClient: HttpClient, eventSerializer: EventSerializer) { + init(client: Client, httpClient: HttpClient, eventSerializer: EventSerializer, systemFileManager: SystemFileManager) { self.baseUrl = client.apiUrl self.apiKey = client.apiKey self.httpClient = httpClient self.eventSerializer = eventSerializer + self.systemFileManager = systemFileManager } func execute(batchId: String, events: [EventEntity]) -> HttpResponse { - let multipartData: [MultipartData] = events.compactMap { eventSerializer.getSerialisedEvent(for: $0) }.map { .formField(name: formFieldEvent, value: $0) } + var multipartData = [MultipartData]() + for event in events { + if let serialisedEvent = eventSerializer.getSerialisedEvent(for: event) { + multipartData.append(.formField(name: formFieldEvent, value: serialisedEvent)) + } + if let attachments = event.getAttachments() { + for attachment in attachments { + if let bytes = attachment.bytes { + multipartData.append(.fileData(name: "blob-\(attachment.id)", filename: attachment.id, data: bytes)) + } + } + } + } return httpClient.sendMultipartRequest(url: baseUrl.appendingPathComponent(eventsEndpoint), method: .put, diff --git a/ios/MeasureSDK/Lifecycle/LifecycleCollector.swift b/ios/MeasureSDK/Lifecycle/LifecycleCollector.swift index 4d9200b56..57054a27b 100644 --- a/ios/MeasureSDK/Lifecycle/LifecycleCollector.swift +++ b/ios/MeasureSDK/Lifecycle/LifecycleCollector.swift @@ -22,11 +22,13 @@ class BaseLifecycleCollector: LifecycleCollector { private let eventProcessor: EventProcessor private let timeProvider: TimeProvider private let logger: Logger + private let crashScreenshotSaver: CrashScreenshotSaver - init(eventProcessor: EventProcessor, timeProvider: TimeProvider, logger: Logger) { + init(eventProcessor: EventProcessor, timeProvider: TimeProvider, logger: Logger, crashScreenshotSaver: CrashScreenshotSaver) { self.eventProcessor = eventProcessor self.timeProvider = timeProvider self.logger = logger + self.crashScreenshotSaver = crashScreenshotSaver } func enable() { @@ -65,6 +67,11 @@ class BaseLifecycleCollector: LifecycleCollector { } func processControllerLifecycleEvent(_ vcLifecycleType: VCLifecycleEventType, for viewController: UIViewController) { + // Track screenshot for every screen when viewDidAppear is called + if vcLifecycleType == .viewDidAppear { + crashScreenshotSaver.onViewDidAppearCalled() + } + let className = String(describing: type(of: viewController)) // Define the list of excluded class names diff --git a/ios/MeasureSDK/MeasureInitializer.swift b/ios/MeasureSDK/MeasureInitializer.swift index c22c1ba48..c3f76182f 100644 --- a/ios/MeasureSDK/MeasureInitializer.swift +++ b/ios/MeasureSDK/MeasureInitializer.swift @@ -52,6 +52,9 @@ protocol MeasureInitializer { var customEventCollector: CustomEventCollector { get } var userTriggeredEventCollector: UserTriggeredEventCollector { get } var dataCleanupService: DataCleanupService { get } + var attachmentProcessor: AttachmentProcessor { get } + var crashScreenshotSaver: CrashScreenshotSaver { get } + var screenshotGenerator: ScreenshotGenerator { get } } /// `BaseMeasureInitializer` is responsible for setting up the internal configuration @@ -98,6 +101,9 @@ protocol MeasureInitializer { /// - `batchStore`: `BatchStore` object that manages `Batch` related operations /// - `batchCreator`: `BatchCreator` object used to create a batch. /// - `dataCleanupService`: `DataCleanupService` object responsible for clearing stale data +/// - `attachmentProcessor`: `AttachmentProcessor` object responsible for generating and managing screenshots. +/// - `screenshotGenerator`: `ScreenshotGenerator` object responsible for generating screenshots. +/// - `crashScreenshotSaver`: `CrashScreenshotSaver` object responsible for saving crash related screenshots. /// final class BaseMeasureInitializer: MeasureInitializer { let configProvider: ConfigProvider @@ -142,6 +148,9 @@ final class BaseMeasureInitializer: MeasureInitializer { let customEventCollector: CustomEventCollector let userTriggeredEventCollector: UserTriggeredEventCollector let dataCleanupService: DataCleanupService + let attachmentProcessor: AttachmentProcessor + let crashScreenshotSaver: CrashScreenshotSaver + let screenshotGenerator: ScreenshotGenerator init(config: MeasureConfig, // swiftlint:disable:this function_body_length client: Client) { @@ -183,6 +192,11 @@ final class BaseMeasureInitializer: MeasureInitializer { self.crashDataPersistence = BaseCrashDataPersistence(logger: logger, systemFileManager: systemFileManager) CrashDataWriter.shared.setCrashDataPersistence(crashDataPersistence) + self.attachmentProcessor = BaseAttachmentProcessor(logger: logger, + fileManager: systemFileManager, + idProvider: idProvider) + self.screenshotGenerator = BaseScreenshotGenerator(configProvider: configProvider, + logger: logger) self.eventProcessor = BaseEventProcessor(logger: logger, idProvider: idProvider, sessionManager: sessionManager, @@ -190,12 +204,16 @@ final class BaseMeasureInitializer: MeasureInitializer { configProvider: configProvider, timeProvider: timeProvider, crashDataPersistence: crashDataPersistence, - eventStore: eventStore) + eventStore: eventStore, + attachmentProcessor: attachmentProcessor, + screenshotGenerator: screenshotGenerator) self.systemCrashReporter = BaseSystemCrashReporter() self.crashReportManager = CrashReportingManager(logger: logger, eventProcessor: eventProcessor, crashDataPersistence: crashDataPersistence, - crashReporter: systemCrashReporter) + crashReporter: systemCrashReporter, + systemFileManager: systemFileManager, + idProvider: idProvider) self.gestureTargetFinder = BaseGestureTargetFinder() self.gestureCollector = BaseGestureCollector(logger: logger, eventProcessor: eventProcessor, @@ -205,7 +223,8 @@ final class BaseMeasureInitializer: MeasureInitializer { self.httpClient = BaseHttpClient(logger: logger, configProvider: configProvider) self.networkClient = BaseNetworkClient(client: client, httpClient: httpClient, - eventSerializer: EventSerializer()) + eventSerializer: EventSerializer(), + systemFileManager: systemFileManager) self.heartbeat = BaseHeartbeat() self.batchStore = BaseBatchStore(coreDataManager: coreDataManager, logger: logger) @@ -226,9 +245,13 @@ final class BaseMeasureInitializer: MeasureInitializer { heartbeat: heartbeat, eventExporter: eventExporter, dispatchQueue: MeasureQueue.periodicEventExporter) + self.crashScreenshotSaver = BaseCrashScreenshotSaver(logger: logger, + screenshotGenerator: screenshotGenerator, + systemFileManager: systemFileManager) self.lifecycleCollector = BaseLifecycleCollector(eventProcessor: eventProcessor, timeProvider: timeProvider, - logger: logger) + logger: logger, + crashScreenshotSaver: crashScreenshotSaver) self.cpuUsageCalculator = BaseCpuUsageCalculator() self.memoryUsageCalculator = BaseMemoryUsageCalculator() self.sysCtl = BaseSysCtl() diff --git a/ios/MeasureSDK/MeasureInternal.swift b/ios/MeasureSDK/MeasureInternal.swift index bab58187c..79b9d84d4 100644 --- a/ios/MeasureSDK/MeasureInternal.swift +++ b/ios/MeasureSDK/MeasureInternal.swift @@ -118,6 +118,12 @@ final class MeasureInternal { var dataCleanupService: DataCleanupService { return measureInitializer.dataCleanupService } + var attachmentProcessor: AttachmentProcessor { + return measureInitializer.attachmentProcessor + } + var crashScreenshotSaver: CrashScreenshotSaver { + return measureInitializer.crashScreenshotSaver + } private let lifecycleObserver: LifecycleObserver init(_ measureInitializer: MeasureInitializer) { @@ -143,6 +149,7 @@ final class MeasureInternal { self.periodicEventExporter.start() self.httpEventCollector.enable() self.networkChangeCollector.enable() + self.crashScreenshotSaver.enable() DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { if let window = UIApplication.shared.windows.first { self.gestureCollector.enable(for: window) diff --git a/ios/MeasureSDK/Utils/Constants.swift b/ios/MeasureSDK/Utils/Constants.swift index f8c3a70e9..b7c3c5f0c 100644 --- a/ios/MeasureSDK/Utils/Constants.swift +++ b/ios/MeasureSDK/Utils/Constants.swift @@ -36,6 +36,9 @@ let recentSessionVersionCodeKey = "recent_session_version_code" let recentLaunchAppVersion = "recent_launch_app_version" let recentLaunchTimeSinceLastBoot = "recent_launch_time_since_last_boot" let networkInterceptorHandledKey = "NetworkInterceptorHandled" +let crashScreenshotName = "crashScreenshot.png" +let crashScreenshotDirectoryName = "crashScreenshot" +let screenshotName = "screenshot.png" struct AttributeConstants { static let deviceManufacturer = "Apple" diff --git a/ios/MeasureSDK/Utils/Extensions/UIColor+Extension.swift b/ios/MeasureSDK/Utils/Extensions/UIColor+Extension.swift new file mode 100644 index 000000000..424bed1b3 --- /dev/null +++ b/ios/MeasureSDK/Utils/Extensions/UIColor+Extension.swift @@ -0,0 +1,37 @@ +// +// UIColor+Extension.swift +// MeasureSDK +// +// Created by Adwin Ross on 29/01/25. +// + +import UIKit + +extension UIColor { + convenience init?(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + var rgb: UInt64 = 0 + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil } + + let length = hexSanitized.count + let r, g, b, a: CGFloat + + if length == 6 { + r = CGFloat((rgb >> 16) & 0xFF) / 255.0 + g = CGFloat((rgb >> 8) & 0xFF) / 255.0 + b = CGFloat(rgb & 0xFF) / 255.0 + a = 1.0 + } else if length == 8 { + r = CGFloat((rgb >> 24) & 0xFF) / 255.0 + g = CGFloat((rgb >> 16) & 0xFF) / 255.0 + b = CGFloat((rgb >> 8) & 0xFF) / 255.0 + a = CGFloat(rgb & 0xFF) / 255.0 + } else { + return nil + } + + self.init(red: r, green: g, blue: b, alpha: a) + } +} diff --git a/ios/MeasureSDK/Utils/ScreenshotGenerator.swift b/ios/MeasureSDK/Utils/ScreenshotGenerator.swift new file mode 100644 index 000000000..084defcf9 --- /dev/null +++ b/ios/MeasureSDK/Utils/ScreenshotGenerator.swift @@ -0,0 +1,82 @@ +// +// ScreenshotGenerator.swift +// MeasureSDK +// +// Created by Adwin Ross on 16/01/25. +// + +import UIKit + +protocol ScreenshotGenerator { + func generate() -> UIImage? +} + +final class BaseScreenshotGenerator: ScreenshotGenerator { + private let configProvider: ConfigProvider + private let maskColor: UIColor + private let logger: Logger + + init(configProvider: ConfigProvider, logger: Logger) { + self.configProvider = configProvider + self.maskColor = UIColor(hex: configProvider.screenshotMaskHexColor) ?? .black + self.logger = logger + } + + func generate() -> UIImage? { + guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { + logger.log(level: .debug, message: "ScreenshotGenerator: No key window found.", error: nil, data: nil) + return nil + } + + let sensitiveFrames = findSensitiveFrames(in: window, rootView: window, types: [UITextView.self, UILabel.self, UIImageView.self]) + + let renderer = UIGraphicsImageRenderer(bounds: window.bounds) + let screenshot = renderer.image { context in + window.layer.render(in: context.cgContext) + } + + guard let redactedImage = redactScreenshot(screenshot, sensitiveFrames: sensitiveFrames, maskColor: self.maskColor) else { + return nil + } + + guard let compressedData = redactedImage.jpegData(compressionQuality: configProvider.screenshotCompressionQuality), + let compressedImage = UIImage(data: compressedData) else { + logger.log(level: .debug, message: "ScreenshotGenerator: Failed to compress image.", error: nil, data: nil) + return nil + } + + return compressedImage + } + + + private func findSensitiveFrames(in view: UIView, rootView: UIView, types: [UIView.Type]) -> [CGRect] { + var sensitiveFrames: [CGRect] = [] + + // Check if the current view is of a sensitive type + if types.contains(where: { view.isKind(of: $0) }) { + let frameInRootView = view.convert(view.bounds, to: rootView) + sensitiveFrames.append(frameInRootView) + } + + // Recursively search subviews + for subview in view.subviews { + sensitiveFrames.append(contentsOf: findSensitiveFrames(in: subview, rootView: rootView, types: types)) + } + + return sensitiveFrames + } + + private func redactScreenshot(_ screenshot: UIImage, sensitiveFrames: [CGRect], maskColor: UIColor) -> UIImage? { + let renderer = UIGraphicsImageRenderer(size: screenshot.size) + return renderer.image { context in + // Draw the original screenshot + screenshot.draw(at: .zero) + maskColor.setFill() + + // Draw black boxes over sensitive areas + for frame in sensitiveFrames { + context.cgContext.fill(frame) + } + } + } +} diff --git a/ios/MeasureSDK/Utils/SystemFileManager.swift b/ios/MeasureSDK/Utils/SystemFileManager.swift index 1525cd1fb..f030051a6 100644 --- a/ios/MeasureSDK/Utils/SystemFileManager.swift +++ b/ios/MeasureSDK/Utils/SystemFileManager.swift @@ -10,6 +10,8 @@ import Foundation /// A protocol that defines file system related operations. protocol SystemFileManager { func getCrashFilePath() -> URL? + func saveFile(data: Data, name: String, folderName: String?, directory: FileManager.SearchPathDirectory) -> URL? + func retrieveFile(name: String, folderName: String?, directory: FileManager.SearchPathDirectory) -> Data? } final class BaseSystemFileManager: SystemFileManager { @@ -40,4 +42,61 @@ final class BaseSystemFileManager: SystemFileManager { } return crashReportDirectory.appendingPathComponent(crashDataFileName) } + + func saveFile(data: Data, name: String, folderName: String?, directory: FileManager.SearchPathDirectory) -> URL? { + guard let directoryURL = fileManager.urls(for: directory, in: .userDomainMask).first else { + logger.internalLog(level: .error, message: "Unable to access directory \(directory)", error: nil, data: nil) + return nil + } + + // Append the folder name if provided + let folderURL = folderName != nil ? directoryURL.appendingPathComponent(folderName!) : directoryURL + + // Create the folder if it doesn't exist + if folderName != nil, !fileManager.fileExists(atPath: folderURL.path) { + do { + try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil) + } catch { + logger.internalLog(level: .error, message: "Failed to create folder \(folderName!) in directory \(directory)", error: error, data: nil) + return nil + } + } + + let fileURL = folderURL.appendingPathComponent(name) + + if fileManager.fileExists(atPath: fileURL.path) { + do { + try fileManager.removeItem(at: fileURL) + } catch { + logger.internalLog(level: .error, message: "Failed to remove existing file \(name) in folder \(folderName ?? "root")", error: error, data: nil) + return nil + } + } + + do { + try data.write(to: fileURL, options: .atomic) + return fileURL + } catch { + logger.internalLog(level: .error, message: "Failed to save file \(name) to folder \(folderName ?? "root") in directory \(directory)", error: error, data: nil) + return nil + } + } + + func retrieveFile(name: String, folderName: String?, directory: FileManager.SearchPathDirectory) -> Data? { + guard let directoryURL = fileManager.urls(for: directory, in: .userDomainMask).first else { + logger.internalLog(level: .error, message: "Unable to access directory \(directory)", error: nil, data: nil) + return nil + } + + let folderURL = folderName != nil ? directoryURL.appendingPathComponent(folderName!) : directoryURL + let fileURL = folderURL.appendingPathComponent(name) + + do { + let data = try Data(contentsOf: fileURL) + return data + } catch { + logger.internalLog(level: .error, message: "Failed to retrieve file \(name) from folder \(folderName ?? "root") in directory \(directory)", error: error, data: nil) + return nil + } + } }