diff --git a/CHANGELOG.md b/CHANGELOG.md index f020c82b67..08c75cf590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Session replay touch tracking race condition (#4548) +- Session replay transformed view masking (#4529) ### Features diff --git a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift index eaa89e6d14..570b7b2245 100644 --- a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift +++ b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift @@ -43,7 +43,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { options.debug = true if #available(iOS 16.0, *), !args.contains("--disable-session-replay") { - options.experimental.sessionReplay = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true) + options.experimental.sessionReplay = SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true) options.experimental.sessionReplay.quality = .high } diff --git a/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard b/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard index 971b70b2a4..30fe801f7c 100644 --- a/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard +++ b/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -1194,7 +1194,8 @@ - + + @@ -1279,7 +1280,7 @@ - + diff --git a/Samples/iOS-Swift/iOS-Swift/ViewControllers/SRRedactSampleViewController.swift b/Samples/iOS-Swift/iOS-Swift/ViewControllers/SRRedactSampleViewController.swift index d14416638d..9f662ca154 100644 --- a/Samples/iOS-Swift/iOS-Swift/ViewControllers/SRRedactSampleViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ViewControllers/SRRedactSampleViewController.swift @@ -3,6 +3,7 @@ import Foundation class SRRedactSampleViewController: UIViewController { @IBOutlet var notRedactedView: UIView! + @IBOutlet var notRedactedLabel: UILabel! @IBOutlet var label: UILabel! @@ -11,7 +12,6 @@ class SRRedactSampleViewController: UIViewController { notRedactedView.backgroundColor = .green notRedactedView.transform = CGAffineTransform(rotationAngle: 45 * .pi / 180.0) - - SentrySDK.replay.maskView(notRedactedView) + SentrySDK.replay.unmaskView(notRedactedLabel) } } diff --git a/Sources/Swift/Tools/SentryViewPhotographer.swift b/Sources/Swift/Tools/SentryViewPhotographer.swift index d2efb32e4b..1b71cb2bf2 100644 --- a/Sources/Swift/Tools/SentryViewPhotographer.swift +++ b/Sources/Swift/Tools/SentryViewPhotographer.swift @@ -36,7 +36,7 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { self.renderer = DefaultViewRenderer() self.redactBuilder = UIRedactBuilder(options: redactOptions) } - + func image(view: UIView, options: SentryRedactOptions, onComplete: @escaping ScreenshotCallback ) { let image = renderer.render(view: view) @@ -45,6 +45,9 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { dispatchQueue.dispatchAsync { let screenshot = UIGraphicsImageRenderer(size: imageSize, format: .init(for: .init(displayScale: 1))).image { context in + let clipOutPath = CGMutablePath(rect: CGRect(origin: .zero, size: imageSize), transform: nil) + var clipPaths = [CGPath]() + let imageRect = CGRect(origin: .zero, size: imageSize) context.cgContext.addRect(CGRect(origin: CGPoint.zero, size: imageSize)) context.cgContext.clip(using: .evenOdd) @@ -62,23 +65,27 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { defer { latestRegion = region } guard latestRegion?.canReplace(as: region) != true && imageRect.intersects(path.boundingBoxOfPath) else { continue } - + switch region.type { case .redact, .redactSwiftUI: (region.color ?? UIImageHelper.averageColor(of: context.currentImage, at: rect.applying(region.transform))).setFill() context.cgContext.addPath(path) context.cgContext.fillPath() case .clipOut: - context.cgContext.addRect(context.cgContext.boundingBoxOfClipPath) - context.cgContext.addPath(path) - context.cgContext.clip(using: .evenOdd) + clipOutPath.addPath(path) + self.updateClipping(for: context.cgContext, + clipPaths: clipPaths, + clipOutPath: clipOutPath) case .clipBegin: - context.cgContext.saveGState() - context.cgContext.resetClip() - context.cgContext.addPath(path) - context.cgContext.clip() + clipPaths.append(path) + self.updateClipping(for: context.cgContext, + clipPaths: clipPaths, + clipOutPath: clipOutPath) case .clipEnd: - context.cgContext.restoreGState() + clipPaths.removeLast() + self.updateClipping(for: context.cgContext, + clipPaths: clipPaths, + clipOutPath: clipOutPath) } } } @@ -86,6 +93,17 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { } } + private func updateClipping(for context: CGContext, clipPaths: [CGPath], clipOutPath: CGPath) { + context.resetClip() + clipPaths.reversed().forEach { + context.addPath($0) + context.clip() + } + + context.addPath(clipOutPath) + context.clip(using: .evenOdd) + } + @objc(addIgnoreClasses:) func addIgnoreClasses(classes: [AnyClass]) { redactBuilder.addIgnoreClasses(classes) diff --git a/Sources/Swift/Tools/UIRedactBuilder.swift b/Sources/Swift/Tools/UIRedactBuilder.swift index f1d2c1e9a8..4630fe57c2 100644 --- a/Sources/Swift/Tools/UIRedactBuilder.swift +++ b/Sources/Swift/Tools/UIRedactBuilder.swift @@ -184,7 +184,8 @@ class UIRedactBuilder { self.mapRedactRegion(fromView: view, relativeTo: nil, redacting: &redactingRegions, - rootFrame: view.frame) + rootFrame: view.frame, + transform: .identity) var swiftUIRedact = [RedactRegion]() var otherRegions = [RedactRegion]() @@ -237,11 +238,12 @@ class UIRedactBuilder { return image.imageAsset?.value(forKey: "_containingBundle") == nil } - private func mapRedactRegion(fromView view: UIView, relativeTo parentLayer: CALayer?, redacting: inout [RedactRegion], rootFrame: CGRect, forceRedact: Bool = false) { + private func mapRedactRegion(fromView view: UIView, relativeTo parentLayer: CALayer?, redacting: inout [RedactRegion], rootFrame: CGRect, transform: CGAffineTransform, forceRedact: Bool = false) { + guard !redactClassesIdentifiers.isEmpty && !view.isHidden && view.alpha != 0 else { return } let layer = view.layer.presentation() ?? view.layer guard !redactClassesIdentifiers.isEmpty && !layer.isHidden && layer.opacity != 0 else { return } - let newTransform = getTranform(from: layer, withParent: parentLayer) + let newTransform = concatenateTranform(transform, from: layer, withParent: parentLayer) let ignore = !forceRedact && shouldIgnore(view: view) let swiftUI = SentryRedactViewHelper.shouldRedactSwiftUI(view) @@ -271,7 +273,7 @@ class UIRedactBuilder { redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: .clipEnd)) } for subview in view.subviews.sorted(by: { $0.layer.zPosition < $1.layer.zPosition }) { - mapRedactRegion(fromView: subview, relativeTo: layer, redacting: &redacting, rootFrame: rootFrame, forceRedact: enforceRedact) + mapRedactRegion(fromView: subview, relativeTo: layer, redacting: &redacting, rootFrame: rootFrame, transform: newTransform, forceRedact: enforceRedact) } if view.clipsToBounds { redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: .clipBegin)) @@ -281,12 +283,14 @@ class UIRedactBuilder { /** Gets a transform that represents the layer global position. */ - private func getTranform(from layer: CALayer, withParent parentLayer: CALayer?) -> CGAffineTransform { + private func concatenateTranform(_ transform: CGAffineTransform, from layer: CALayer, withParent parentLayer: CALayer?) -> CGAffineTransform { let size = layer.bounds.size let anchorPoint = CGPoint(x: size.width * layer.anchorPoint.x, y: size.height * layer.anchorPoint.y) let position = parentLayer?.convert(layer.position, to: nil) ?? layer.position - - var newTransform = CGAffineTransform(translationX: position.x, y: position.y) + + var newTransform = transform + newTransform.tx = position.x + newTransform.ty = position.y newTransform = CATransform3DGetAffineTransform(layer.transform).concatenating(newTransform) return newTransform.translatedBy(x: -anchorPoint.x, y: -anchorPoint.y) } diff --git a/Tests/SentryTests/SentryViewPhotographerTests.swift b/Tests/SentryTests/SentryViewPhotographerTests.swift index fd85fe559f..af83a45656 100644 --- a/Tests/SentryTests/SentryViewPhotographerTests.swift +++ b/Tests/SentryTests/SentryViewPhotographerTests.swift @@ -144,6 +144,33 @@ class SentryViewPhotographerTests: XCTestCase { assertColor(pixel2, .green) } + func testRedactLabelWithParentTransformed() throws { + let label = UILabel(frame: CGRect(x: 0, y: 0, width: 50, height: 25)) + label.text = "Test" + + let parentView = UIView(frame: CGRect(x: 0, y: 12.5, width: 50, height: 25)) + parentView.backgroundColor = .green + parentView.transform = CGAffineTransform(rotationAngle: .pi / 2) + parentView.addSubview(label) + + let image = try XCTUnwrap(prepare(views: [parentView] )) + assertColor(.white, in: image, at: [ + CGPoint(x: 2, y: 2), + CGPoint(x: 10, y: 2), + CGPoint(x: 2, y: 47), + CGPoint(x: 10, y: 47), + CGPoint(x: 39, y: 2), + CGPoint(x: 39, y: 47) + ]) + + assertColor(.black, in: image, at: [ + CGPoint(x: 13, y: 2), + CGPoint(x: 35, y: 2), + CGPoint(x: 13, y: 47), + CGPoint(x: 35, y: 47) + ]) + } + func testDontRedactClippedLabel() throws { let label = UILabel(frame: CGRect(x: 0, y: 25, width: 50, height: 25)) label.text = "Test" @@ -210,6 +237,44 @@ class SentryViewPhotographerTests: XCTestCase { assertColor(pixel1, .green) } + func testNotMaskingLabelInsideClippedViewHiddenByAnOpaqueExternalView() throws { + let topView = UIView(frame: CGRect(x: 25, y: 0, width: 25, height: 25)) + topView.backgroundColor = .green + + let label1 = UILabel(frame: CGRect(x: 0, y: 0, width: 50, height: 25)) + label1.text = "Test" + label1.textColor = .black + + let parentView = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 25)) + parentView.addSubview(label1) + parentView.clipsToBounds = true + + let image = try XCTUnwrap(prepare(views: [parentView, topView])) + + assertColor(.green, in: image, at: [ + CGPoint(x: 27, y: 3), + CGPoint(x: 27, y: 22), + CGPoint(x: 35, y: 12), + CGPoint(x: 47, y: 3), + CGPoint(x: 47, y: 22) + ]) + + assertColor(.black, in: image, at: [ + CGPoint(x: 3, y: 3), + CGPoint(x: 3, y: 22), + CGPoint(x: 12, y: 12), + CGPoint(x: 22, y: 3), + CGPoint(x: 22, y: 22) + ]) + } + + private func assertColor(_ color: UIColor, in image: UIImage, at points: [CGPoint]) { + points.forEach { + let pixel = self.color(at: $0, in: image) + assertColor(color, pixel) + } + } + private func assertColor(_ color1: UIColor, _ color2: UIColor) { let sRGBColor1 = color1.cgColor.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .defaultIntent, options: nil) let sRGBColor2 = color2.cgColor.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .defaultIntent, options: nil)