From 7260f00b8f09f0250be8f8bea237112697eae560 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 9 Apr 2025 11:12:26 +0200 Subject: [PATCH 1/3] add analysis tools --- .../iOS-Swift/ErrorsViewController.swift | 2 + .../iOS-Swift/SentrySDKWrapper.swift | 45 +++++- .../Tools/DSNDisplayViewController.swift | 4 +- .../TransactionsViewController.swift | 18 ++- Sentry.xcodeproj/project.pbxproj | 22 ++- .../Sentry/SentrySessionReplayIntegration.m | 13 +- .../Sentry/include/SentryDisplayLinkWrapper.m | 1 + .../Preview/SentryMaskingPreviewView.swift | 2 +- .../SessionReplay/SentryOnDemandReplay.swift | 135 +++++++++++------- .../SessionReplay/SentryReplayOptions.swift | 15 +- .../SentryReplayVideoMaker.swift | 27 +++- .../SessionReplay/SentrySessionReplay.swift | 31 +++- .../SentryDefaultMaskRenderer.swift | 3 + .../SentryExperimentalMaskRenderer.swift | 2 +- .../ViewCapture/SentryViewPhotographer.swift | 6 +- .../SentryViewScreenshotProvider.swift | 2 +- .../Tools/ViewRedaction/RedactRegion.swift | 109 ++++++++++++++ .../ViewRedaction/RedactRegionType.swift | 19 +++ .../UIRedactBuilder.swift | 97 ++++++------- .../ViewRedaction/ViewHierarchyNode.swift | 85 +++++++++++ 20 files changed, 512 insertions(+), 126 deletions(-) create mode 100644 Sources/Swift/Tools/ViewRedaction/RedactRegion.swift create mode 100644 Sources/Swift/Tools/ViewRedaction/RedactRegionType.swift rename Sources/Swift/Tools/{ViewCapture => ViewRedaction}/UIRedactBuilder.swift (87%) create mode 100644 Sources/Swift/Tools/ViewRedaction/ViewHierarchyNode.swift diff --git a/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift b/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift index a83e6df57fd..802236ef7e2 100644 --- a/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift @@ -17,6 +17,8 @@ class ErrorsViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + print("--> ErrorsViewController.viewDidAppear") + SentrySDK.reportFullyDisplayed() if ProcessInfo.processInfo.arguments.contains("--io.sentry.feedback.inject-screenshot") { diff --git a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift index 1b7590fbef3..084e07cb176 100644 --- a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift +++ b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift @@ -19,8 +19,51 @@ struct SentrySDKWrapper { options.debug = true if #available(iOS 16.0, *), enableSessionReplay { - options.sessionReplay = SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true) + options.sessionReplay = SentryReplayOptions(sessionSampleRate: 0.0, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true) options.sessionReplay.quality = .high + options.sessionReplay.frameRate = 10 + try! FileManager.default.removeItem(atPath: "/tmp/workdir") + try! FileManager.default.createDirectory(atPath: "/tmp/workdir", withIntermediateDirectories: true, attributes: nil) + var previousEncodedViewData: Data? + var counter = 0 + options.sessionReplay.onNewFrame = { _, viewHiearchy, redactRegions, renderedViewImage, maskedViewImage in + guard TransactionsViewController.isTransitioning else { return } + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let encodedViewData = try encoder.encode(viewHiearchy) + if let previousEncodedViewData = previousEncodedViewData { + if encodedViewData == previousEncodedViewData { + return + } + } + previousEncodedViewData = encodedViewData + if counter >= 2 { + return + } + counter += 1 + + let viewHiearchyPath = "/tmp/workdir/\(counter)-0_view.json" + let regionsPath = "/tmp/workdir/\(counter)-1_regions.json" + let imagePath = "/tmp/workdir/\(counter)-2_image.png" + let maskedImagePath = "/tmp/workdir/\(counter)-3_masked.png" + + try encodedViewData.write(to: URL(fileURLWithPath: viewHiearchyPath)) + + let encodedRegionsData = try encoder.encode(redactRegions) + try encodedRegionsData.write(to: URL(fileURLWithPath: regionsPath)) + + let encodedImage = renderedViewImage.pngData() + try encodedImage?.write(to: URL(fileURLWithPath: imagePath)) + + let encodedMaskedImage = maskedViewImage.pngData() + try encodedMaskedImage?.write(to: URL(fileURLWithPath: maskedImagePath)) + + } catch { + print("Could not encode redact regions. Error: \(error)") + } + } } if #available(iOS 15.0, *), enableMetricKit { diff --git a/Samples/iOS-Swift/iOS-Swift/Tools/DSNDisplayViewController.swift b/Samples/iOS-Swift/iOS-Swift/Tools/DSNDisplayViewController.swift index adeec5511e5..38910f10c51 100644 --- a/Samples/iOS-Swift/iOS-Swift/Tools/DSNDisplayViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/Tools/DSNDisplayViewController.swift @@ -15,13 +15,13 @@ class DSNDisplayViewController: UIViewController { override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) - + if #available(iOS 13.0, *) { view.backgroundColor = .systemFill } else { view.backgroundColor = .lightGray.withAlphaComponent(0.5) } - + label.numberOfLines = 0 label.lineBreakMode = .byCharWrapping label.textAlignment = .center diff --git a/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift b/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift index dfb4a833fac..b49eb0e8d61 100644 --- a/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift @@ -2,29 +2,37 @@ import Sentry import UIKit class TransactionsViewController: UIViewController { - + static var isTransitioning = false @IBOutlet weak var appHangFullyBlockingButton: UIButton! private let dispatchQueue = DispatchQueue(label: "ViewController", attributes: .concurrent) private var timer: Timer? @IBOutlet weak var dsnView: UIView! - + override func viewDidLoad() { super.viewDidLoad() addDSNDisplay(self, vcview: dsnView) SentrySDK.reportFullyDisplayed() } - + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + print("--> TransactionsViewController.viewWillAppear") + TransactionsViewController.isTransitioning = true + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + print("--> TransactionsViewController.viewDidAppear") + TransactionsViewController.isTransitioning = false periodicallyDoWork() } - + override func viewDidDisappear(_ animated: Bool) { super .viewDidDisappear(animated) self.timer?.invalidate() } - + private func periodicallyDoWork() { self.timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 7b787fedbfb..08bef33fd18 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -815,6 +815,9 @@ A8F17B342902870300990B25 /* SentryHttpStatusCodeRange.m in Sources */ = {isa = PBXBuildFile; fileRef = A8F17B332902870300990B25 /* SentryHttpStatusCodeRange.m */; }; D4009EB22D771BC20007AF30 /* SentryFileIOTrackerSwiftHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4009EB12D771BB90007AF30 /* SentryFileIOTrackerSwiftHelpersTests.swift */; }; D42E48572D48DF1600D251BC /* SentryBuildAppStartSpansTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42E48562D48DF1600D251BC /* SentryBuildAppStartSpansTests.swift */; }; + D43546462D8C2B0B00E9C810 /* RedactRegionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43546452D8C2B0A00E9C810 /* RedactRegionType.swift */; }; + D43546482D8C2B2300E9C810 /* RedactRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43546472D8C2B2300E9C810 /* RedactRegion.swift */; }; + D435464A2D8C32A500E9C810 /* ViewHierarchyNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43546492D8C32A400E9C810 /* ViewHierarchyNode.swift */; }; D43647F12D5CFB71001468E0 /* SentrySpanKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43647F02D5CFB71001468E0 /* SentrySpanKeyTests.swift */; }; D43647F32D5CFBC7001468E0 /* FileManagerTracingIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43647F22D5CFBC2001468E0 /* FileManagerTracingIntegrationTests.swift */; }; D43B26D62D70964C007747FD /* SentrySpanOperation.m in Sources */ = {isa = PBXBuildFile; fileRef = D43B26D52D709648007747FD /* SentrySpanOperation.m */; }; @@ -1974,6 +1977,9 @@ D41909942D490006002B83D0 /* SentryNSDictionarySanitize+Tests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SentryNSDictionarySanitize+Tests.m"; sourceTree = ""; }; D42E48562D48DF1600D251BC /* SentryBuildAppStartSpansTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryBuildAppStartSpansTests.swift; sourceTree = ""; }; D42E48582D48FC8F00D251BC /* SentryNSDictionarySanitizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryNSDictionarySanitizeTests.swift; sourceTree = ""; }; + D43546452D8C2B0A00E9C810 /* RedactRegionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactRegionType.swift; sourceTree = ""; }; + D43546472D8C2B2300E9C810 /* RedactRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactRegion.swift; sourceTree = ""; }; + D43546492D8C32A400E9C810 /* ViewHierarchyNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewHierarchyNode.swift; sourceTree = ""; }; D43647F02D5CFB71001468E0 /* SentrySpanKeyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SentrySpanKeyTests.swift; sourceTree = ""; }; D43647F22D5CFBC2001468E0 /* FileManagerTracingIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerTracingIntegrationTests.swift; sourceTree = ""; }; D43B26D52D709648007747FD /* SentrySpanOperation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentrySpanOperation.m; sourceTree = ""; }; @@ -3857,6 +3863,17 @@ path = ViewCapture; sourceTree = ""; }; + D43546442D8C2AFD00E9C810 /* ViewRedaction */ = { + isa = PBXGroup; + children = ( + D43546492D8C32A400E9C810 /* ViewHierarchyNode.swift */, + D43546472D8C2B2300E9C810 /* RedactRegion.swift */, + D43546452D8C2B0A00E9C810 /* RedactRegionType.swift */, + D8AFC0562BDA895400118BE1 /* UIRedactBuilder.swift */, + ); + path = ViewRedaction; + sourceTree = ""; + }; D468C0602D36699700964230 /* IO */ = { isa = PBXGroup; children = ( @@ -3913,7 +3930,6 @@ D8CAC0722BA4473000E38F34 /* SentryViewPhotographer.swift */, D4E829D52D75E39900D375AD /* SentryViewRenderer.swift */, D8AFC0192BD7A20B00118BE1 /* SentryViewScreenshotProvider.swift */, - D8AFC0562BDA895400118BE1 /* UIRedactBuilder.swift */, ); path = ViewCapture; sourceTree = ""; @@ -4071,6 +4087,7 @@ D856272A2A374A6800FB8062 /* Tools */ = { isa = PBXGroup; children = ( + D43546442D8C2AFD00E9C810 /* ViewRedaction */, D4E829DD2D75FCA200D375AD /* ViewCapture */, D856272B2A374A8600FB8062 /* UrlSanitized.swift */, D8292D7A2A38AF04009872F7 /* HTTPHeaderSanitizer.swift */, @@ -4996,6 +5013,7 @@ 7B63459F280EBA7200CFA05A /* SentryUIEventTracker.m in Sources */, 7BF9EF782722B35D00B5BBEF /* SentrySubClassFinder.m in Sources */, D80CD8D32B751447002F710B /* SentryMXCallStackTree.swift in Sources */, + D435464A2D8C32A500E9C810 /* ViewHierarchyNode.swift in Sources */, 84CFA4CD2C9E0CA3008DA5F4 /* SentryUserFeedbackIntegration.m in Sources */, 7BCFA71627D0BB50008C662C /* SentryANRTrackerV1.m in Sources */, 8459FCC02BD73EB20038E9C9 /* SentryProfilerSerialization.mm in Sources */, @@ -5066,6 +5084,7 @@ 63FE712120DA4C1000CDBAE8 /* SentryCrashSymbolicator.c in Sources */, 627C77892D50B6840055E966 /* SentryBreadcrumbCodable.swift in Sources */, 63FE70D720DA4C1000CDBAE8 /* SentryCrashMonitor_MachException.c in Sources */, + D43546462D8C2B0B00E9C810 /* RedactRegionType.swift in Sources */, 7B96572226830D2400C66E25 /* SentryScopeSyncC.c in Sources */, 0A9BF4E228A114940068D266 /* SentryViewHierarchyIntegration.m in Sources */, 0ADC33EC28D9BB780078D980 /* SentryUIDeviceWrapper.m in Sources */, @@ -5172,6 +5191,7 @@ 7BD729982463E93500EA3610 /* SentryDateUtil.m in Sources */, 639FCF9D1EBC7F9500778193 /* SentryThread.m in Sources */, D88B30A92D48D8C3008DE513 /* SentryMaskingPreviewView.swift in Sources */, + D43546482D8C2B2300E9C810 /* RedactRegion.swift in Sources */, 849B8F992C6E906900148E1F /* SentryUserFeedbackFormConfiguration.swift in Sources */, 8E8C57A225EEFC07001CEEFA /* SentrySampling.m in Sources */, 8454CF8D293EAF9A006AC140 /* SentryMetricProfiler.mm in Sources */, diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index 0eb4de59798..2b8aad6096d 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -192,6 +192,7 @@ - (void)resumePreviousSessionReplay:(SentryEvent *)event [[SentryOnDemandReplay alloc] initWithContentFrom:lastReplayURL.path]; resumeReplayMaker.bitRate = _replayOptions.replayBitRate; resumeReplayMaker.videoScale = _replayOptions.sizeScale; + resumeReplayMaker.frameRate = _replayOptions.frameRate; NSDate *beginning = hasCrashInfo ? [NSDate dateWithTimeIntervalSinceReferenceDate:crashInfo.lastSegmentEnd] @@ -319,9 +320,15 @@ - (void)startWithOptions:(SentryReplayOptions *)replayOptions SentryOnDemandReplay *replayMaker = [[SentryOnDemandReplay alloc] initWithOutputPath:docs.path]; replayMaker.bitRate = replayOptions.replayBitRate; replayMaker.videoScale = replayOptions.sizeScale; - replayMaker.cacheMaxSize - = (NSInteger)(shouldReplayFullSession ? replayOptions.sessionSegmentDuration + 1 - : replayOptions.errorReplayDuration + 1); + replayMaker.frameRate = replayOptions.frameRate; + replayMaker.onNewFrame = replayOptions.onNewFrame; + + // The cache should be at least the amount of frames fitting into he session segment duration + // plus one frame to ensure that the last frame is not dropped. + NSInteger sessionSegmentDuration + = (NSInteger)(shouldReplayFullSession ? replayOptions.sessionSegmentDuration + : replayOptions.errorReplayDuration); + replayMaker.cacheMaxSize = (sessionSegmentDuration * replayOptions.frameRate) + 1; dispatch_queue_attr_t attributes = dispatch_queue_attr_make_with_qos_class( DISPATCH_QUEUE_SERIAL, DISPATCH_QUEUE_PRIORITY_LOW, 0); diff --git a/Sources/Sentry/include/SentryDisplayLinkWrapper.m b/Sources/Sentry/include/SentryDisplayLinkWrapper.m index 29eab1a8f6e..85a721924bb 100644 --- a/Sources/Sentry/include/SentryDisplayLinkWrapper.m +++ b/Sources/Sentry/include/SentryDisplayLinkWrapper.m @@ -21,6 +21,7 @@ - (CFTimeInterval)targetTimestamp API_AVAILABLE(ios(10.0), tvos(10.0)) - (void)linkWithTarget:(id)target selector:(SEL)sel { displayLink = [CADisplayLink displayLinkWithTarget:target selector:sel]; + displayLink.preferredFramesPerSecond = 10; [displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; } diff --git a/Sources/Swift/Integrations/SessionReplay/Preview/SentryMaskingPreviewView.swift b/Sources/Swift/Integrations/SessionReplay/Preview/SentryMaskingPreviewView.swift index 9757e531e0d..d90485aeb01 100644 --- a/Sources/Swift/Integrations/SessionReplay/Preview/SentryMaskingPreviewView.swift +++ b/Sources/Swift/Integrations/SessionReplay/Preview/SentryMaskingPreviewView.swift @@ -62,7 +62,7 @@ class SentryMaskingPreviewView: UIView { private func update() { guard let superview = self.superview, idle else { return } idle = false - self.photographer.image(view: superview) { image in + self.photographer.image(view: superview) { _, _, _, image in DispatchQueue.main.async { self.imageView.image = image self.idle = true diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 6017a98a5f9..07e106e4dac 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -21,34 +21,41 @@ enum SentryOnDemandReplayError: Error { @objcMembers class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { - + private let _outputPath: String private var _totalFrames = 0 private let dateProvider: SentryCurrentDateProvider private let workingQueue: SentryDispatchQueueWrapper private var _frames = [SentryReplayFrame]() - - #if SENTRY_TEST || SENTRY_TEST_CI || DEBUG + +#if SENTRY_TEST || SENTRY_TEST_CI || DEBUG //This is exposed only for tests, no need to make it thread safe. var frames: [SentryReplayFrame] { get { _frames } set { _frames = newValue } } - #endif // SENTRY_TEST || SENTRY_TEST_CI || DEBUG +#endif // SENTRY_TEST || SENTRY_TEST_CI || DEBUG var videoScale: Float = 1 var bitRate = 20_000 var frameRate = 1 var cacheMaxSize = UInt.max - + var onNewFrame: (( + _ timestamp: Date, + _ viewHiearchy: ViewHierarchyNode, + _ redactRegions: [RedactRegion], + _ renderedViewImage: UIImage, + _ maskedViewImage: UIImage + ) -> Void)? + init(outputPath: String, workingQueue: SentryDispatchQueueWrapper, dateProvider: SentryCurrentDateProvider) { self._outputPath = outputPath self.dateProvider = dateProvider self.workingQueue = workingQueue } - + convenience init(withContentFrom outputPath: String, workingQueue: SentryDispatchQueueWrapper, dateProvider: SentryCurrentDateProvider) { self.init(outputPath: outputPath, workingQueue: workingQueue, dateProvider: dateProvider) - + do { let content = try FileManager.default.contentsOfDirectory(atPath: outputPath) _frames = content.compactMap { @@ -57,111 +64,141 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { return SentryReplayFrame(imagePath: "\(outputPath)/\($0)", time: Date(timeIntervalSinceReferenceDate: time), screenName: nil) }.sorted { $0.time < $1.time } } catch { - SentryLog.debug("Could not list frames from replay: \(error.localizedDescription)") + SentryLog.debug("[SessionReplay] Could not list frames from replay: \(error.localizedDescription)") return } } - + convenience init(outputPath: String) { self.init(outputPath: outputPath, workingQueue: SentryDispatchQueueWrapper(name: "io.sentry.onDemandReplay", attributes: nil), dateProvider: SentryDefaultCurrentDateProvider()) } - + convenience init(withContentFrom outputPath: String) { self.init(withContentFrom: outputPath, workingQueue: SentryDispatchQueueWrapper(name: "io.sentry.onDemandReplay", attributes: nil), dateProvider: SentryDefaultCurrentDateProvider()) } - - func addFrameAsync(image: UIImage, forScreen: String?) { + + @objc func addFrameAsync( + timestamp: Date, + viewHiearchy: ViewHierarchyNode, + redactRegions: [RedactRegion], + renderedViewImage: UIImage, + maskedViewImage: UIImage, + forScreen screen: String? + ) { workingQueue.dispatchAsync({ - self.addFrame(image: image, forScreen: forScreen) + self.addFrame( + timestamp: timestamp, + viewHiearchy: viewHiearchy, + redactRegions: redactRegions, + renderedViewImage: renderedViewImage, + maskedViewImage: maskedViewImage, + forScreen: screen + ) }) } - - private func addFrame(image: UIImage, forScreen: String?) { - guard let data = rescaleImage(image)?.pngData() else { return } - - let date = dateProvider.date() - let imagePath = (_outputPath as NSString).appendingPathComponent("\(date.timeIntervalSinceReferenceDate).png") + + private func addFrame( + timestamp: Date, + viewHiearchy: ViewHierarchyNode, + redactRegions: [RedactRegion], + renderedViewImage: UIImage, + maskedViewImage: UIImage, + forScreen screen: String? + ) { + guard let data = rescaleImage(maskedViewImage)?.pngData() else { return } + onNewFrame?(timestamp, viewHiearchy, redactRegions, renderedViewImage, maskedViewImage) + + let imagePath = (_outputPath as NSString).appendingPathComponent("\(timestamp.timeIntervalSinceReferenceDate).png") do { try data.write(to: URL(fileURLWithPath: imagePath)) } catch { - SentryLog.debug("Could not save replay frame. Error: \(error)") + SentryLog.debug("[SessionReplay] Could not save replay frame. Error: \(error)") return } - _frames.append(SentryReplayFrame(imagePath: imagePath, time: date, screenName: forScreen)) - + _frames.append(SentryReplayFrame(imagePath: imagePath, time: timestamp, screenName: screen)) + + // Remove oldest frame from the cache and delete the file from disk if reaching the limit while _frames.count > cacheMaxSize { - let first = _frames.removeFirst() - try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) + do { + let first = _frames.removeFirst() + try FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) + } catch { + SentryLog.debug("[SessionReplay] Could not delete oldest session replay frame in cache. Error: \(error)") + } } _totalFrames += 1 } - + private func rescaleImage(_ originalImage: UIImage) -> UIImage? { guard originalImage.scale > 1 else { return originalImage } - + UIGraphicsBeginImageContextWithOptions(originalImage.size, false, 1) defer { UIGraphicsEndImageContext() } - + originalImage.draw(in: CGRect(origin: .zero, size: originalImage.size)) return UIGraphicsGetImageFromCurrentImageContext() } - + func releaseFramesUntil(_ date: Date) { + SentryLog.debug("[SessionReplay] Releasing frames until timestamp: \(date)") workingQueue.dispatchAsync ({ while let first = self._frames.first, first.time < date { + SentryLog.debug("Releasing frame: \(first.time), path: \(first.imagePath)") self._frames.removeFirst() try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) } }) } - + var oldestFrameDate: Date? { return _frames.first?.time } - + func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { + SentryLog.debug("[SessionReplay] Creating videos from: \(beginning), to: \(end)") let videoFrames = filterFrames(beginning: beginning, end: end) var frameCount = 0 - + var videos = [SentryVideoInfo]() - + while frameCount < videoFrames.count { let outputFileURL = URL(fileURLWithPath: _outputPath.appending("/\(videoFrames[frameCount].time.timeIntervalSinceReferenceDate).mp4")) if let videoInfo = try renderVideo(with: videoFrames, from: &frameCount, at: outputFileURL) { videos.append(videoInfo) } else { frameCount++ - } + } } + SentryLog.debug("[SessionReplay] Created \(videos.count) videos") return videos } - + private func renderVideo(with videoFrames: [SentryReplayFrame], from: inout Int, at outputFileURL: URL) throws -> SentryVideoInfo? { guard from < videoFrames.count, let image = UIImage(contentsOfFile: videoFrames[from].imagePath) else { return nil } let videoWidth = image.size.width * CGFloat(videoScale) let videoHeight = image.size.height * CGFloat(videoScale) - + let videoWriter = try AVAssetWriter(url: outputFileURL, fileType: .mp4) let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: createVideoSettings(width: videoWidth, height: videoHeight)) - + guard let currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: videoWidth, height: videoHeight), videoWriterInput: videoWriterInput) else { throw SentryOnDemandReplayError.cantCreatePixelBuffer } - + videoWriter.add(videoWriterInput) videoWriter.startWriting() videoWriter.startSession(atSourceTime: .zero) - + var lastImageSize: CGSize = image.size var usedFrames = [SentryReplayFrame]() let group = DispatchGroup() - + var result: Result? var frameCount = from - + group.enter() videoWriterInput.requestMediaDataWhenReady(on: workingQueue.queue) { guard videoWriter.status == .writing else { @@ -183,8 +220,10 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { return } lastImageSize = image.size - - let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(1 / self.frameRate)) + + // Calculate the time of the frame based on the frame rate + let timePerFrame = CMTimeMake(value: 1, timescale: Int32(self.frameRate)) + let presentTime = CMTimeMultiply(timePerFrame, multiplier: Int32(frameCount)) if currentPixelBuffer.append(image: image, presentationTime: presentTime) != true { videoWriter.cancelWriting() result = .failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo ) @@ -197,15 +236,15 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } guard group.wait(timeout: .now() + 2) == .success else { throw SentryOnDemandReplayError.errorRenderingVideo } from = frameCount - + return try result?.get() } - + private func finishVideo(outputFileURL: URL, usedFrames: [SentryReplayFrame], videoHeight: Int, videoWidth: Int, videoWriter: AVAssetWriter) -> Result { let group = DispatchGroup() var finishError: Error? var result: SentryVideoInfo? - + group.enter() videoWriter.inputs.forEach { $0.markAsFinished() } videoWriter.finishWriting { @@ -226,11 +265,11 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } } group.wait() - + if let finishError = finishError { return .failure(finishError) } return .success(result) } - + private func filterFrames(beginning: Date, end: Date) -> [SentryReplayFrame] { var frames = [SentryReplayFrame]() //Using dispatch queue as sync mechanism since we need a queue already to generate the video. @@ -239,7 +278,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { }) return frames } - + private func createVideoSettings(width: CGFloat, height: CGFloat) -> [String: Any] { return [ AVVideoCodecKey: AVVideoCodecType.h264, diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift index ba81e9aeadd..cae1d0fcf17 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift @@ -1,5 +1,8 @@ @_implementationOnly import _SentryPrivate import Foundation +#if canImport(UIKit) +import UIKit +#endif @objcMembers public class SentryReplayOptions: NSObject, SentryRedactOptions { @@ -140,6 +143,16 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions { */ public var enableFastViewRendering = false + #if canImport(UIKit) + public var onNewFrame: (( + _ timestamp: Date, + _ viewHiearchy: ViewHierarchyNode, + _ redactRegions: [RedactRegion], + _ renderedViewImage: UIImage, + _ maskedViewImage: UIImage + ) -> Void)? + #endif + /** * Defines the quality of the session replay. * Higher bit rates better quality, but also bigger files to transfer. @@ -160,7 +173,7 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions { * The more the havier the process is. * The minimum is 1, if set to zero this will change to 1. */ - var frameRate: UInt = 1 { + public var frameRate: UInt = 1 { didSet { if frameRate < 1 { frameRate = 1 } } diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift index 32831f38642..5a55ca732a7 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift @@ -4,14 +4,35 @@ import UIKit @objc protocol SentryReplayVideoMaker: NSObjectProtocol { - func addFrameAsync(image: UIImage, forScreen: String?) + func addFrameAsync( + timestamp: Date, + viewHiearchy: ViewHierarchyNode, + redactRegions: [RedactRegion], + renderedViewImage: UIImage, + maskedViewImage: UIImage, + forScreen screen: String? + ) func releaseFramesUntil(_ date: Date) func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] } extension SentryReplayVideoMaker { - func addFrameAsync(image: UIImage) { - self.addFrameAsync(image: image, forScreen: nil) + func addFrameAsync( + timestamp: Date, + viewHiearchy: ViewHierarchyNode, + redactRegions: [RedactRegion], + renderedViewImage: UIImage, + maskedViewImage: UIImage, + forScreen screen: String? + ) { + self.addFrameAsync( + timestamp: timestamp, + viewHiearchy: viewHiearchy, + redactRegions: redactRegions, + renderedViewImage: renderedViewImage, + maskedViewImage: maskedViewImage, + forScreen: screen + ) } } diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 49e856871ab..521fbf4b426 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -311,17 +311,38 @@ class SentrySessionReplay: NSObject { processingScreenshot = true lock.unlock() + let timestamp = dateProvider.date() let screenName = delegate?.currentScreenNameForSessionReplay() - - screenshotProvider.image(view: rootView) { [weak self] screenshot in - self?.newImage(image: screenshot, forScreen: screenName) + screenshotProvider.image(view: rootView) { [weak self] viewHiearchy, redactRegions, renderedViewImage, maskedViewImage in + self?.newImage( + timestamp: timestamp, + viewHiearchy: viewHiearchy, + redactRegions: redactRegions, + renderedViewImage: renderedViewImage, + maskedViewImage: maskedViewImage, + forScreen: screenName + ) } } - private func newImage(image: UIImage, forScreen screen: String?) { + private func newImage( + timestamp: Date, + viewHiearchy: ViewHierarchyNode, + redactRegions: [RedactRegion], + renderedViewImage: UIImage, + maskedViewImage: UIImage, + forScreen screen: String? + ) { lock.synchronized { processingScreenshot = false - replayMaker.addFrameAsync(image: image, forScreen: screen) + replayMaker.addFrameAsync( + timestamp: timestamp, + viewHiearchy: viewHiearchy, + redactRegions: redactRegions, + renderedViewImage: renderedViewImage, + maskedViewImage: maskedViewImage, + forScreen: screen + ) } } } diff --git a/Sources/Swift/Tools/ViewCapture/SentryDefaultMaskRenderer.swift b/Sources/Swift/Tools/ViewCapture/SentryDefaultMaskRenderer.swift index ba7f6ee085e..47d79bdb280 100644 --- a/Sources/Swift/Tools/ViewCapture/SentryDefaultMaskRenderer.swift +++ b/Sources/Swift/Tools/ViewCapture/SentryDefaultMaskRenderer.swift @@ -48,6 +48,9 @@ class SentryDefaultMaskRenderer: NSObject, SentryMaskRenderer { self.updateClipping(for: context.cgContext, clipPaths: clipPaths, clipOutPath: clipOutPath) + UIColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 0.5).setFill() + context.cgContext.addPath(path) + context.cgContext.fillPath() case .clipBegin: clipPaths.append(path) self.updateClipping(for: context.cgContext, diff --git a/Sources/Swift/Tools/ViewCapture/SentryExperimentalMaskRenderer.swift b/Sources/Swift/Tools/ViewCapture/SentryExperimentalMaskRenderer.swift index 2cf68171276..be4daf5c50e 100644 --- a/Sources/Swift/Tools/ViewCapture/SentryExperimentalMaskRenderer.swift +++ b/Sources/Swift/Tools/ViewCapture/SentryExperimentalMaskRenderer.swift @@ -7,7 +7,7 @@ class SentryExperimentalMaskRenderer: SentryDefaultMaskRenderer { override func maskScreenshot(screenshot image: UIImage, size: CGSize, masking: [RedactRegion]) -> UIImage { // The `SentryDefaultMaskRenderer` is also using an display scale of 1, therefore we also use 1 here. // This could be evaluated in future iterations to view performance impact vs quality. - let image = SentryGraphicsImageRenderer(size: size, scale: 1).image { context in + let image = SentryGraphicsImageRenderer(size: size, scale: 2).image { context in // The experimental mask renderer only uses a different graphics renderer and can reuse the default masking logic. applyMasking(to: context, image: image, size: size, masking: masking) } diff --git a/Sources/Swift/Tools/ViewCapture/SentryViewPhotographer.swift b/Sources/Swift/Tools/ViewCapture/SentryViewPhotographer.swift index 481078a32d0..ec6bfad94e6 100644 --- a/Sources/Swift/Tools/ViewCapture/SentryViewPhotographer.swift +++ b/Sources/Swift/Tools/ViewCapture/SentryViewPhotographer.swift @@ -35,7 +35,7 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { func image(view: UIView, onComplete: @escaping ScreenshotCallback) { let viewSize = view.bounds.size - let redact = redactBuilder.redactRegionsFor(view: view) + let (viewHiearchy, redact) = redactBuilder.redactRegionsFor(view: view) // The render method is synchronous and must be called on the main thread. // This is because the render method accesses the view hierarchy which is managed from the main thread. let renderedScreenshot = renderer.render(view: view) @@ -45,13 +45,13 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { // Moving it to a background thread to avoid blocking the main thread, therefore reducing the performance // impact/lag of the user interface. let maskedScreenshot = maskRenderer.maskScreenshot(screenshot: renderedScreenshot, size: viewSize, masking: redact) - onComplete(maskedScreenshot) + onComplete(viewHiearchy, redact, renderedScreenshot, maskedScreenshot) } } func image(view: UIView) -> UIImage { let viewSize = view.bounds.size - let redact = redactBuilder.redactRegionsFor(view: view) + let (_, redact) = redactBuilder.redactRegionsFor(view: view) let renderedScreenshot = renderer.render(view: view) let maskedScreenshot = maskRenderer.maskScreenshot(screenshot: renderedScreenshot, size: viewSize, masking: redact) diff --git a/Sources/Swift/Tools/ViewCapture/SentryViewScreenshotProvider.swift b/Sources/Swift/Tools/ViewCapture/SentryViewScreenshotProvider.swift index 0b99bc1bffb..ec544185606 100644 --- a/Sources/Swift/Tools/ViewCapture/SentryViewScreenshotProvider.swift +++ b/Sources/Swift/Tools/ViewCapture/SentryViewScreenshotProvider.swift @@ -3,7 +3,7 @@ import Foundation import UIKit -typealias ScreenshotCallback = (UIImage) -> Void +typealias ScreenshotCallback = (_ viewHierarchy: ViewHierarchyNode, _ redactRegions: [RedactRegion], _ renderedViewImage: UIImage, _ maskedViewImage: UIImage) -> Void @objc protocol SentryViewScreenshotProvider: NSObjectProtocol { diff --git a/Sources/Swift/Tools/ViewRedaction/RedactRegion.swift b/Sources/Swift/Tools/ViewRedaction/RedactRegion.swift new file mode 100644 index 00000000000..017bb540ac4 --- /dev/null +++ b/Sources/Swift/Tools/ViewRedaction/RedactRegion.swift @@ -0,0 +1,109 @@ +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) +import Foundation +import ObjectiveC.NSObjCRuntime +import UIKit + +@objc public class RedactRegion: NSObject, Encodable { + enum CodingKeys: CodingKey { + case size + case transform + case type + case color + case name + } + + public let size: CGSize + public let transform: CGAffineTransform + public let type: RedactRegionType + public let color: UIColor? + public let name: String + + init(size: CGSize, transform: CGAffineTransform, type: RedactRegionType, color: UIColor? = nil, name: String) { + self.size = size + self.transform = transform + self.type = type + self.color = color + self.name = name + super.init() + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(size, forKey: .size) + try container.encode(transform, forKey: .transform) + try container.encode(type, forKey: .type) + try container.encode(UIColorBox(color), forKey: .color) + try container.encode(name, forKey: .name) + } + + func canReplace(as other: RedactRegion) -> Bool { + size == other.size && transform == other.transform && type == other.type + } +} + +private struct UIColorBox: Codable { + let color: UIColor? + + init(_ color: UIColor?) { + self.color = color + } + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let cgColorBox = try container.decode(CGColorBox.self) + if let cgColor = cgColorBox.cgColor { + color = UIColor(cgColor: cgColor) + } else { + color = nil + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(CGColorBox(color?.cgColor)) + } +} + +private struct CGColorBox: Codable { + let cgColor: CGColor? + + init(_ cgColor: CGColor?) { + self.cgColor = cgColor + } + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let colorData = try container.decode(CGColorData.self) + + guard let colorSpace = CGColorSpace(name: colorData.colorSpaceName as CFString), + let cgColor = CGColor(colorSpace: colorSpace, components: colorData.components) else { + self.cgColor = nil + return + } + + self.cgColor = cgColor + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + + guard let cgColor = cgColor, + let colorSpaceName = cgColor.colorSpace?.name as String?, + let components = cgColor.components else { + try container.encodeNil() + return + } + + let colorData = CGColorData(components: components, colorSpaceName: colorSpaceName) + try container.encode(colorData) + } +} + +private struct CGColorData: Codable { + let components: [CGFloat] + let colorSpaceName: String +} + +#endif +#endif diff --git a/Sources/Swift/Tools/ViewRedaction/RedactRegionType.swift b/Sources/Swift/Tools/ViewRedaction/RedactRegionType.swift new file mode 100644 index 00000000000..100dcf7076e --- /dev/null +++ b/Sources/Swift/Tools/ViewRedaction/RedactRegionType.swift @@ -0,0 +1,19 @@ +public enum RedactRegionType: String, Codable { + /// Redacts the region. + case redact = "redact" + + /// Marks a region to not draw anything. + /// This is used for opaque views. + case clipOut = "clip_out" + + /// Push a clip region to the drawing context. + /// This is used for views that clip to its bounds. + case clipBegin = "clip_begin" + + /// Pop the last Pushed region from the drawing context. + /// Used after prossing every child of a view that clip to its bounds. + case clipEnd = "clip_end" + + /// These regions are redacted first, there is no way to avoid it. + case redactSwiftUI = "redact_swiftui" +} diff --git a/Sources/Swift/Tools/ViewCapture/UIRedactBuilder.swift b/Sources/Swift/Tools/ViewRedaction/UIRedactBuilder.swift similarity index 87% rename from Sources/Swift/Tools/ViewCapture/UIRedactBuilder.swift rename to Sources/Swift/Tools/ViewRedaction/UIRedactBuilder.swift index 3db19ce67b8..f6313edc12e 100644 --- a/Sources/Swift/Tools/ViewCapture/UIRedactBuilder.swift +++ b/Sources/Swift/Tools/ViewRedaction/UIRedactBuilder.swift @@ -7,44 +7,6 @@ import UIKit import WebKit #endif -enum RedactRegionType { - /// Redacts the region. - case redact - - /// Marks a region to not draw anything. - /// This is used for opaque views. - case clipOut - - /// Push a clip region to the drawing context. - /// This is used for views that clip to its bounds. - case clipBegin - - /// Pop the last Pushed region from the drawing context. - /// Used after prossing every child of a view that clip to its bounds. - case clipEnd - - /// These regions are redacted first, there is no way to avoid it. - case redactSwiftUI -} - -struct RedactRegion { - let size: CGSize - let transform: CGAffineTransform - let type: RedactRegionType - let color: UIColor? - - init(size: CGSize, transform: CGAffineTransform, type: RedactRegionType, color: UIColor? = nil) { - self.size = size - self.transform = transform - self.type = type - self.color = color - } - - func canReplace(as other: RedactRegion) -> Bool { - size == other.size && transform == other.transform && type == other.type - } -} - class UIRedactBuilder { ///This is a wrapper which marks it's direct children to be ignored private var ignoreContainerClassIdentifier: ObjectIdentifier? @@ -178,10 +140,10 @@ class UIRedactBuilder { This function returns the redaction regions in reverse order from what was found in the view hierarchy, allowing the processing of regions from top to bottom. This ensures that clip regions are applied first before drawing a redact mask on lower views. */ - func redactRegionsFor(view: UIView) -> [RedactRegion] { + func redactRegionsFor(view: UIView) -> (ViewHierarchyNode, [RedactRegion]) { var redactingRegions = [RedactRegion]() - self.mapRedactRegion(fromLayer: view.layer.presentation() ?? view.layer, + let node = self.mapRedactRegion(fromLayer: view.layer.presentation() ?? view.layer, relativeTo: nil, redacting: &redactingRegions, rootFrame: view.frame, @@ -199,7 +161,7 @@ class UIRedactBuilder { } //The swiftUI type needs to appear first in the list so it always get masked - return (otherRegions + swiftUIRedact).reversed() + return (node, (otherRegions + swiftUIRedact).reversed()) } private func shouldIgnore(view: UIView) -> Bool { @@ -238,9 +200,11 @@ class UIRedactBuilder { return image.imageAsset?.value(forKey: "_containingBundle") == nil } - private func mapRedactRegion(fromLayer layer: CALayer, relativeTo parentLayer: CALayer?, redacting: inout [RedactRegion], rootFrame: CGRect, transform: CGAffineTransform, forceRedact: Bool = false) { - guard !redactClassesIdentifiers.isEmpty && !layer.isHidden && layer.opacity != 0, let view = layer.delegate as? UIView else { return } - + private func mapRedactRegion(fromLayer layer: CALayer, relativeTo parentLayer: CALayer?, redacting: inout [RedactRegion], rootFrame: CGRect, transform: CGAffineTransform, forceRedact: Bool = false) -> ViewHierarchyNode { + let node = ViewHierarchyNode(layer: layer) + guard !redactClassesIdentifiers.isEmpty && !layer.isHidden && layer.opacity != 0, let view = layer.delegate as? UIView else { + return node + } let newTransform = concatenateTranform(transform, from: layer, withParent: parentLayer) let ignore = !forceRedact && shouldIgnore(view: view) @@ -249,9 +213,17 @@ class UIRedactBuilder { var enforceRedact = forceRedact if !ignore && redact { - redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: swiftUI ? .redactSwiftUI : .redact, color: self.color(for: view))) + redacting.append(RedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: swiftUI ? .redactSwiftUI : .redact, + color: self.color(for: view), + name: layer.debugDescription + )) - guard !view.clipsToBounds else { return } + guard !view.clipsToBounds else { + return node + } enforceRedact = true } else if isOpaque(view) { let finalViewFrame = CGRect(origin: .zero, size: layer.bounds.size).applying(newTransform) @@ -259,23 +231,46 @@ class UIRedactBuilder { //Because the current view is covering everything we found so far we can clear `redacting` list redacting.removeAll() } else { - redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: .clipOut)) + let image = SentryGraphicsImageRenderer(size: view.bounds.size, scale: 1).image { _ in + view.drawHierarchy(in: view.bounds, afterScreenUpdates: false) + } + print(image) + redacting.append(RedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: .clipOut, + name: layer.debugDescription + )) } } - guard let subLayers = layer.sublayers, subLayers.count > 0 else { return } + guard let subLayers = layer.sublayers, subLayers.count > 0 else { + return node + } let clipToBounds = view.clipsToBounds if clipToBounds { /// Because the order in which we process the redacted regions is reversed, we add the end of the clip region first. /// The beginning will be added after all the subviews have been mapped. - redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: .clipEnd)) + redacting.append(RedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: .clipEnd, + name: layer.debugDescription + )) } for subLayer in subLayers.sorted(by: { $0.zPosition < $1.zPosition }) { - mapRedactRegion(fromLayer: subLayer, relativeTo: layer, redacting: &redacting, rootFrame: rootFrame, transform: newTransform, forceRedact: enforceRedact) + let child = mapRedactRegion(fromLayer: subLayer, relativeTo: layer, redacting: &redacting, rootFrame: rootFrame, transform: newTransform, forceRedact: enforceRedact) + node.children.append(child) } if clipToBounds { - redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: .clipBegin)) + redacting.append(RedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: .clipBegin, + name: layer.debugDescription + )) } + return node } /** diff --git a/Sources/Swift/Tools/ViewRedaction/ViewHierarchyNode.swift b/Sources/Swift/Tools/ViewRedaction/ViewHierarchyNode.swift new file mode 100644 index 00000000000..cbd1606d028 --- /dev/null +++ b/Sources/Swift/Tools/ViewRedaction/ViewHierarchyNode.swift @@ -0,0 +1,85 @@ +import QuartzCore + +@objc public class ViewHierarchyNode: NSObject, Encodable { + enum CodingKeys: CodingKey { + case layer + case children + } + + public var layer: CALayer? + public var children: [ViewHierarchyNode] + + init(layer: CALayer?, children: [ViewHierarchyNode] = []) { + self.layer = layer + self.children = children + super.init() + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(children, forKey: .children) + if let layer = layer { + try container.encode(CALayerBox(layer), forKey: .layer) + } + } + + public static func == (lhs: ViewHierarchyNode, rhs: ViewHierarchyNode) -> Bool { + if !lhs.children.elementsEqual(rhs.children) { + return false + } + if let lhsLayer = lhs.layer, let rhsLayer = rhs.layer { + return CALayerBox(lhsLayer) == CALayerBox(rhsLayer) + } + return lhs.layer == nil && rhs.layer == nil + } +} + +struct CALayerBox: Encodable, Equatable { + enum CodingKeys: CodingKey { + case description + case frame + case delegateType + case type + case customTag + } + + let layer: CALayer + + init(_ layer: CALayer) { + self.layer = layer + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type(of: layer).description(), forKey: .type) + try container.encode(String(describing: layer.delegate), forKey: .delegateType) + try container.encode(layer.frame, forKey: .frame) + try container.encode(layer.customTag, forKey: .customTag) + } + + static func == (lhs: CALayerBox, rhs: CALayerBox) -> Bool { + if type(of: lhs.layer).description() != type(of: rhs.layer).description() { + return false + } + if String(describing: type(of: lhs.layer.delegate)) != String(describing: type(of: rhs.layer.delegate)) { + return false + } + if lhs.layer.frame != rhs.layer.frame { + return false + } + return true + } +} + +public extension CALayer { + static let customTagAssociationKey = UnsafeRawPointer(bitPattern: "customTagAssociationKey".hashValue)! + + var customTag: String? { + get { + objc_getAssociatedObject(self, CALayer.customTagAssociationKey) as? String + } + set { + objc_setAssociatedObject(self, CALayer.customTagAssociationKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} From 89c5b1b2c652907138c5fe3fac59e574eb2753b9 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 3 Jun 2025 11:37:46 +0200 Subject: [PATCH 2/3] more refactoring --- Sentry.xcodeproj/project.pbxproj | 36 ++++---- .../SentryDefaultMaskRenderer.swift | 6 +- .../ViewCapture/SentryMaskRenderer.swift | 2 +- .../ViewCapture/SentryMaskRendererV2.swift | 2 +- ...tRegion.swift => SentryRedactRegion.swift} | 24 +++--- ...ype.swift => SentryRedactRegionType.swift} | 2 +- .../ViewCapture/SentryRedactViewHelper.swift | 53 ++++++++++++ ...lder.swift => SentryUIRedactBuilder.swift} | 64 +++----------- .../ViewCapture/SentryViewHierarchyNode.swift | 75 ++++++++++++++++ .../ViewCapture/SentryViewPhotographer.swift | 4 +- .../SentryViewScreenshotProvider.swift | 2 +- .../Tools/ViewCapture/ViewHierarchyNode.swift | 85 ------------------- .../SessionReplay/SentryOnDemandReplay.swift | 12 +-- .../SessionReplay/SentryReplayOptions.swift | 4 +- .../SentryReplayVideoMaker.swift | 8 +- .../SessionReplay/SentrySessionReplay.swift | 4 +- .../ViewCapture/UIRedactBuilderTests.swift | 4 +- 17 files changed, 196 insertions(+), 191 deletions(-) rename Sources/Swift/Core/Tools/ViewCapture/{RedactRegion.swift => SentryRedactRegion.swift} (75%) rename Sources/Swift/Core/Tools/ViewCapture/{RedactRegionType.swift => SentryRedactRegionType.swift} (91%) create mode 100644 Sources/Swift/Core/Tools/ViewCapture/SentryRedactViewHelper.swift rename Sources/Swift/Core/Tools/ViewCapture/{UIRedactBuilder.swift => SentryUIRedactBuilder.swift} (85%) create mode 100644 Sources/Swift/Core/Tools/ViewCapture/SentryViewHierarchyNode.swift delete mode 100644 Sources/Swift/Core/Tools/ViewCapture/ViewHierarchyNode.swift diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index b8bcdbede72..b462456ebbf 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -807,6 +807,7 @@ A8F17B2E2901765900990B25 /* SentryRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = A8F17B2D2901765900990B25 /* SentryRequest.m */; }; A8F17B342902870300990B25 /* SentryHttpStatusCodeRange.m in Sources */ = {isa = PBXBuildFile; fileRef = A8F17B332902870300990B25 /* SentryHttpStatusCodeRange.m */; }; D4009EB22D771BC20007AF30 /* SentryFileIOTrackerSwiftHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4009EB12D771BB90007AF30 /* SentryFileIOTrackerSwiftHelpersTests.swift */; }; + D41415A72DEEE532003B14D5 /* SentryRedactViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41415A62DEEE532003B14D5 /* SentryRedactViewHelper.swift */; }; D4291A692DD61A3F00772088 /* SentryDispatchQueueProviderProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = D4291A672DD61A3F00772088 /* SentryDispatchQueueProviderProtocol.h */; }; D4291A6D2DD62ACE00772088 /* SentryDispatchFactoryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4291A6C2DD62AC800772088 /* SentryDispatchFactoryTests.m */; }; D42E48572D48DF1600D251BC /* SentryBuildAppStartSpansTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42E48562D48DF1600D251BC /* SentryBuildAppStartSpansTests.swift */; }; @@ -856,9 +857,9 @@ D4C5F59A2D4249E6002A9BF6 /* DataSentryTracingIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4C5F5992D4249E0002A9BF6 /* DataSentryTracingIntegrationTests.swift */; }; D4CBA2472DE06D0200581618 /* libSentryTestUtils.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8431F00A29B284F200D8DC56 /* libSentryTestUtils.a */; }; D4CBA2532DE06D1600581618 /* TestConstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CBA2512DE06D1600581618 /* TestConstantTests.swift */; }; - D4CD2A7F2DE9F91900DA9F59 /* ViewHierarchyNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7E2DE9F91900DA9F59 /* ViewHierarchyNode.swift */; }; - D4CD2A802DE9F91900DA9F59 /* RedactRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7C2DE9F91900DA9F59 /* RedactRegion.swift */; }; - D4CD2A812DE9F91900DA9F59 /* RedactRegionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7D2DE9F91900DA9F59 /* RedactRegionType.swift */; }; + D4CD2A7F2DE9F91900DA9F59 /* SentryViewHierarchyNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7E2DE9F91900DA9F59 /* SentryViewHierarchyNode.swift */; }; + D4CD2A802DE9F91900DA9F59 /* SentryRedactRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7C2DE9F91900DA9F59 /* SentryRedactRegion.swift */; }; + D4CD2A812DE9F91900DA9F59 /* SentryRedactRegionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7D2DE9F91900DA9F59 /* SentryRedactRegionType.swift */; }; D4E3F35D2D4A864600F79E2B /* SentryNSDictionarySanitizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42E48582D48FC8F00D251BC /* SentryNSDictionarySanitizeTests.swift */; }; D4E3F35E2D4A877300F79E2B /* SentryNSDictionarySanitize+Tests.m in Sources */ = {isa = PBXBuildFile; fileRef = D41909942D490006002B83D0 /* SentryNSDictionarySanitize+Tests.m */; }; D4EDF9842D0B2A210071E7B3 /* Data+SentryTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4EDF9832D0B2A1D0071E7B3 /* Data+SentryTracing.swift */; }; @@ -1037,7 +1038,7 @@ FA67DD0A2DDBD4EA00896B02 /* SentryBaggageSerialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA67DCC92DDBD4EA00896B02 /* SentryBaggageSerialization.swift */; }; FA67DD0B2DDBD4EA00896B02 /* UIViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA67DCC52DDBD4EA00896B02 /* UIViewExtensions.swift */; }; FA67DD0C2DDBD4EA00896B02 /* SentryMaskRendererV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA67DCE32DDBD4EA00896B02 /* SentryMaskRendererV2.swift */; }; - FA67DD0D2DDBD4EA00896B02 /* UIRedactBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA67DCE82DDBD4EA00896B02 /* UIRedactBuilder.swift */; }; + FA67DD0D2DDBD4EA00896B02 /* SentryUIRedactBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA67DCE82DDBD4EA00896B02 /* SentryUIRedactBuilder.swift */; }; FA67DD0E2DDBD4EA00896B02 /* SentryFileContents.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA67DCCB2DDBD4EA00896B02 /* SentryFileContents.swift */; }; FA67DD0F2DDBD4EA00896B02 /* SentryViewControllerBreadcrumbTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA67DCDD2DDBD4EA00896B02 /* SentryViewControllerBreadcrumbTracking.swift */; }; FA67DD102DDBD4EA00896B02 /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA67DCC42DDBD4EA00896B02 /* StringExtensions.swift */; }; @@ -2011,6 +2012,7 @@ A8F17B2D2901765900990B25 /* SentryRequest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryRequest.m; sourceTree = ""; }; A8F17B332902870300990B25 /* SentryHttpStatusCodeRange.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryHttpStatusCodeRange.m; sourceTree = ""; }; D4009EB12D771BB90007AF30 /* SentryFileIOTrackerSwiftHelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryFileIOTrackerSwiftHelpersTests.swift; sourceTree = ""; }; + D41415A62DEEE532003B14D5 /* SentryRedactViewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactViewHelper.swift; sourceTree = ""; }; D41909922D48FFF6002B83D0 /* SentryNSDictionarySanitize+Tests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SentryNSDictionarySanitize+Tests.h"; sourceTree = ""; }; D41909942D490006002B83D0 /* SentryNSDictionarySanitize+Tests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SentryNSDictionarySanitize+Tests.m"; sourceTree = ""; }; D4291A672DD61A3F00772088 /* SentryDispatchQueueProviderProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryDispatchQueueProviderProtocol.h; path = include/SentryDispatchQueueProviderProtocol.h; sourceTree = ""; }; @@ -2068,9 +2070,9 @@ D4C5F5992D4249E0002A9BF6 /* DataSentryTracingIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSentryTracingIntegrationTests.swift; sourceTree = ""; }; D4CBA2432DE06D0200581618 /* SentryTestUtilsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SentryTestUtilsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D4CBA2512DE06D1600581618 /* TestConstantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstantTests.swift; sourceTree = ""; }; - D4CD2A7C2DE9F91900DA9F59 /* RedactRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactRegion.swift; sourceTree = ""; }; - D4CD2A7D2DE9F91900DA9F59 /* RedactRegionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactRegionType.swift; sourceTree = ""; }; - D4CD2A7E2DE9F91900DA9F59 /* ViewHierarchyNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewHierarchyNode.swift; sourceTree = ""; }; + D4CD2A7C2DE9F91900DA9F59 /* SentryRedactRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactRegion.swift; sourceTree = ""; }; + D4CD2A7D2DE9F91900DA9F59 /* SentryRedactRegionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactRegionType.swift; sourceTree = ""; }; + D4CD2A7E2DE9F91900DA9F59 /* SentryViewHierarchyNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryViewHierarchyNode.swift; sourceTree = ""; }; D4EDF9832D0B2A1D0071E7B3 /* Data+SentryTracing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+SentryTracing.swift"; sourceTree = ""; }; D4F2B5342D0C69D100649E42 /* SentryCrashCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCrashCTests.swift; sourceTree = ""; }; D4FC68162DD632E7001B74FF /* SentryDispatchSourceProviderProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryDispatchSourceProviderProtocol.h; path = include/SentryDispatchSourceProviderProtocol.h; sourceTree = ""; }; @@ -2269,7 +2271,7 @@ FA67DCE52DDBD4EA00896B02 /* SentryViewRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryViewRenderer.swift; sourceTree = ""; }; FA67DCE62DDBD4EA00896B02 /* SentryViewRendererV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryViewRendererV2.swift; sourceTree = ""; }; FA67DCE72DDBD4EA00896B02 /* SentryViewScreenshotProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryViewScreenshotProvider.swift; sourceTree = ""; }; - FA67DCE82DDBD4EA00896B02 /* UIRedactBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIRedactBuilder.swift; sourceTree = ""; }; + FA67DCE82DDBD4EA00896B02 /* SentryUIRedactBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUIRedactBuilder.swift; sourceTree = ""; }; FA67DCEA2DDBD4EA00896B02 /* HTTPHeaderSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeaderSanitizer.swift; sourceTree = ""; }; FA67DCEB2DDBD4EA00896B02 /* SentryLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLog.swift; sourceTree = ""; }; FA67DCEC2DDBD4EA00896B02 /* SentryLogOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogOutput.swift; sourceTree = ""; }; @@ -4494,8 +4496,8 @@ FA67DCE92DDBD4EA00896B02 /* ViewCapture */ = { isa = PBXGroup; children = ( - D4CD2A7C2DE9F91900DA9F59 /* RedactRegion.swift */, - D4CD2A7D2DE9F91900DA9F59 /* RedactRegionType.swift */, + D4CD2A7C2DE9F91900DA9F59 /* SentryRedactRegion.swift */, + D4CD2A7D2DE9F91900DA9F59 /* SentryRedactRegionType.swift */, FA67DCDF2DDBD4EA00896B02 /* SentryDefaultMaskRenderer.swift */, FA67DCE02DDBD4EA00896B02 /* SentryDefaultViewRenderer.swift */, FA67DCE12DDBD4EA00896B02 /* SentryGraphicsImageRenderer.swift */, @@ -4505,8 +4507,9 @@ FA67DCE52DDBD4EA00896B02 /* SentryViewRenderer.swift */, FA67DCE62DDBD4EA00896B02 /* SentryViewRendererV2.swift */, FA67DCE72DDBD4EA00896B02 /* SentryViewScreenshotProvider.swift */, - FA67DCE82DDBD4EA00896B02 /* UIRedactBuilder.swift */, - D4CD2A7E2DE9F91900DA9F59 /* ViewHierarchyNode.swift */, + FA67DCE82DDBD4EA00896B02 /* SentryUIRedactBuilder.swift */, + D4CD2A7E2DE9F91900DA9F59 /* SentryViewHierarchyNode.swift */, + D41415A62DEEE532003B14D5 /* SentryRedactViewHelper.swift */, ); path = ViewCapture; sourceTree = ""; @@ -5358,6 +5361,7 @@ FA67DCFD2DDBD4EA00896B02 /* SentryANRTracker.swift in Sources */, FA67DCFE2DDBD4EA00896B02 /* SentryANRTrackerV2Delegate.swift in Sources */, FA67DCFF2DDBD4EA00896B02 /* SentryMXManager.swift in Sources */, + D41415A72DEEE532003B14D5 /* SentryRedactViewHelper.swift in Sources */, FA67DD002DDBD4EA00896B02 /* SentryMaskRenderer.swift in Sources */, FA67DD012DDBD4EA00896B02 /* SentryMXCallStackTree.swift in Sources */, FA67DD022DDBD4EA00896B02 /* SentryViewScreenshotProvider.swift in Sources */, @@ -5371,7 +5375,7 @@ FA67DD0A2DDBD4EA00896B02 /* SentryBaggageSerialization.swift in Sources */, FA67DD0B2DDBD4EA00896B02 /* UIViewExtensions.swift in Sources */, FA67DD0C2DDBD4EA00896B02 /* SentryMaskRendererV2.swift in Sources */, - FA67DD0D2DDBD4EA00896B02 /* UIRedactBuilder.swift in Sources */, + FA67DD0D2DDBD4EA00896B02 /* SentryUIRedactBuilder.swift in Sources */, FA67DD0E2DDBD4EA00896B02 /* SentryFileContents.swift in Sources */, FA67DD0F2DDBD4EA00896B02 /* SentryViewControllerBreadcrumbTracking.swift in Sources */, FA67DD102DDBD4EA00896B02 /* StringExtensions.swift in Sources */, @@ -5431,9 +5435,9 @@ 03F84D3227DD4191008FE43F /* SentryProfiler.mm in Sources */, 635B3F391EBC6E2500A6176D /* SentryAsynchronousOperation.m in Sources */, 63FE717520DA4C1100CDBAE8 /* SentryCrash.m in Sources */, - D4CD2A7F2DE9F91900DA9F59 /* ViewHierarchyNode.swift in Sources */, - D4CD2A802DE9F91900DA9F59 /* RedactRegion.swift in Sources */, - D4CD2A812DE9F91900DA9F59 /* RedactRegionType.swift in Sources */, + D4CD2A7F2DE9F91900DA9F59 /* SentryViewHierarchyNode.swift in Sources */, + D4CD2A802DE9F91900DA9F59 /* SentryRedactRegion.swift in Sources */, + D4CD2A812DE9F91900DA9F59 /* SentryRedactRegionType.swift in Sources */, 6344DDB11EC308E400D9160D /* SentryCrashInstallationReporter.m in Sources */, 84BA62272CAE2EEF0049F636 /* SentryUserFeedbackWidgetButtonView.swift in Sources */, D85596F3280580F10041FF8B /* SentryScreenshotIntegration.m in Sources */, diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryDefaultMaskRenderer.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryDefaultMaskRenderer.swift index 47d79bdb280..f2bd01a3a8f 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryDefaultMaskRenderer.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryDefaultMaskRenderer.swift @@ -4,7 +4,7 @@ import UIKit class SentryDefaultMaskRenderer: NSObject, SentryMaskRenderer { - func maskScreenshot(screenshot image: UIImage, size: CGSize, masking: [RedactRegion]) -> UIImage { + func maskScreenshot(screenshot image: UIImage, size: CGSize, masking: [SentryRedactRegion]) -> UIImage { let image = UIGraphicsImageRenderer(size: size, format: .init(for: .init(displayScale: 1))).image { context in applyMasking(to: context, image: image, size: size, masking: masking) } @@ -15,7 +15,7 @@ class SentryDefaultMaskRenderer: NSObject, SentryMaskRenderer { to context: SentryMaskRendererContext, image: UIImage, size: CGSize, - masking: [RedactRegion] + masking: [SentryRedactRegion] ) { let clipOutPath = CGMutablePath(rect: CGRect(origin: .zero, size: size), transform: nil) var clipPaths = [CGPath]() @@ -27,7 +27,7 @@ class SentryDefaultMaskRenderer: NSObject, SentryMaskRenderer { context.cgContext.interpolationQuality = .none image.draw(at: .zero) - var latestRegion: RedactRegion? + var latestRegion: SentryRedactRegion? for region in masking { let rect = CGRect(origin: CGPoint.zero, size: region.size) var transform = region.transform diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRenderer.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRenderer.swift index 3b202c2f044..21997876153 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRenderer.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRenderer.swift @@ -4,7 +4,7 @@ import UIKit protocol SentryMaskRenderer { - func maskScreenshot(screenshot image: UIImage, size: CGSize, masking: [RedactRegion]) -> UIImage + func maskScreenshot(screenshot image: UIImage, size: CGSize, masking: [SentryRedactRegion]) -> UIImage } protocol SentryMaskRendererContext { diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift index d6e90cb0918..da23c1f564a 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift @@ -4,7 +4,7 @@ import UIKit class SentryMaskRendererV2: SentryDefaultMaskRenderer { - override func maskScreenshot(screenshot image: UIImage, size: CGSize, masking: [RedactRegion]) -> UIImage { + override func maskScreenshot(screenshot image: UIImage, size: CGSize, masking: [SentryRedactRegion]) -> UIImage { // The `SentryDefaultMaskRenderer` is also using an display scale of 1, therefore we also use 1 here. // This could be evaluated in future iterations to view performance impact vs quality. let image = SentryGraphicsImageRenderer(size: size, scale: 2).image { context in diff --git a/Sources/Swift/Core/Tools/ViewCapture/RedactRegion.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegion.swift similarity index 75% rename from Sources/Swift/Core/Tools/ViewCapture/RedactRegion.swift rename to Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegion.swift index 017bb540ac4..96616cb6083 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/RedactRegion.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegion.swift @@ -4,7 +4,7 @@ import Foundation import ObjectiveC.NSObjCRuntime import UIKit -@objc public class RedactRegion: NSObject, Encodable { +@objc public class SentryRedactRegion: NSObject, Encodable { enum CodingKeys: CodingKey { case size case transform @@ -15,11 +15,11 @@ import UIKit public let size: CGSize public let transform: CGAffineTransform - public let type: RedactRegionType + public let type: SentryRedactRegionType public let color: UIColor? public let name: String - init(size: CGSize, transform: CGAffineTransform, type: RedactRegionType, color: UIColor? = nil, name: String) { + init(size: CGSize, transform: CGAffineTransform, type: SentryRedactRegionType, color: UIColor? = nil, name: String) { self.size = size self.transform = transform self.type = type @@ -33,16 +33,16 @@ import UIKit try container.encode(size, forKey: .size) try container.encode(transform, forKey: .transform) try container.encode(type, forKey: .type) - try container.encode(UIColorBox(color), forKey: .color) + try container.encode(SentryUIColorBox(color), forKey: .color) try container.encode(name, forKey: .name) } - func canReplace(as other: RedactRegion) -> Bool { + func canReplace(as other: SentryRedactRegion) -> Bool { size == other.size && transform == other.transform && type == other.type } } -private struct UIColorBox: Codable { +private struct SentryUIColorBox: Codable { let color: UIColor? init(_ color: UIColor?) { @@ -51,7 +51,7 @@ private struct UIColorBox: Codable { init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() - let cgColorBox = try container.decode(CGColorBox.self) + let cgColorBox = try container.decode(SentryCGColorBox.self) if let cgColor = cgColorBox.cgColor { color = UIColor(cgColor: cgColor) } else { @@ -61,11 +61,11 @@ private struct UIColorBox: Codable { func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() - try container.encode(CGColorBox(color?.cgColor)) + try container.encode(SentryCGColorBox(color?.cgColor)) } } -private struct CGColorBox: Codable { +private struct SentryCGColorBox: Codable { let cgColor: CGColor? init(_ cgColor: CGColor?) { @@ -74,7 +74,7 @@ private struct CGColorBox: Codable { init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() - let colorData = try container.decode(CGColorData.self) + let colorData = try container.decode(SentryCGColorData.self) guard let colorSpace = CGColorSpace(name: colorData.colorSpaceName as CFString), let cgColor = CGColor(colorSpace: colorSpace, components: colorData.components) else { @@ -95,12 +95,12 @@ private struct CGColorBox: Codable { return } - let colorData = CGColorData(components: components, colorSpaceName: colorSpaceName) + let colorData = SentryCGColorData(components: components, colorSpaceName: colorSpaceName) try container.encode(colorData) } } -private struct CGColorData: Codable { +private struct SentryCGColorData: Codable { let components: [CGFloat] let colorSpaceName: String } diff --git a/Sources/Swift/Core/Tools/ViewCapture/RedactRegionType.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegionType.swift similarity index 91% rename from Sources/Swift/Core/Tools/ViewCapture/RedactRegionType.swift rename to Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegionType.swift index 100dcf7076e..04a35a5473c 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/RedactRegionType.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegionType.swift @@ -1,4 +1,4 @@ -public enum RedactRegionType: String, Codable { +public enum SentryRedactRegionType: String, Codable { /// Redacts the region. case redact = "redact" diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryRedactViewHelper.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactViewHelper.swift new file mode 100644 index 00000000000..d1a15deaaa5 --- /dev/null +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactViewHelper.swift @@ -0,0 +1,53 @@ +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) +import Foundation +import ObjectiveC.NSObjCRuntime +import UIKit +#if os(iOS) +import WebKit +#endif + +@objcMembers +public class SentryRedactViewHelper: NSObject { + private static var associatedRedactObjectHandle: UInt8 = 0 + private static var associatedIgnoreObjectHandle: UInt8 = 0 + private static var associatedClipOutObjectHandle: UInt8 = 0 + private static var associatedSwiftUIRedactObjectHandle: UInt8 = 0 + + override private init() {} + + static func maskView(_ view: UIView) { + objc_setAssociatedObject(view, &associatedRedactObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN) + } + + static func shouldMaskView(_ view: UIView) -> Bool { + (objc_getAssociatedObject(view, &associatedRedactObjectHandle) as? NSNumber)?.boolValue ?? false + } + + static func shouldUnmask(_ view: UIView) -> Bool { + (objc_getAssociatedObject(view, &associatedIgnoreObjectHandle) as? NSNumber)?.boolValue ?? false + } + + static func unmaskView(_ view: UIView) { + objc_setAssociatedObject(view, &associatedIgnoreObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN) + } + + static func shouldClipOut(_ view: UIView) -> Bool { + (objc_getAssociatedObject(view, &associatedClipOutObjectHandle) as? NSNumber)?.boolValue ?? false + } + + static public func clipOutView(_ view: UIView) { + objc_setAssociatedObject(view, &associatedClipOutObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN) + } + + static func shouldRedactSwiftUI(_ view: UIView) -> Bool { + (objc_getAssociatedObject(view, &associatedSwiftUIRedactObjectHandle) as? NSNumber)?.boolValue ?? false + } + + static public func maskSwiftUI(_ view: UIView) { + objc_setAssociatedObject(view, &associatedSwiftUIRedactObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN) + } +} + +#endif +#endif diff --git a/Sources/Swift/Core/Tools/ViewCapture/UIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift similarity index 85% rename from Sources/Swift/Core/Tools/ViewCapture/UIRedactBuilder.swift rename to Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index 1dbd619933b..c1b893f678d 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/UIRedactBuilder.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift @@ -7,7 +7,7 @@ import UIKit import WebKit #endif -class UIRedactBuilder { +class SentryUIRedactBuilder { ///This is a wrapper which marks it's direct children to be ignored private var ignoreContainerClassIdentifier: ObjectIdentifier? ///This is a wrapper which marks it's direct children to be redacted @@ -147,8 +147,8 @@ class UIRedactBuilder { This function returns the redaction regions in reverse order from what was found in the view hierarchy, allowing the processing of regions from top to bottom. This ensures that clip regions are applied first before drawing a redact mask on lower views. */ - func redactRegionsFor(view: UIView) -> (ViewHierarchyNode, [RedactRegion]) { - var redactingRegions = [RedactRegion]() + func redactRegionsFor(view: UIView) -> (SentryViewHierarchyNode, [SentryRedactRegion]) { + var redactingRegions = [SentryRedactRegion]() let node = self.mapRedactRegion(fromLayer: view.layer.presentation() ?? view.layer, relativeTo: nil, @@ -156,8 +156,8 @@ class UIRedactBuilder { rootFrame: view.frame, transform: .identity) - var swiftUIRedact = [RedactRegion]() - var otherRegions = [RedactRegion]() + var swiftUIRedact = [SentryRedactRegion]() + var otherRegions = [SentryRedactRegion]() for region in redactingRegions { if region.type == .redactSwiftUI { @@ -208,8 +208,8 @@ class UIRedactBuilder { } // swiftlint:disable:next function_body_length - private func mapRedactRegion(fromLayer layer: CALayer, relativeTo parentLayer: CALayer?, redacting: inout [RedactRegion], rootFrame: CGRect, transform: CGAffineTransform, forceRedact: Bool = false) -> ViewHierarchyNode { - let node = ViewHierarchyNode(layer: layer) + private func mapRedactRegion(fromLayer layer: CALayer, relativeTo parentLayer: CALayer?, redacting: inout [SentryRedactRegion], rootFrame: CGRect, transform: CGAffineTransform, forceRedact: Bool = false) -> SentryViewHierarchyNode { + let node = SentryViewHierarchyNode(layer: layer) guard !redactClassesIdentifiers.isEmpty && !layer.isHidden && layer.opacity != 0, let view = layer.delegate as? UIView else { return node } @@ -221,7 +221,7 @@ class UIRedactBuilder { var enforceRedact = forceRedact if !ignore && redact { - redacting.append(RedactRegion( + redacting.append(SentryRedactRegion( size: layer.bounds.size, transform: newTransform, type: swiftUI ? .redactSwiftUI : .redact, @@ -239,7 +239,7 @@ class UIRedactBuilder { //Because the current view is covering everything we found so far we can clear `redacting` list redacting.removeAll() } else { - redacting.append(RedactRegion( + redacting.append(SentryRedactRegion( size: layer.bounds.size, transform: newTransform, type: .clipOut, @@ -255,7 +255,7 @@ class UIRedactBuilder { if clipToBounds { /// Because the order in which we process the redacted regions is reversed, we add the end of the clip region first. /// The beginning will be added after all the subviews have been mapped. - redacting.append(RedactRegion( + redacting.append(SentryRedactRegion( size: layer.bounds.size, transform: newTransform, type: .clipEnd, @@ -267,7 +267,7 @@ class UIRedactBuilder { node.children.append(child) } if clipToBounds { - redacting.append(RedactRegion( + redacting.append(SentryRedactRegion( size: layer.bounds.size, transform: newTransform, type: .clipBegin, @@ -313,47 +313,5 @@ class UIRedactBuilder { } } -@objcMembers -public class SentryRedactViewHelper: NSObject { - private static var associatedRedactObjectHandle: UInt8 = 0 - private static var associatedIgnoreObjectHandle: UInt8 = 0 - private static var associatedClipOutObjectHandle: UInt8 = 0 - private static var associatedSwiftUIRedactObjectHandle: UInt8 = 0 - - override private init() {} - - static func maskView(_ view: UIView) { - objc_setAssociatedObject(view, &associatedRedactObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN) - } - - static func shouldMaskView(_ view: UIView) -> Bool { - (objc_getAssociatedObject(view, &associatedRedactObjectHandle) as? NSNumber)?.boolValue ?? false - } - - static func shouldUnmask(_ view: UIView) -> Bool { - (objc_getAssociatedObject(view, &associatedIgnoreObjectHandle) as? NSNumber)?.boolValue ?? false - } - - static func unmaskView(_ view: UIView) { - objc_setAssociatedObject(view, &associatedIgnoreObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN) - } - - static func shouldClipOut(_ view: UIView) -> Bool { - (objc_getAssociatedObject(view, &associatedClipOutObjectHandle) as? NSNumber)?.boolValue ?? false - } - - static public func clipOutView(_ view: UIView) { - objc_setAssociatedObject(view, &associatedClipOutObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN) - } - - static func shouldRedactSwiftUI(_ view: UIView) -> Bool { - (objc_getAssociatedObject(view, &associatedSwiftUIRedactObjectHandle) as? NSNumber)?.boolValue ?? false - } - - static public func maskSwiftUI(_ view: UIView) { - objc_setAssociatedObject(view, &associatedSwiftUIRedactObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN) - } -} - #endif #endif diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryViewHierarchyNode.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryViewHierarchyNode.swift new file mode 100644 index 00000000000..d237f5dcd77 --- /dev/null +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryViewHierarchyNode.swift @@ -0,0 +1,75 @@ +import QuartzCore + +@objc public class SentryViewHierarchyNode: NSObject, Encodable { + enum CodingKeys: CodingKey { + case layer + case children + } + + public var layer: CALayerBox? + public var children: [SentryViewHierarchyNode] + + init(layer: CALayer?, children: [SentryViewHierarchyNode] = []) { + if let layer = layer { + self.layer = CALayerBox(layer) + } else { + self.layer = nil + } + self.children = children + super.init() + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(children, forKey: .children) + if let layer = layer { + try container.encode(layer, forKey: .layer) + } + } + + public static func == (lhs: SentryViewHierarchyNode, rhs: SentryViewHierarchyNode) -> Bool { + if !lhs.children.elementsEqual(rhs.children) { + return false + } + return lhs.layer == rhs.layer + } +} + +public struct CALayerBox: Encodable, Equatable { + enum CodingKeys: CodingKey { + case description + case frame + case delegateType + case type + } + + let layerType: String + let layerDelegateType: String + let frame: CGRect + + init(_ layer: CALayer) { + self.layerType = type(of: layer).description() + self.layerDelegateType = String(describing: layer.delegate) + self.frame = layer.frame + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(layerType, forKey: .type) + try container.encode(layerDelegateType, forKey: .delegateType) + try container.encode(frame, forKey: .frame) + } + + public static func == (lhs: CALayerBox, rhs: CALayerBox) -> Bool { + if lhs.layerType != rhs.layerType { + return false + } + if lhs.layerDelegateType != rhs.layerDelegateType { + return false + } + if lhs.frame != rhs.frame { + return false + } + return true + } +} diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift index 03994f269bf..a80b407ae6e 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift @@ -8,7 +8,7 @@ import UIKit @objcMembers class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { - private let redactBuilder: UIRedactBuilder + private let redactBuilder: SentryUIRedactBuilder private let maskRenderer: SentryMaskRenderer private let dispatchQueue = SentryDispatchQueueWrapper() @@ -29,7 +29,7 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { ) { self.renderer = renderer self.maskRenderer = enableMaskRendererV2 ? SentryMaskRendererV2() : SentryDefaultMaskRenderer() - redactBuilder = UIRedactBuilder(options: redactOptions) + redactBuilder = SentryUIRedactBuilder(options: redactOptions) super.init() } diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryViewScreenshotProvider.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryViewScreenshotProvider.swift index ec544185606..b825e8c9676 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryViewScreenshotProvider.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryViewScreenshotProvider.swift @@ -3,7 +3,7 @@ import Foundation import UIKit -typealias ScreenshotCallback = (_ viewHierarchy: ViewHierarchyNode, _ redactRegions: [RedactRegion], _ renderedViewImage: UIImage, _ maskedViewImage: UIImage) -> Void +typealias ScreenshotCallback = (_ viewHierarchy: SentryViewHierarchyNode, _ redactRegions: [SentryRedactRegion], _ renderedViewImage: UIImage, _ maskedViewImage: UIImage) -> Void @objc protocol SentryViewScreenshotProvider: NSObjectProtocol { diff --git a/Sources/Swift/Core/Tools/ViewCapture/ViewHierarchyNode.swift b/Sources/Swift/Core/Tools/ViewCapture/ViewHierarchyNode.swift deleted file mode 100644 index cbd1606d028..00000000000 --- a/Sources/Swift/Core/Tools/ViewCapture/ViewHierarchyNode.swift +++ /dev/null @@ -1,85 +0,0 @@ -import QuartzCore - -@objc public class ViewHierarchyNode: NSObject, Encodable { - enum CodingKeys: CodingKey { - case layer - case children - } - - public var layer: CALayer? - public var children: [ViewHierarchyNode] - - init(layer: CALayer?, children: [ViewHierarchyNode] = []) { - self.layer = layer - self.children = children - super.init() - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(children, forKey: .children) - if let layer = layer { - try container.encode(CALayerBox(layer), forKey: .layer) - } - } - - public static func == (lhs: ViewHierarchyNode, rhs: ViewHierarchyNode) -> Bool { - if !lhs.children.elementsEqual(rhs.children) { - return false - } - if let lhsLayer = lhs.layer, let rhsLayer = rhs.layer { - return CALayerBox(lhsLayer) == CALayerBox(rhsLayer) - } - return lhs.layer == nil && rhs.layer == nil - } -} - -struct CALayerBox: Encodable, Equatable { - enum CodingKeys: CodingKey { - case description - case frame - case delegateType - case type - case customTag - } - - let layer: CALayer - - init(_ layer: CALayer) { - self.layer = layer - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(type(of: layer).description(), forKey: .type) - try container.encode(String(describing: layer.delegate), forKey: .delegateType) - try container.encode(layer.frame, forKey: .frame) - try container.encode(layer.customTag, forKey: .customTag) - } - - static func == (lhs: CALayerBox, rhs: CALayerBox) -> Bool { - if type(of: lhs.layer).description() != type(of: rhs.layer).description() { - return false - } - if String(describing: type(of: lhs.layer.delegate)) != String(describing: type(of: rhs.layer.delegate)) { - return false - } - if lhs.layer.frame != rhs.layer.frame { - return false - } - return true - } -} - -public extension CALayer { - static let customTagAssociationKey = UnsafeRawPointer(bitPattern: "customTagAssociationKey".hashValue)! - - var customTag: String? { - get { - objc_getAssociatedObject(self, CALayer.customTagAssociationKey) as? String - } - set { - objc_setAssociatedObject(self, CALayer.customTagAssociationKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } -} diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 23fcaf0f6d7..907f9cc833f 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -33,8 +33,8 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { var cacheMaxSize = UInt.max var onNewFrame: (( _ timestamp: Date, - _ viewHiearchy: ViewHierarchyNode, - _ redactRegions: [RedactRegion], + _ viewHiearchy: SentryViewHierarchyNode, + _ redactRegions: [SentryRedactRegion], _ renderedViewImage: UIImage, _ maskedViewImage: UIImage ) -> Void)? @@ -88,8 +88,8 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { @objc func addFrameAsync( timestamp: Date, - viewHiearchy: ViewHierarchyNode, - redactRegions: [RedactRegion], + viewHiearchy: SentryViewHierarchyNode, + redactRegions: [SentryRedactRegion], renderedViewImage: UIImage, maskedViewImage: UIImage, forScreen screen: String? @@ -111,8 +111,8 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { private func addFrame( timestamp: Date, - viewHiearchy: ViewHierarchyNode, - redactRegions: [RedactRegion], + viewHiearchy: SentryViewHierarchyNode, + redactRegions: [SentryRedactRegion], renderedViewImage: UIImage, maskedViewImage: UIImage, forScreen screen: String? diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift index a39fc1bec0a..e3285c7ab53 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift @@ -221,8 +221,8 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions { #if canImport(UIKit) public var onNewFrame: (( _ timestamp: Date, - _ viewHiearchy: ViewHierarchyNode, - _ redactRegions: [RedactRegion], + _ viewHiearchy: SentryViewHierarchyNode, + _ redactRegions: [SentryRedactRegion], _ renderedViewImage: UIImage, _ maskedViewImage: UIImage ) -> Void)? diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift index 1dd49259378..4ce6aafec0f 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift @@ -6,8 +6,8 @@ import UIKit protocol SentryReplayVideoMaker: NSObjectProtocol { func addFrameAsync( timestamp: Date, - viewHiearchy: ViewHierarchyNode, - redactRegions: [RedactRegion], + viewHiearchy: SentryViewHierarchyNode, + redactRegions: [SentryRedactRegion], renderedViewImage: UIImage, maskedViewImage: UIImage, forScreen screen: String? @@ -20,8 +20,8 @@ protocol SentryReplayVideoMaker: NSObjectProtocol { extension SentryReplayVideoMaker { func addFrameAsync( timestamp: Date, - viewHiearchy: ViewHierarchyNode, - redactRegions: [RedactRegion], + viewHiearchy: SentryViewHierarchyNode, + redactRegions: [SentryRedactRegion], renderedViewImage: UIImage, maskedViewImage: UIImage, forScreen screen: String? diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 316ee6779a6..2eb3ef6c409 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -364,8 +364,8 @@ class SentrySessionReplay: NSObject { private func newImage( timestamp: Date, - viewHiearchy: ViewHierarchyNode, - redactRegions: [RedactRegion], + viewHiearchy: SentryViewHierarchyNode, + redactRegions: [SentryRedactRegion], renderedViewImage: UIImage, maskedViewImage: UIImage, forScreen screen: String? diff --git a/Tests/SentryTests/ViewCapture/UIRedactBuilderTests.swift b/Tests/SentryTests/ViewCapture/UIRedactBuilderTests.swift index bfd56130d39..b1ca7569cd8 100644 --- a/Tests/SentryTests/ViewCapture/UIRedactBuilderTests.swift +++ b/Tests/SentryTests/ViewCapture/UIRedactBuilderTests.swift @@ -55,8 +55,8 @@ class UIRedactBuilderTests: XCTestCase { private let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) - private func getSut(_ option: RedactOptions = RedactOptions()) -> UIRedactBuilder { - return UIRedactBuilder(options: option) + private func getSut(_ option: RedactOptions = RedactOptions()) -> SentryUIRedactBuilder { + return SentryUIRedactBuilder(options: option) } func testNoNeedForRedact() { From 6aa25478b47359ff5f617174d179714fb1b44d6c Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 3 Jun 2025 11:55:40 +0200 Subject: [PATCH 3/3] revert unused changes --- Makefile | 2 +- .../SentrySampleShared/SentrySDKWrapper.swift | 9 ++-- Samples/iOS-Swift/iOS-Swift/AppDelegate.swift | 48 +------------------ .../iOS-Swift/ErrorsViewController.swift | 2 - .../TransactionsViewController.swift | 18 ++----- .../SentryDefaultMaskRenderer.swift | 3 -- .../ViewCapture/SentryMaskRendererV2.swift | 2 +- .../ViewCapture/SentryUIRedactBuilder.swift | 4 +- .../ViewCapture/SentryViewHierarchyNode.swift | 5 +- 9 files changed, 17 insertions(+), 76 deletions(-) diff --git a/Makefile b/Makefile index fd95cacee1f..d25407b5d63 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ lint-staged: $(call run-lint-tools,$(STAGED_SWIFT_FILES)) .PHONY: lint-staged -format: format-clang format-swift format-markdown format-json format-yaml +format: format-clang format-swift-all format-markdown format-json format-yaml # Format ObjC, ObjC++, C, and C++ format-clang: diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift index 82006f01340..fa0b3d9fea7 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift @@ -20,13 +20,10 @@ public struct SentrySDKWrapper { }() #endif // !os(macOS) && !os(tvOS) && !os(watchOS) - public func startSentry(additionalOptions: ((Options) -> Void)? = nil) { - SentrySDK.start { options in - configureSentryOptions(options: options) - additionalOptions?(options) - } + public func startSentry() { + SentrySDK.start(configureOptions: configureSentryOptions(options:)) } - + func configureSentryOptions(options: Options) { options.dsn = dsn options.beforeSend = { $0 } diff --git a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift index 28720fb43f1..be2f24a403c 100644 --- a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift +++ b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift @@ -17,53 +17,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { removeAppData() } if !args.contains("--skip-sentry-init") { - var previousEncodedViewData: Data? - var counter = 0 - SentrySDKWrapper.shared.startSentry { options in - options.sessionReplay.frameRate = 10 - if FileManager.default.fileExists(atPath: "/tmp/workdir") { - try! FileManager.default.removeItem(atPath: "/tmp/workdir") - } - try! FileManager.default.createDirectory(atPath: "/tmp/workdir", withIntermediateDirectories: true, attributes: nil) - options.sessionReplay.onNewFrame = { _, viewHiearchy, redactRegions, renderedViewImage, maskedViewImage in - guard TransactionsViewController.isTransitioning else { return } - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - - let encodedViewData = try encoder.encode(viewHiearchy) - if let previousEncodedViewData = previousEncodedViewData { - if encodedViewData == previousEncodedViewData { - return - } - } - previousEncodedViewData = encodedViewData -// if counter >= 2 { -// return -// } - counter += 1 - - let viewHiearchyPath = "/tmp/workdir/\(counter)-0_view.json" - let regionsPath = "/tmp/workdir/\(counter)-1_regions.json" - let imagePath = "/tmp/workdir/\(counter)-2_image.png" - let maskedImagePath = "/tmp/workdir/\(counter)-3_masked.png" - - try encodedViewData.write(to: URL(fileURLWithPath: viewHiearchyPath)) - - let encodedRegionsData = try encoder.encode(redactRegions) - try encodedRegionsData.write(to: URL(fileURLWithPath: regionsPath)) - - let encodedImage = renderedViewImage.pngData() - try encodedImage?.write(to: URL(fileURLWithPath: imagePath)) - - let encodedMaskedImage = maskedViewImage.pngData() - try encodedMaskedImage?.write(to: URL(fileURLWithPath: maskedImagePath)) - - } catch { - print("Could not encode redact regions. Error: \(error)") - } - } - } + SentrySDKWrapper.shared.startSentry() } if #available(iOS 15.0, *) { diff --git a/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift b/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift index 22d9ff5992e..3d328f8ff5a 100644 --- a/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift @@ -25,8 +25,6 @@ class ErrorsViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - print("--> ErrorsViewController.viewDidAppear") - SentrySDK.reportFullyDisplayed() if SentrySDKOverrides.Feedback.injectScreenshot.boolValue { diff --git a/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift b/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift index 93338fb1588..4430081c11c 100644 --- a/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift @@ -3,37 +3,29 @@ import SentrySampleShared import UIKit class TransactionsViewController: UIViewController { - static var isTransitioning = false + @IBOutlet weak var appHangFullyBlockingButton: UIButton! private let dispatchQueue = DispatchQueue(label: "ViewController", attributes: .concurrent) private var timer: Timer? @IBOutlet weak var dsnView: UIView! - + override func viewDidLoad() { super.viewDidLoad() addDSNDisplay(self, vcview: dsnView) SentrySDK.reportFullyDisplayed() } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - print("--> TransactionsViewController.viewWillAppear") - TransactionsViewController.isTransitioning = true - } - + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - print("--> TransactionsViewController.viewDidAppear") - TransactionsViewController.isTransitioning = false periodicallyDoWork() } - + override func viewDidDisappear(_ animated: Bool) { super .viewDidDisappear(animated) self.timer?.invalidate() } - + private func periodicallyDoWork() { self.timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryDefaultMaskRenderer.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryDefaultMaskRenderer.swift index f2bd01a3a8f..7a47a97e106 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryDefaultMaskRenderer.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryDefaultMaskRenderer.swift @@ -48,9 +48,6 @@ class SentryDefaultMaskRenderer: NSObject, SentryMaskRenderer { self.updateClipping(for: context.cgContext, clipPaths: clipPaths, clipOutPath: clipOutPath) - UIColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 0.5).setFill() - context.cgContext.addPath(path) - context.cgContext.fillPath() case .clipBegin: clipPaths.append(path) self.updateClipping(for: context.cgContext, diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift index da23c1f564a..4334c4e5e78 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift @@ -7,7 +7,7 @@ class SentryMaskRendererV2: SentryDefaultMaskRenderer { override func maskScreenshot(screenshot image: UIImage, size: CGSize, masking: [SentryRedactRegion]) -> UIImage { // The `SentryDefaultMaskRenderer` is also using an display scale of 1, therefore we also use 1 here. // This could be evaluated in future iterations to view performance impact vs quality. - let image = SentryGraphicsImageRenderer(size: size, scale: 2).image { context in + let image = SentryGraphicsImageRenderer(size: size, scale: 1).image { context in // The experimental mask renderer only uses a different graphics renderer and can reuse the default masking logic. applyMasking(to: context, image: image, size: size, masking: masking) } diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index c1b893f678d..2132b3f81dd 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift @@ -147,7 +147,7 @@ class SentryUIRedactBuilder { This function returns the redaction regions in reverse order from what was found in the view hierarchy, allowing the processing of regions from top to bottom. This ensures that clip regions are applied first before drawing a redact mask on lower views. */ - func redactRegionsFor(view: UIView) -> (SentryViewHierarchyNode, [SentryRedactRegion]) { + func redactRegionsFor(view: UIView) -> (node: SentryViewHierarchyNode, regions: [SentryRedactRegion]) { var redactingRegions = [SentryRedactRegion]() let node = self.mapRedactRegion(fromLayer: view.layer.presentation() ?? view.layer, @@ -168,7 +168,7 @@ class SentryUIRedactBuilder { } //The swiftUI type needs to appear first in the list so it always get masked - return (node, (otherRegions + swiftUIRedact).reversed()) + return (node: node, regions: (otherRegions + swiftUIRedact).reversed()) } private func shouldIgnore(view: UIView) -> Bool { diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryViewHierarchyNode.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryViewHierarchyNode.swift index d237f5dcd77..56557fc8eb2 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryViewHierarchyNode.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryViewHierarchyNode.swift @@ -6,10 +6,13 @@ import QuartzCore case children } - public var layer: CALayerBox? + private let layer: CALayerBox? + public var children: [SentryViewHierarchyNode] init(layer: CALayer?, children: [SentryViewHierarchyNode] = []) { + // Do not keep a reference to the layer, as it would require all encoding to be done on the main thread. + // Instead we store a box of relevant layer information. if let layer = layer { self.layer = CALayerBox(layer) } else {