From 294208950afdb8f592fe6417ef9dc0fa7b4a99d9 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 26 Sep 2025 15:44:01 +0200 Subject: [PATCH 01/24] fix(session-replay): Ignore list background decoration view in redaction --- .../Tools/ViewCapture/SentryUIRedactBuilder.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index a702ef8f06d..1799b42e9b7 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift @@ -18,6 +18,13 @@ final class SentryUIRedactBuilder { /// causes a crash due to unimplemented init(layer:) initializer. private static let cameraSwiftUIViewClassId = "CameraUI.ChromeSwiftUIView" + /// Class identifier for ``_UICollectionViewListLayoutSectionBackgroundColorDecorationView``, if it exists. + /// + /// This object identifier is used to identify views of this class type during the redaction process. + /// This workaround is required because SwiftUI's List view uses this class to display the background color + /// with it expanding way beyond the bounds of the list. + private static let collectionViewListLayoutSectionBackgroundColorDecorationViewClassId = "_UICollectionViewListLayoutSectionBackgroundColorDecorationView" + ///This is a wrapper which marks it's direct children to be ignored private var ignoreContainerClassIdentifier: ObjectIdentifier? @@ -338,6 +345,10 @@ final class SentryUIRedactBuilder { // This crash only occurs when building with Xcode 16 for iOS 26, so we add a runtime check return true } + + if viewTypeId == Self.collectionViewListLayoutSectionBackgroundColorDecorationViewClassId { + return true + } return false } From 26dccd1736d274bebdf93c3e7afce0f47df3dacf Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Mon, 29 Sep 2025 11:18:06 +0200 Subject: [PATCH 02/24] add comments --- CHANGELOG.md | 6 ++++++ .../Core/Tools/ViewCapture/SentryUIRedactBuilder.swift | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e47abc29db1..3f062475219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Ignore SwiftUI's list background decoration view in redaction ([#6292](https://github.com/getsentry/sentry-cocoa/pull/6292)) + ## 8.56.2 ### Fixes diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index 1799b42e9b7..5d468cf950c 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift @@ -23,7 +23,9 @@ final class SentryUIRedactBuilder { /// This object identifier is used to identify views of this class type during the redaction process. /// This workaround is required because SwiftUI's List view uses this class to display the background color /// with it expanding way beyond the bounds of the list. - private static let collectionViewListLayoutSectionBackgroundColorDecorationViewClassId = "_UICollectionViewListLayoutSectionBackgroundColorDecorationView" + private static let collectionViewListLayoutSectionBackgroundColorDecorationViewClassId = "_UICollectionViewListLayoutSectionBackgroundColorDecorationView" // swiftlint:disable:this identifier_name + + // MARK: - Properties ///This is a wrapper which marks it's direct children to be ignored private var ignoreContainerClassIdentifier: ObjectIdentifier? @@ -347,6 +349,9 @@ final class SentryUIRedactBuilder { } if viewTypeId == Self.collectionViewListLayoutSectionBackgroundColorDecorationViewClassId { + // _UICollectionViewListLayoutSectionBackgroundColorDecorationView is a special case because it is used by the SwiftUI.List view to display the background color. + // + // We need to ignore it because it causes issues in the redaction clipping process. return true } return false From ea7ecaa1a9644d67598085d28338e1dc50f5e00d Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 30 Sep 2025 16:18:02 +0200 Subject: [PATCH 03/24] WIP --- Sentry.xcodeproj/project.pbxproj | 4 +- .../ViewCapture/SentryMaskRendererV2.swift | 2 +- .../ViewCapture/SentryRedactRegion.swift | 9 ++ .../ViewCapture/SentryUIRedactBuilder.swift | 61 +++++++--- .../ViewCapture/SentryViewPhotographer.swift | 19 +++ .../SentryUIRedactBuilderTests.swift | 113 ++++++++++++++++++ 6 files changed, 188 insertions(+), 20 deletions(-) diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index efbe13be22e..a20a5e8e51c 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -986,9 +986,9 @@ F41362112E1C55AF00B84443 /* SentryScopePersistentStore+Tags.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41362102E1C55AF00B84443 /* SentryScopePersistentStore+Tags.swift */; }; F41362132E1C566100B84443 /* SentryScopePersistentStore+User.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41362122E1C566100B84443 /* SentryScopePersistentStore+User.swift */; }; F41362152E1C568400B84443 /* SentryScopePersistentStore+Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41362142E1C568400B84443 /* SentryScopePersistentStore+Context.swift */; }; - F429D3AA2E8562EF00DBF387 /* RateLimitParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F429D3A82E8562EF00DBF387 /* RateLimitParser.swift */; }; F429D37F2E8532A300DBF387 /* HttpDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F429D37D2E8532A300DBF387 /* HttpDateParser.swift */; }; F429D39A2E85360F00DBF387 /* RetryAfterHeaderParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F429D3992E85360F00DBF387 /* RetryAfterHeaderParser.swift */; }; + F429D3AA2E8562EF00DBF387 /* RateLimitParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F429D3A82E8562EF00DBF387 /* RateLimitParser.swift */; }; F443DB272E09BE8C009A9045 /* LoadValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F443DB262E09BE8C009A9045 /* LoadValidatorTests.swift */; }; F44858132E03579D0013E63B /* SentryCrashDynamicLinker+Test.h in Headers */ = {isa = PBXBuildFile; fileRef = F44858122E0357940013E63B /* SentryCrashDynamicLinker+Test.h */; }; F451FAA62E0B304E0050ACF2 /* LoadValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F451FAA52E0B304E0050ACF2 /* LoadValidator.swift */; }; @@ -2329,9 +2329,9 @@ F41362102E1C55AF00B84443 /* SentryScopePersistentStore+Tags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryScopePersistentStore+Tags.swift"; sourceTree = ""; }; F41362122E1C566100B84443 /* SentryScopePersistentStore+User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryScopePersistentStore+User.swift"; sourceTree = ""; }; F41362142E1C568400B84443 /* SentryScopePersistentStore+Context.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryScopePersistentStore+Context.swift"; sourceTree = ""; }; - F429D3A82E8562EF00DBF387 /* RateLimitParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitParser.swift; sourceTree = ""; }; F429D37D2E8532A300DBF387 /* HttpDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpDateParser.swift; sourceTree = ""; }; F429D3992E85360F00DBF387 /* RetryAfterHeaderParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryAfterHeaderParser.swift; sourceTree = ""; }; + F429D3A82E8562EF00DBF387 /* RateLimitParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitParser.swift; sourceTree = ""; }; F443DB262E09BE8C009A9045 /* LoadValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadValidatorTests.swift; sourceTree = ""; }; F44858122E0357940013E63B /* SentryCrashDynamicLinker+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SentryCrashDynamicLinker+Test.h"; sourceTree = ""; }; F451FAA52E0B304E0050ACF2 /* LoadValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadValidator.swift; sourceTree = ""; }; diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift index 0b04faa094d..aab3a8915e2 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryMaskRendererV2.swift @@ -7,7 +7,7 @@ final 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: 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/Core/Tools/ViewCapture/SentryRedactRegion.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegion.swift index 9cd378bdb2a..9d2224d7bd9 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegion.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegion.swift @@ -23,5 +23,14 @@ final class SentryRedactRegion { size == other.size && transform == other.transform && type == other.type } } + +extension SentryRedactRegion: Encodable {} + +extension UIColor: @retroactive Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.cgColor.components) + } +} #endif #endif diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index 5d468cf950c..4c5884e5ed6 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift @@ -60,28 +60,43 @@ final class SentryUIRedactBuilder { var redactClasses = [AnyClass]() if options.maskAllText { - redactClasses += [ UILabel.self, UITextView.self, UITextField.self ] + redactClasses += [ + UILabel.self, + UITextView.self, + UITextField.self + ] // These classes are used by React Native to display text. // We are including them here to avoid leaking text from RN apps with manually initialized sentry-cocoa. - redactClasses += ["RCTTextView", "RCTParagraphComponentView"].compactMap(NSClassFromString(_:)) + redactClasses += [ + "SwiftUI.CGDrawingView", // Used by SwiftUI to render text without UIKit + "RCTTextView", // Used by React Native to render long text + "RCTParagraphComponentView" // Used by React Native to render long text + ].compactMap(NSClassFromString(_:)) } if options.maskAllImages { //this classes are used by SwiftUI to display images. - redactClasses += ["_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView", - "_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView", - "SwiftUI._UIGraphicsView", "SwiftUI.ImageLayer" + redactClasses += [ + "_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView", + "_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView", +// "SwiftUI._UIGraphicsView", + "SwiftUI.ImageLayer", ].compactMap(NSClassFromString(_:)) // These classes are used by React Native to display images/vectors. // We are including them here to avoid leaking images from RN apps with manually initialized sentry-cocoa. - redactClasses += ["RCTImageView"].compactMap(NSClassFromString(_:)) + redactClasses += [ + "RCTImageView" // Used by React Native to display images + ].compactMap(NSClassFromString(_:)) redactClasses.append(UIImageView.self) } #if os(iOS) - redactClasses += [ PDFView.self, WKWebView.self ] + redactClasses += [ + PDFView.self, + WKWebView.self + ] redactClasses += [ // If we try to use 'UIWebView.self' it will not compile for macCatalyst, but the class does exists. @@ -92,10 +107,23 @@ final class SentryUIRedactBuilder { "SFSafariView", // Used by: // - https://developer.apple.com/documentation/avkit/avplayerviewcontroller - "AVPlayerView" + "AVPlayerView", + + + // _UICollectionViewListLayoutSectionBackgroundColorDecorationView is a special case because it is + // used by the SwiftUI.List view to display the background color. + // + // Its frame can be extremely large and extend well beyond the visible list bounds. Treating it as a + // normal opaque background view would generate clip regions that suppress unrelated redaction boxes + // (e.g. navigation bar content). To avoid this, we short-circuit traversal and add a single redact + // region for the decoration view instead of clip-outs. + Self.collectionViewListLayoutSectionBackgroundColorDecorationViewClassId ].compactMap(NSClassFromString(_:)) - ignoreClassesIdentifiers = [ ObjectIdentifier(UISlider.self), ObjectIdentifier(UISwitch.self) ] + ignoreClassesIdentifiers = [ + ObjectIdentifier(UISlider.self), + ObjectIdentifier(UISwitch.self) + ] #else ignoreClassesIdentifiers = [] #endif @@ -308,7 +336,13 @@ final class SentryUIRedactBuilder { name: view.debugDescription )) } - for subLayer in subLayers.sorted(by: { $0.zPosition < $1.zPosition }) { + // Preserve Core Animation's sibling order when zPosition ties to mirror real render order. + for (_, subLayer) in subLayers.enumerated().sorted(by: { lhs, rhs in + if lhs.element.zPosition == rhs.element.zPosition { + return lhs.offset < rhs.offset + } + return lhs.element.zPosition < rhs.element.zPosition + }) { mapRedactRegion(fromLayer: subLayer, relativeTo: layer, redacting: &redacting, rootFrame: rootFrame, transform: newTransform, forceRedact: enforceRedact) } if clipToBounds { @@ -347,13 +381,6 @@ final class SentryUIRedactBuilder { // This crash only occurs when building with Xcode 16 for iOS 26, so we add a runtime check return true } - - if viewTypeId == Self.collectionViewListLayoutSectionBackgroundColorDecorationViewClassId { - // _UICollectionViewListLayoutSectionBackgroundColorDecorationView is a special case because it is used by the SwiftUI.List view to display the background color. - // - // We need to ignore it because it causes issues in the redaction clipping process. - return true - } return false } diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift index 2abc75698e2..8556fe59de7 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift @@ -34,17 +34,36 @@ import UIKit } public func image(view: UIView, onComplete: @escaping ScreenshotCallback) { + // Define a helper variable for the size, so the view is not accessed in the async block let viewSize = view.bounds.size + + #if DEBUG + if let viewDebugHierarchy = view.value(forKey: "recursiveDescription") as? String { + let data = viewDebugHierarchy.data(using: .utf8)! + try? data.write(to: URL(fileURLWithPath: "/tmp/workdir/0-hierarchy.txt")) + } + #endif + + // The redact regions are expected to be thread-safe data structures let redactRegions = redactBuilder.redactRegionsFor(view: view) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try! encoder.encode(redactRegions) + try! data.write(to: URL(fileURLWithPath: "/tmp/workdir/1-regions.json")) + // 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) + try! renderedScreenshot.pngData()!.write(to: URL(fileURLWithPath: "/tmp/workdir/2-render.png")) + dispatchQueue.dispatchAsync { [maskRenderer] in // The mask renderer does not need to be on the main thread. // 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: redactRegions) + try! maskedScreenshot.pngData()!.write(to: URL(fileURLWithPath: "/tmp/workdir/3-masked.png")) onComplete(maskedScreenshot) } diff --git a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift index 709e07831a4..c381a9d7f07 100644 --- a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift +++ b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift @@ -878,6 +878,96 @@ class SentryUIRedactBuilderTests: XCTestCase { XCTAssertEqual(result.first?.name, "CustomDebugDescription") } + func testCollectionViewListBackgroundDecorationView_isIgnoredSubtree_redactsAndDoesNotClipOut() throws { + // -- Arrange -- + // The SwiftUI List uses an internal decoration view + // `_UICollectionViewListLayoutSectionBackgroundColorDecorationView` which may have + // an extremely large frame. We ensure our builder treats this as a special case and + // redacts it directly instead of producing clip regions that could hide other masks. + let decorationView = try createCollectionViewListBackgroundDecorationView(frame: .zero) + + // Configure a very large frame similar to what we see in production + decorationView.frame = CGRect(x: -20, y: -1100, width: 440, height: 2300) + decorationView.backgroundColor = .systemGroupedBackground + + // Add another redacted view that must remain redacted (no clip-out should hide it) + let titleLabel = UILabel(frame: CGRect(x: 16, y: 60, width: 120, height: 40)) + titleLabel.text = "Flinky" + + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 402, height: 874)) + rootView.addSubview(decorationView) + rootView.addSubview(titleLabel) + + let sut = getSut() + + // -- Act -- + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // We should have at least two redact regions (label + decoration view) + XCTAssertGreaterThanOrEqual(result.count, 2) + // There must be no clipOut regions produced by the decoration view special-case + XCTAssertFalse(result.contains(where: { $0.type == .clipOut }), "No clipOut regions expected for decoration background view") + // Ensure we have at least one redact region that matches the large decoration view size + XCTAssertTrue(result.contains(where: { $0.type == .redact && $0.size == decorationView.bounds.size })) + } + + func testSwiftUIListDecorationBackground_doesNotUnmaskNavigationBarContent_elaborateHierarchy() throws { + // -- Arrange -- + // Build a simplified but elaborate hierarchy inspired by the attached recursiveDescription. + // The key actors are: + // - A navigation bar label near the top that should be redacted + // - A SwiftUI List area with a very large `_UICollectionViewListLayoutSectionBackgroundColorDecorationView` + // that extends well beyond the list bounds + // This test captures the expected behavior (label remains redacted, no clipOut overshadowing), + // but is marked as an expected failure to document the current bug. + + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 402, height: 874)) + + // Navigation bar container and title label + let navBar = UIView(frame: CGRect(x: 0, y: 56, width: 402, height: 96)) + navBar.backgroundColor = .clear + let titleLabel = UILabel(frame: CGRect(x: 16, y: 8, width: 120, height: 40)) + titleLabel.text = "Flinky" + navBar.addSubview(titleLabel) + rootView.addSubview(navBar) + + // List container (mimics SwiftUI.UpdateCoalescingCollectionView) + let listContainer = UIView(frame: CGRect(x: 0, y: 306, width: 402, height: 568)) + listContainer.clipsToBounds = true + listContainer.backgroundColor = .systemGroupedBackground + rootView.addSubview(listContainer) + + // Oversized decoration background view + let decorationView = try createCollectionViewListBackgroundDecorationView(frame: .zero) + + // Large frame similar to the debug output (-1135, 2336) + decorationView.frame = CGRect(x: -20, y: -1135.33, width: 442, height: 2336) + decorationView.backgroundColor = .systemGroupedBackground + listContainer.addSubview(decorationView) + + // A representative list cell area (not strictly necessary for the bug but mirrors structure) + let cell = UIView(frame: CGRect(x: 20, y: 0, width: 362, height: 45.33)) + cell.backgroundColor = .white + listContainer.addSubview(cell) + + let sut = getSut() + + // -- Act -- + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTExpectFailure("Decoration background may clear previous redactions due to oversized opaque frame covering root") + + // 1) Navigation title label should remain redacted (i.e., a redact region matching its size exists) + XCTAssertTrue(result.contains(where: { $0.type == .redact && $0.size == titleLabel.bounds.size }), + "Navigation title label should remain redacted") + + // 2) No clipOut regions should be produced by the decoration background handling + XCTAssertFalse(result.contains(where: { $0.type == .clipOut }), + "No clipOut regions expected; decoration view should not suppress unrelated masks") + } + func testViewSubtreeIgnored_noDuplicateRedactionRegions_whenViewMeetsBothConditions() throws { // -- Arrange -- // This test verifies that views meeting both general redaction criteria AND isViewSubtreeIgnored @@ -977,6 +1067,29 @@ class SentryUIRedactBuilderTests: XCTestCase { return cameraView } + + /// Creates a `_UICollectionViewListLayoutSectionBackgroundColorDecorationView` instance for tests. + /// - Parameter frame: Frame to assign after allocation and storage reinitialization + /// - Returns: The created decoration background view + /// - Throws: `XCTSkip` if the class is not available on the platform + private func createCollectionViewListBackgroundDecorationView(frame: CGRect) throws -> UIView { + // Obtain class at runtime – skip if unavailable + guard let decorationClass = NSClassFromString("_UICollectionViewListLayoutSectionBackgroundColorDecorationView") else { + throw XCTSkip("Decoration view class not available on this platform/runtime") + } + + // Allocate instance without calling subclass initializers + let decorationView = try XCTUnwrap(class_createInstance(decorationClass, 0) as? UIView) + + // Reinitialize storage using UIView.initWithFrame(_:) similar to other helpers + typealias InitWithFrame = @convention(c) (AnyObject, Selector, CGRect) -> AnyObject + let sel = NSSelectorFromString("initWithFrame:") + let m = try XCTUnwrap(class_getInstanceMethod(UIView.self, sel)) + let f = unsafeBitCast(method_getImplementation(m), to: InitWithFrame.self) + _ = f(decorationView, sel, frame) + + return decorationView + } } #endif // os(iOS) From edfe05552fb2b6e74f6fc2b340281480309f4afa Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Thu, 2 Oct 2025 11:53:38 +0200 Subject: [PATCH 04/24] WIP --- .../ViewCapture/SentryUIRedactBuilder.swift | 369 +++-- .../SentrySessionReplayIntegrationTests.swift | 50 +- .../SentryUIRedactBuilderTests.swift | 1273 ++++++++++++----- 3 files changed, 1174 insertions(+), 518 deletions(-) diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index 4c5884e5ed6..f3ec6ecb18a 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift @@ -9,6 +9,54 @@ import WebKit #endif final class SentryUIRedactBuilder { + // MARK: - Types + + /// Type used to represented a view that needs to be redacted + struct ExtendedClassIdentifier: Hashable { + /// String representation of the class + /// + /// We deliberately store class identities as strings (e.g. "SwiftUI._UIGraphicsView") + /// instead of `AnyClass` to avoid triggering Objective‑C `+initialize` on UIKit internals + /// or private classes when running off the main thread. The string is obtained via + /// `type(of: someObject).description()`. + let classId: String + + /// Optional filter for layer + /// + /// Some view types are reused for multiple purposes. For example, `SwiftUI._UIGraphicsView` + /// is used both as a structural background (should not be redacted) and as a drawing surface + /// for images when paired with `SwiftUI.ImageLayer` (should be redacted). When `layerId` is + /// provided we only match a view if its backing layer’s type description equals the filter. + let layerId: String? + + /// Initializes a new instance of the extended class identifier using a class ID. + /// + /// - parameter classId: The class name. + /// - parameter layerId: The layer name. + init(classId: String, layerId: String? = nil) { + self.classId = classId + self.layerId = layerId + } + + /// Initializes a new instance of the extended class identifier using an Objective-C type. + /// + /// - parameter objcType: The object type. + /// - parameter layerId: The layer name. + init(objcType: T.Type, layerId: String? = nil) { + self.classId = objcType.description() + self.layerId = layerId + } + + /// Initializes a new instance of the extended class identifier using a Swift class. + /// + /// - parameter class: The class. + /// - parameter layerId: The layer name. + init(class: AnyClass, layerId: String? = nil) { + self.classId = `class`.description() + self.layerId = layerId + } + } + // MARK: - Constants /// Class identifier for ``CameraUI.ChromeSwiftUIView``, if it exists. @@ -16,168 +64,210 @@ final class SentryUIRedactBuilder { /// This object identifier is used to identify views of this class type during the redaction process. /// This workaround is specifically for Xcode 16 building for iOS 26 where accessing CameraUI.ModeLoupeLayer /// causes a crash due to unimplemented init(layer:) initializer. - private static let cameraSwiftUIViewClassId = "CameraUI.ChromeSwiftUIView" - - /// Class identifier for ``_UICollectionViewListLayoutSectionBackgroundColorDecorationView``, if it exists. - /// - /// This object identifier is used to identify views of this class type during the redaction process. - /// This workaround is required because SwiftUI's List view uses this class to display the background color - /// with it expanding way beyond the bounds of the list. - private static let collectionViewListLayoutSectionBackgroundColorDecorationViewClassId = "_UICollectionViewListLayoutSectionBackgroundColorDecorationView" // swiftlint:disable:this identifier_name + private static let cameraSwiftUIViewClassId = ExtendedClassIdentifier(classId: "CameraUI.ChromeSwiftUIView") // MARK: - Properties - ///This is a wrapper which marks it's direct children to be ignored + /// 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 + + /// This is a wrapper which marks it's direct children to be redacted private var redactContainerClassIdentifier: ObjectIdentifier? - ///This is a list of UIView subclasses that will be ignored during redact process - private var ignoreClassesIdentifiers: Set + /// This is a list of UIView subclasses that will be ignored during redact process + /// + /// Stored as `ExtendedClassIdentifier` so we can reference classes by their string description + /// and, if needed, constrain the match to a specific Core Animation layer subtype. + private var ignoreClassesIdentifiers: Set /// This is a list of UIView subclasses that need to be redacted from screenshot /// /// This set is configured as `private(set)` to allow modification only from within this class, - /// while still allowing read access from tests. - private(set) var redactClassesIdentifiers: Set + /// while still allowing read access from tests. Same semantics as `ignoreClassesIdentifiers`. + private var redactClassesIdentifiers: Set - /** - Initializes a new instance of the redaction process with the specified options. - - This initializer configures which `UIView` subclasses should be redacted from screenshots and which should be ignored during the redaction process. - - - parameter options: A `SentryRedactOptions` object that specifies the configuration for the redaction process. - - - If `options.maskAllText` is `true`, common text-related views such as `UILabel`, `UITextView`, and `UITextField` are redacted. - - If `options.maskAllImages` is `true`, common image-related views such as `UIImageView` and various internal `SwiftUI` image views are redacted. - - The `options.unmaskViewTypes` allows specifying custom view types to be ignored during the redaction process. - - The `options.maskViewTypes` allows specifying additional custom view types to be redacted. - - - note: On iOS, views such as `WKWebView` and `UIWebView` are automatically redacted, and controls like `UISlider` and `UISwitch` are ignored. - */ + /// Initializes a new instance of the redaction process with the specified options. + /// + /// This initializer populates allow/deny lists for view types using `ExtendedClassIdentifier`, + /// which lets us match by view class and, optionally, by layer class to disambiguate multi‑use + /// view types (e.g. `SwiftUI._UIGraphicsView`). + /// + /// - parameter options: A `SentryRedactOptions` object that specifies the configuration. + /// - If `options.maskAllText` is `true`, common UIKit text views and SwiftUI text drawing views are redacted. + /// - If `options.maskAllImages` is `true`, UIKit/SwiftUI/Hybrid image views are redacted. + /// - `options.unmaskViewTypes` contributes to the ignore list; `options.maskViewTypes` to the redact list. + /// + /// - note: On iOS, views such as `WKWebView` and `UIWebView` are always redacted, and controls like + /// `UISlider` and `UISwitch` are ignored by default. init(options: SentryRedactOptions) { - var redactClasses = [AnyClass]() - + var redactClasses = Set() + if options.maskAllText { - redactClasses += [ - UILabel.self, - UITextView.self, - UITextField.self - ] - // These classes are used by React Native to display text. + redactClasses.insert(ExtendedClassIdentifier(objcType: UILabel.self)) + redactClasses.insert(ExtendedClassIdentifier(objcType: UITextView.self)) + redactClasses.insert(ExtendedClassIdentifier(objcType: UITextField.self)) + + // The following classes are used by React Native to display text. // We are including them here to avoid leaking text from RN apps with manually initialized sentry-cocoa. - redactClasses += [ - "SwiftUI.CGDrawingView", // Used by SwiftUI to render text without UIKit - "RCTTextView", // Used by React Native to render long text - "RCTParagraphComponentView" // Used by React Native to render long text - ].compactMap(NSClassFromString(_:)) + + // Used by SwiftUI to render text without UIKit, e.g. `Text("Hello World")`. + // We include the class name without a layer filter because it is specifically + // used to draw text glyphs in this context. + redactClasses.insert(ExtendedClassIdentifier(classId: "SwiftUI.CGDrawingView")) + + // Used to render SwiftUI.Text on iOS versions prior to iOS 18 + redactClasses.insert(ExtendedClassIdentifier(classId: "_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView")) + + // Used by React Native to render short text + redactClasses.insert(ExtendedClassIdentifier(classId: "RCTTextView")) + + // Used by React Native to render long text + redactClasses.insert(ExtendedClassIdentifier(classId: "RCTParagraphComponentView")) } if options.maskAllImages { - //this classes are used by SwiftUI to display images. - redactClasses += [ - "_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView", - "_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView", -// "SwiftUI._UIGraphicsView", - "SwiftUI.ImageLayer", - ].compactMap(NSClassFromString(_:)) + redactClasses.insert(ExtendedClassIdentifier(objcType: UIImageView.self)) + + // Used by SwiftUI.Image to display SFSymbols, e.g. `Image(systemName: "star.fill")` + redactClasses.insert(ExtendedClassIdentifier(classId: "_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView")) + + // Used by SwiftUI.Image to display images, e.g. `Image("my_image")`. + // The same view class is also used for structural backgrounds. We differentiate by + // requiring the backing layer to be `SwiftUI.ImageLayer` so we only redact the image case. + redactClasses.insert(ExtendedClassIdentifier(classId: "SwiftUI._UIGraphicsView", layerId: "SwiftUI.ImageLayer")) // These classes are used by React Native to display images/vectors. // We are including them here to avoid leaking images from RN apps with manually initialized sentry-cocoa. - redactClasses += [ - "RCTImageView" // Used by React Native to display images - ].compactMap(NSClassFromString(_:)) - - redactClasses.append(UIImageView.self) + + // Used by React Native to display images + redactClasses.insert(ExtendedClassIdentifier(classId: "RCTImageView")) } #if os(iOS) - redactClasses += [ - PDFView.self, - WKWebView.self - ] + redactClasses.insert(ExtendedClassIdentifier(objcType: PDFView.self)) + redactClasses.insert(ExtendedClassIdentifier(objcType: WKWebView.self)) - redactClasses += [ - // If we try to use 'UIWebView.self' it will not compile for macCatalyst, but the class does exists. - "UIWebView", - // Used by: - // - https://developer.apple.com/documentation/SafariServices/SFSafariViewController - // - https://developer.apple.com/documentation/AuthenticationServices/ASWebAuthenticationSession - "SFSafariView", - // Used by: - // - https://developer.apple.com/documentation/avkit/avplayerviewcontroller - "AVPlayerView", + // If we try to use 'UIWebView.self' it will not compile for macCatalyst, but the class does exists. + redactClasses.insert(ExtendedClassIdentifier(classId: "UIWebView")) + // Used by: + // - https://developer.apple.com/documentation/SafariServices/SFSafariViewController + // - https://developer.apple.com/documentation/AuthenticationServices/ASWebAuthenticationSession + redactClasses.insert(ExtendedClassIdentifier(classId: "SFSafariView")) - // _UICollectionViewListLayoutSectionBackgroundColorDecorationView is a special case because it is - // used by the SwiftUI.List view to display the background color. - // - // Its frame can be extremely large and extend well beyond the visible list bounds. Treating it as a - // normal opaque background view would generate clip regions that suppress unrelated redaction boxes - // (e.g. navigation bar content). To avoid this, we short-circuit traversal and add a single redact - // region for the decoration view instead of clip-outs. - Self.collectionViewListLayoutSectionBackgroundColorDecorationViewClassId - ].compactMap(NSClassFromString(_:)) - - ignoreClassesIdentifiers = [ - ObjectIdentifier(UISlider.self), - ObjectIdentifier(UISwitch.self) + // Used by: + // - https://developer.apple.com/documentation/avkit/avplayerviewcontroller + redactClasses.insert(ExtendedClassIdentifier(classId: "AVPlayerView")) + + // _UICollectionViewListLayoutSectionBackgroundColorDecorationView is a special case because it is + // used by the SwiftUI.List view to display the background color. + // + // Its frame can be extremely large and extend well beyond the visible list bounds. Treating it as a + // normal opaque background view would generate clip regions that suppress unrelated redaction boxes + // (e.g. navigation bar content). To avoid this, we short-circuit traversal and add a single redact + // region for the decoration view instead of clip-outs. + redactClasses.insert(ExtendedClassIdentifier(classId: "_UICollectionViewListLayoutSectionBackgroundColorDecorationView")) + + ignoreClassesIdentifiers = [ + ExtendedClassIdentifier(objcType: UISlider.self), + ExtendedClassIdentifier(objcType: UISwitch.self) ] #else ignoreClassesIdentifiers = [] #endif - redactClassesIdentifiers = Set(redactClasses.map({ ObjectIdentifier($0) })) - + redactClassesIdentifiers = redactClasses + for type in options.unmaskedViewClasses { - self.ignoreClassesIdentifiers.insert(ObjectIdentifier(type)) + self.ignoreClassesIdentifiers.insert(ExtendedClassIdentifier(class: type)) } for type in options.maskedViewClasses { - self.redactClassesIdentifiers.insert(ObjectIdentifier(type)) + self.redactClassesIdentifiers.insert(ExtendedClassIdentifier(class: type)) } } - func containsIgnoreClass(_ ignoreClass: AnyClass) -> Bool { - return ignoreClassesIdentifiers.contains(ObjectIdentifier(ignoreClass)) + /// Returns `true` if the provided class type is contained in the ignore list. + /// + /// This compares by string description to avoid touching Objective‑C class objects directly. + private func containsIgnoreClass(_ ignoreClass: AnyClass) -> Bool { + return ignoreClassesIdentifiers.contains(where: { $0.classId == ignoreClass.description() }) } - func containsRedactClass(_ redactClass: AnyClass) -> Bool { - var currentClass: AnyClass? = redactClass + /// Returns `true` if the view class (and, when required, the backing layer class) matches + /// one of the configured redact identifiers. + /// + /// - Parameters: + /// - viewClass: Concrete runtime class of the `UIView` instance under inspection. + /// - layerClass: Concrete runtime class of the view's backing `CALayer`. + /// + /// Matching rules: + /// - We traverse the view class hierarchy to honor base‑class entries (e.g. matching `UILabel` for subclasses). + /// - If an identifier specifies a `layerId`, the layer’s type description must match as well. + /// + /// Examples: + /// - A custom label `class MyTitleLabel: UILabel {}` will match because `UILabel` is in the redact set: + /// `containsRedactClass(viewClass: MyTitleLabel.self, layerClass: CALayer.self) == true`. + /// - SwiftUI image drawing: `viewClass == SwiftUI._UIGraphicsView` and `layerClass == SwiftUI.ImageLayer` + /// will match because we register `("SwiftUI._UIGraphicsView", layerId: "SwiftUI.ImageLayer")`. + /// - SwiftUI structural background: `viewClass == SwiftUI._UIGraphicsView` with a generic `CALayer` + /// will NOT match (no `ImageLayer`), so we don’t redact background fills. + /// - `UIImageView` will match the class rule; the final decision is refined by `shouldRedact(imageView:)`. + private func containsRedactClass(viewClass: AnyClass, layerClass: AnyClass) -> Bool { + var currentClass: AnyClass? = viewClass while currentClass != nil && currentClass != UIView.self { - if let currentClass = currentClass, redactClassesIdentifiers.contains(ObjectIdentifier(currentClass)) { - return true + if let currentClass = currentClass { + if redactClassesIdentifiers.contains(where: { + guard $0.classId == currentClass.description() else { + return false + } + // If the redaction should only affect views with a specific layer, we need to check it. + // If no `layerId` is defined, we redact all instances of the view + if let filterLayerClass = $0.layerId { + return layerClass.description() == filterLayerClass + } + return true + }) { + return true + } } currentClass = currentClass?.superclass() } return false } + /// Adds a class to the ignore list. func addIgnoreClass(_ ignoreClass: AnyClass) { - ignoreClassesIdentifiers.insert(ObjectIdentifier(ignoreClass)) + ignoreClassesIdentifiers.insert(ExtendedClassIdentifier(class: ignoreClass)) } + /// Adds a class to the redact list. func addRedactClass(_ redactClass: AnyClass) { - redactClassesIdentifiers.insert(ObjectIdentifier(redactClass)) + redactClassesIdentifiers.insert(ExtendedClassIdentifier(class: redactClass)) } + /// Adds multiple classes to the ignore list. func addIgnoreClasses(_ ignoreClasses: [AnyClass]) { ignoreClasses.forEach(addIgnoreClass(_:)) } + /// Adds multiple classes to the redact list. func addRedactClasses(_ redactClasses: [AnyClass]) { redactClasses.forEach(addRedactClass(_:)) } + /// Marks a container class whose direct children should be ignored (unmasked). func setIgnoreContainerClass(_ containerClass: AnyClass) { ignoreContainerClassIdentifier = ObjectIdentifier(containerClass) } + /// Marks a container class whose subtree should be force‑redacted. + /// + /// Note: We also add the container class to the redact list so the container itself becomes a region. func setRedactContainerClass(_ containerClass: AnyClass) { let id = ObjectIdentifier(containerClass) redactContainerClassIdentifier = id - redactClassesIdentifiers.insert(id) + redactClassesIdentifiers.insert(ExtendedClassIdentifier(class: containerClass)) } #if SENTRY_TEST || SENTRY_TEST_CI @@ -190,28 +280,29 @@ final class SentryUIRedactBuilder { } #endif - /** - This function identifies and returns the regions within a given UIView that need to be redacted, based on the specified redaction options. - - - Parameter view: The root UIView for which redaction regions are to be calculated. - - Parameter options: A `SentryRedactOptions` object specifying whether to redact all text (`maskAllText`) or all images (`maskAllImages`). If `options` is nil, defaults are used (redacting all text and images). - - - Returns: An array of `RedactRegion` objects representing areas of the view (and its subviews) that require redaction, based on the current visibility, opacity, and content (text or images). - - The method recursively traverses the view hierarchy, collecting redaction areas from the view and all its subviews. Each redaction area is calculated based on the view’s presentation layer, size, transformation matrix, and other attributes. - - The redaction process considers several key factors: - 1. **Text Redaction**: If `maskAllText` is set to true, regions containing text within the view or its subviews are marked for redaction. - 2. **Image Redaction**: If `maskAllImages` is set to true, image-containing regions are also marked for redaction. - 3. **Opaque View Handling**: If an opaque view covers the entire area, obfuscating views beneath it, those hidden views are excluded from processing, and we can remove them from the result. - 4. **Clip Area Creation**: If a smaller opaque view blocks another view, we create a clip area to avoid drawing a redact mask on top of a view that does not require redaction. - - 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. - */ + /// Identifies and returns the regions within a given `UIView` that need to be redacted. + /// + /// - Parameter view: The root `UIView` for which redaction regions are to be calculated. + /// - Returns: An array of `SentryRedactRegion` objects representing areas of the view (and its subviews) + /// that require redaction, based on visibility, opacity, and content (text or images). + /// + /// The method recursively traverses the view hierarchy, collecting redaction areas from the view and all + /// its subviews. Each redaction area is calculated based on the view’s presentation layer, size, transform, + /// and other attributes. + /// + /// The redaction process considers several key factors: + /// 1. Text redaction when enabled by options. + /// 2. Image redaction when enabled by options. + /// 3. Opaque view handling: fully covering opaque views can clear previously collected regions. + /// 4. Clip area creation to avoid over‑masking when a smaller opaque view blocks another view. + /// + /// The function returns the redaction regions in reverse order from what was found in the hierarchy, + /// so clip regions are applied first before drawing a redact mask on lower views. func redactRegionsFor(view: UIView) -> [SentryRedactRegion] { var redactingRegions = [SentryRedactRegion]() - self.mapRedactRegion(fromLayer: view.layer.presentation() ?? view.layer, + self.mapRedactRegion( + fromLayer: view.layer.presentation() ?? view.layer, relativeTo: nil, redacting: &redactingRegions, rootFrame: view.frame, @@ -251,20 +342,43 @@ final class SentryUIRedactBuilder { return ObjectIdentifier(containerClass) == redactContainerClassIdentifier } + /// Determines whether a given view should be redacted based on configuration and heuristics. + /// + /// Order of checks: + /// 1. Per‑instance override via `SentryRedactViewHelper.shouldMaskView`. + /// 2. Class‑based membership in `redactClassesIdentifiers` (optionally constrained by layer type). + /// 3. Special case handling for `UIImageView` (bundle image exemption). private func shouldRedact(view: UIView) -> Bool { + // First we check if the view instance was marked to be masked if SentryRedactViewHelper.shouldMaskView(view) { return true } - if let imageView = view as? UIImageView, containsRedactClass(UIImageView.self) { + + // Extract the view and layer types for checking + let viewType = type(of: view) + let layerType = type(of: view.layer) + + // Check if the view is supposed to be redacted + guard containsRedactClass(viewClass: viewType, layerClass: layerType) else { + return false + } + + // We need to perform special handling for UIImageView + if let imageView = view as? UIImageView { return shouldRedact(imageView: imageView) } - return containsRedactClass(type(of: view)) + + return true } + /// Special handling for `UIImageView` to avoid masking tiny gradient strips and + /// bundle‑provided assets (e.g. SF Symbols or app assets), which are unlikely to contain PII. private func shouldRedact(imageView: UIImageView) -> Bool { - // Checking the size is to avoid redact gradient background that - // are usually small lines repeating - guard let image = imageView.image, image.size.width > 10 && image.size.height > 10 else { return false } + // Checking the size is to avoid redacting gradient backgrounds that are usually + // implemented as very thin repeating images. + guard let image = imageView.image, image.size.width > 10 && image.size.height > 10 else { + return false + } return image.imageAsset?.value(forKey: "_containingBundle") == nil } @@ -373,7 +487,7 @@ final class SentryUIRedactBuilder { // [2] https://github.com/getsentry/sentry-cocoa/blob/00d97404946a37e983eabb21cc64bd3d5d2cb474/Sources/Sentry/SentrySubClassFinder.m#L58-L84 let viewTypeId = type(of: view).description() - if #available(iOS 26.0, *), viewTypeId == Self.cameraSwiftUIViewClassId { + if #available(iOS 26.0, *), viewTypeId == Self.cameraSwiftUIViewClassId.classId { // CameraUI.ChromeSwiftUIView is a special case because it contains layers which can not be iterated due to this error: // // Fatal error: Use of unimplemented initializer 'init(layer:)' for class 'CameraUI.ModeLoupeLayer' @@ -384,9 +498,7 @@ final class SentryUIRedactBuilder { return false } - /** - Gets a transform that represents the layer global position. - */ + /// Gets a transform that represents the layer global position. 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) @@ -399,21 +511,22 @@ final class SentryUIRedactBuilder { return newTransform.translatedBy(x: -anchorPoint.x, y: -anchorPoint.y) } - /** - Whether the transform does not contains rotation or skew - */ + /// Whether the transform does not contain rotation or skew. private func isAxisAligned(_ transform: CGAffineTransform) -> Bool { // Rotation exists if b or c are not zero return transform.b == 0 && transform.c == 0 } + /// Returns a preferred color for the redact region. + /// + /// For labels we use the resolved `textColor` to produce a visually pleasing mask that + /// roughly matches the original foreground. Other views default to nil and the renderer + /// will compute an average color from the underlying pixels. private func color(for view: UIView) -> UIColor? { return (view as? UILabel)?.textColor.withAlphaComponent(1) } - /** - Indicates whether the view is opaque and will block other view behind it - */ + /// Indicates whether the view is opaque and will block other views behind it. private func isOpaque(_ view: UIView) -> Bool { let layer = view.layer.presentation() ?? view.layer return SentryRedactViewHelper.shouldClipOut(view) || (layer.opacity == 1 && view.backgroundColor != nil && (view.backgroundColor?.cgColor.alpha ?? 0) == 1) diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index 0700833769d..902d13191f1 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -341,31 +341,31 @@ class SentrySessionReplayIntegrationTests: XCTestCase { XCTAssertFalse(sut.sessionReplay.isSessionPaused) } - func testMaskViewFromSDK() throws { - class AnotherLabel: UILabel { - } - - startSDK(sessionSampleRate: 1, errorSampleRate: 1) { options in - options.sessionReplay.maskedViewClasses = [AnotherLabel.self] - } - - let sut = try getSut() - let redactBuilder = sut.viewPhotographer.getRedactBuilder() - XCTAssertTrue(redactBuilder.containsRedactClass(AnotherLabel.self)) - } - - func testIgnoreViewFromSDK() throws { - class AnotherLabel: UILabel { - } - - startSDK(sessionSampleRate: 1, errorSampleRate: 1) { options in - options.sessionReplay.unmaskedViewClasses = [AnotherLabel.self] - } - - let sut = try getSut() - let redactBuilder = sut.viewPhotographer.getRedactBuilder() - XCTAssertTrue(redactBuilder.containsIgnoreClass(AnotherLabel.self)) - } +// func testMaskViewFromSDK() throws { +// class AnotherLabel: UILabel { +// } +// +// startSDK(sessionSampleRate: 1, errorSampleRate: 1) { options in +// options.sessionReplay.maskedViewClasses = [AnotherLabel.self] +// } +// +// let sut = try getSut() +// let redactBuilder = sut.viewPhotographer.getRedactBuilder() +// XCTAssertTrue(redactBuilder.containsRedactClass(viewClass: AnotherLabel.self, layerClass: nil)) +// } +// +// func testIgnoreViewFromSDK() throws { +// class AnotherLabel: UILabel { +// } +// +// startSDK(sessionSampleRate: 1, errorSampleRate: 1) { options in +// options.sessionReplay.unmaskedViewClasses = [AnotherLabel.self] +// } +// +// let sut = try getSut() +// let redactBuilder = sut.viewPhotographer.getRedactBuilder() +// XCTAssertTrue(redactBuilder.containsIgnoreClass(AnotherLabel.self)) +// } func testStop() throws { startSDK(sessionSampleRate: 1, errorSampleRate: 1) diff --git a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift index c381a9d7f07..f05f99ee581 100644 --- a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift +++ b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift @@ -44,274 +44,652 @@ class SentryUIRedactBuilderTests: XCTestCase { private var rootView: UIView! - private func getSut(_ option: TestRedactOptions = TestRedactOptions()) -> SentryUIRedactBuilder { - return SentryUIRedactBuilder(options: option) + private func getSut(maskAllText: Bool, maskAllImages: Bool) -> SentryUIRedactBuilder { + return SentryUIRedactBuilder(options: TestRedactOptions( + maskAllText: true, + maskAllImages: true + )) } override func setUp() { rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) } - func testNoNeedForRedact() { - let sut = getSut() - rootView.addSubview(UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40))) - + func testRedact_withNoSensitiveViews_shouldNotRedactAnything() { + // -- Arrange -- + let view = UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + rootView.addSubview(view) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - + + // -- Assert -- XCTAssertEqual(result.count, 0) } - - func testRedactALabel() { - let sut = getSut() + + // MARK: - UILabel Redaction + + func testRedact_withUILabel_withMaskAllTextEnabled_shouldRedactView() throws { + // -- Arrange -- let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) label.textColor = .purple rootView.addSubview(label) - + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - + + // -- Assert -- + let region = try XCTUnwrap(result.first) + // For UILabel we can use the text color directly to render the redaction geometry + XCTAssertEqual(region.color, .purple) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions XCTAssertEqual(result.count, 1) - XCTAssertEqual(result.first?.color, .purple) - XCTAssertEqual(result.first?.size, CGSize(width: 40, height: 40)) - XCTAssertEqual(result.first?.type, .redact) - XCTAssertEqual(result.first?.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) } - - func testDontUseLabelTransparentColor() { - let sut = getSut() + + func testRedact_withUILabel_withMaskAllTextEnabled_withTransparentForegroundColor_shouldNotUseTransparentColor() throws { + // -- Arrange -- let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) - label.textColor = .purple.withAlphaComponent(0.5) + label.textColor = .purple.withAlphaComponent(0.5) // Any color with an opacity below 1.0 is considered transparent rootView.addSubview(label) + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - XCTAssertEqual(result.first?.color, .purple) + + // -- Assert -- + let region = try XCTUnwrap(result.first) + // For UILabel we can derive which color should be used to render the redaction geometry + XCTAssertEqual(region.color, .purple) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 1) } - func testDontRedactALabelOptionDisabled() { - let sut = getSut(TestRedactOptions(maskAllText: false)) + func testRedact_withUILabel_withMaskAllTextDisabled_shouldNotRedactView() { + // -- Arrange -- let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) label.textColor = .purple rootView.addSubview(label) - + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - + + // -- Assert -- XCTAssertEqual(result.count, 0) } - - func testRedactRCTTextView() { - let sut = getSut(TestRedactOptions(maskAllText: true)) + + func testRedact_withUILabel_withMaskAllImagesDisabled_shouldRedactView() throws { + // This test is to ensure that the option `maskAllImages` does not affect the UILabel redaction + // -- Arrange -- + let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + label.textColor = .purple + rootView.addSubview(label) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: false) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(result.count, 1) + } + + // - MARK: - UITextView Redaction + + func testRedact_withUITextView_withMaskAllTextEnabled_shouldRedactView() throws { + // -- Arrange -- + let textView = UITextView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + textView.textColor = .purple // Set a specific color so it's definitiely set + rootView.addSubview(textView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + let region = try XCTUnwrap(result.first) + // The text color of UITextView is not used for redaction + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 1) + } + + func testRedact_withUITextView_withMaskAllTextDisabled_shouldNotRedactView() { + // -- Arrange -- + let textView = UITextView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + textView.textColor = .purple // Set a specific color so it's definitiely set + rootView.addSubview(textView) + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(result.count, 0) + } + + func testRedact_withUITextView_withMaskAllImagesDisabled_shouldRedactView() throws { + // -- Arrange -- + let textView = UITextView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + textView.textColor = .purple // Set a specific color so it's definitiely set + rootView.addSubview(textView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(result.count, 1) + } + + // MARK: - UITextField Redaction + + func testRedact_withUITextField_withMaskAllTextEnabled_shouldRedactView() throws { + // -- Arrange -- + let textField = UITextField(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + textField.textColor = .purple // Set a specific color so it's definitiely set + rootView.addSubview(textField) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + let region = try XCTUnwrap(result.first) + // The text color of UITextView is not used for redaction + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 1) + } + + func testRedact_withUITextField_withMaskAllTextDisabled_shouldNotRedactView() { + // -- Arrange -- + let textField = UITextField(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + textField.textColor = .purple // Set a specific color so it's definitiely set + rootView.addSubview(textField) + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(result.count, 0) + } + + func testRedact_withUITextField_withMaskAllImagesDisabled_shouldRedactView() { + // -- Arrange -- + let textField = UITextField(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + textField.textColor = .purple // Set a specific color so it's definitiely set + rootView.addSubview(textField) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: false) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(result.count, 1) + } + + // MARK: - RCTTextView Redaction + + func testRedact_withRCTTextView_withMaskAllTextEnabled_shouldRedactView() throws { + // -- Arrange -- let textView = RCTTextView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(textView) - + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - + + // -- Assert -- + let region = try XCTUnwrap(result.first) + // The text color of UITextView is not used for redaction + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions XCTAssertEqual(result.count, 1) - XCTAssertEqual(result.first?.size, CGSize(width: 40, height: 40)) } - func testDoNotRedactRCTTextView() { - let sut = getSut(TestRedactOptions(maskAllText: false)) + func testRedact_withRCTTextView_withMaskAllTextDisabled_shouldNotRedactView() { + // -- Arrange -- let textView = RCTTextView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(textView) - + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - + + // -- Assert -- XCTAssertEqual(result.count, 0) } - - func testRedactRCTParagraphComponentView() { - let sut = getSut(TestRedactOptions(maskAllText: true)) + + func testRedact_withRCTTextView_withMaskAllImagesDisabled_shouldRedactView() { + // -- Arrange -- + let textView = RCTTextView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + rootView.addSubview(textView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: false) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(result.count, 0) + } + + // MARK: - RCTParagraphComponentView Redaction + + func testRedact_withRCTParagraphComponent_withMaskAllTextEnabled_shouldRedactView() throws { + // -- Arrange -- let textView = RCTParagraphComponentView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(textView) - + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - + + // -- Assert -- + let region = try XCTUnwrap(result.first) + // The text color of UITextView is not used for redaction + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions XCTAssertEqual(result.count, 1) - XCTAssertEqual(result.first?.size, CGSize(width: 40, height: 40)) } - - func testDoNotRedactRCTParagraphComponentView() { - let sut = getSut(TestRedactOptions(maskAllText: false)) + + func testRedact_withRCTParagraphComponent_withMaskAllTextDisabled_shouldNotRedactView() { + // -- Arrange -- let textView = RCTParagraphComponentView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(textView) - + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - + + // -- Assert -- XCTAssertEqual(result.count, 0) } - - func testRedactRCTImageView() { - let sut = getSut(TestRedactOptions(maskAllImages: true)) - let imageView = RCTImageView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) - rootView.addSubview(imageView) - + + func testRedact_withRCTParagraphComponent_withMaskAllImagesDisabled_shouldRedactView() { + // -- Arrange -- + let textView = RCTParagraphComponentView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + rootView.addSubview(textView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: false) let result = sut.redactRegionsFor(view: rootView) - + + // -- Assert -- XCTAssertEqual(result.count, 1) - XCTAssertEqual(result.first?.size, CGSize(width: 40, height: 40)) } - - func testDoNotRedactRCTImageView() { - let sut = getSut(TestRedactOptions(maskAllImages: false)) - let imageView = RCTImageView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + + // MARK: - UIImageView Redaction + + func testRedact_withUIImageView_withMaskAllImagesEnabled_shouldRedactView() throws { + // -- Arrange -- + let image = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { context in + context.fill(CGRect(x: 0, y: 0, width: 40, height: 40)) + } + + let imageView = UIImageView(image: image) + imageView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) rootView.addSubview(imageView) - + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - - XCTAssertEqual(result.count, 0) + + // -- Assert -- + let region = try XCTUnwrap(result.first) + // The text color of UITextView is not used for redaction + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 1) } - - func testRedactAImage() { - let sut = getSut() - + + func testRedact_withUIImageView_withMaskAllImagesDisabled_shouldNotRedactView() { + // -- Arrange -- let image = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { context in context.fill(CGRect(x: 0, y: 0, width: 40, height: 40)) } - + let imageView = UIImageView(image: image) imageView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) rootView.addSubview(imageView) - + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: false) let result = sut.redactRegionsFor(view: rootView) - - XCTAssertEqual(result.count, 1) - XCTAssertNil(result.first?.color) - XCTAssertEqual(result.first?.size, CGSize(width: 40, height: 40)) + + // -- Assert -- + XCTAssertEqual(result.count, 0) } - - func testDontRedactAImageOptionDisabled() { - let sut = getSut(TestRedactOptions(maskAllImages: false)) - + + func testRedact_withUIImageView_withMaskAllTextDisabled_shouldRedactView() { + // -- Arrange -- let image = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { context in context.fill(CGRect(x: 0, y: 0, width: 40, height: 40)) } - + let imageView = UIImageView(image: image) imageView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) rootView.addSubview(imageView) - + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - - XCTAssertEqual(result.count, 0) + + // -- Assert -- + XCTAssertEqual(result.count, 1) } - - func testDontRedactABundleImage() { - //The check for bundled image only works for iOS 16 and above - //For others versions all images will be redacted - guard #available(iOS 16, *) else { return } - let sut = getSut() - + + func testRedact_withUIImageView_withImageFromBundle_shouldNotRedactView() throws { + // The check for bundled image only works for iOS 16 and above + // For others versions all images will be redacted + guard #available(iOS 16, *) else { + throw XCTSkip("This test only works on iOS 16 and above") + } + + // -- Arrange -- let imageView = UIImageView(image: .add) imageView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) rootView.addSubview(imageView) - + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - + + // -- Assert -- XCTAssertEqual(result.count, 0) } - - func testDontRedactAHiddenView() { - let sut = getSut() - let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) - label.isHidden = true - rootView.addSubview(label) - + + // - MARK: - RCTImageView Redaction + + func testRedact_withRCTImageView_withMaskAllImagesEnabled_shouldRedactView() throws { + // -- Arrange -- + let imageView = RCTImageView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + rootView.addSubview(imageView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - - XCTAssertEqual(result.count, 0) + + // -- Assert -- + let region = try XCTUnwrap(result.first) + // The text color of UITextView is not used for redaction + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 1) } - func testDontRedactATransparentView() { - let sut = getSut() - let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) - label.alpha = 0 - rootView.addSubview(label) - + func testRedact_withRCTImageView_withMaskAllImagesDisabled_shouldNotRedactView() { + // -- Arrange -- + let imageView = RCTImageView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + rootView.addSubview(imageView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: false) let result = sut.redactRegionsFor(view: rootView) - + + // -- Assert -- XCTAssertEqual(result.count, 0) } - - func testClipForOpaqueView() { + + func testRedact_withRCTImageView_withMaskAllTextDisabled_shouldRedactView() { + // -- Arrange -- + let imageView = RCTImageView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + rootView.addSubview(imageView) + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(result.count, 1) + } + + // - MARK: - Sensitive Views + + func testRedact_withSensitiveView_shouldNotRedactHiddenView() throws { + // -- Arrange -- + // We use any view here we know that should be redacted + let ignoredLabel = UILabel(frame: CGRect(x: 20, y: 10, width: 5, height: 5)) + ignoredLabel.isHidden = true + rootView.addSubview(ignoredLabel) + + let redactedLabel = UILabel(frame: CGRect(x: 20, y: 20, width: 8, height: 8)) + redactedLabel.isHidden = false + rootView.addSubview(redactedLabel) + + // -- Arrange -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // Only the redacted label will result in a region + + let region = try XCTUnwrap(result.first) + // The text color of UITextView is not used for redaction + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 8, height: 8)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 1) + } + + func testRedact_withSensitiveView_shouldNotRedactFullyTransparentView() throws { + // -- Arrange -- + // We use any view here we know that should be redacted + let fullyTransparentLabel = UILabel(frame: CGRect(x: 20, y: 10, width: 5, height: 5)) + fullyTransparentLabel.alpha = 0 + rootView.addSubview(fullyTransparentLabel) + + let transparentLabel = UILabel(frame: CGRect(x: 20, y: 15, width: 3, height: 3)) + transparentLabel.alpha = 0.5 + rootView.addSubview(transparentLabel) + + let nonTransparentLabel = UILabel(frame: CGRect(x: 20, y: 20, width: 8, height: 8)) + nonTransparentLabel.alpha = 1 + rootView.addSubview(nonTransparentLabel) + + // -- Arrange -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // Only the transparent and opaque label will result in regions, not the fully transparent one. + + let transparentLabelRegion = try XCTUnwrap(result.element(at: 0)) + XCTAssertNil(transparentLabelRegion.color) + XCTAssertEqual(transparentLabelRegion.size, CGSize(width: 3, height: 3)) + XCTAssertEqual(transparentLabelRegion.type, .redact) + XCTAssertEqual(transparentLabelRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 15)) + + let nonTransparentLabelRegion = try XCTUnwrap(result.element(at: 1)) + XCTAssertNil(nonTransparentLabelRegion.color) + XCTAssertEqual(nonTransparentLabelRegion.size, CGSize(width: 8, height: 8)) + XCTAssertEqual(nonTransparentLabelRegion.type, .redact) + XCTAssertEqual(nonTransparentLabelRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 2) + } + + // MARK: - Clipping + + func testClipping_withOpaqueView_shouldClipOutRegion() throws { + // -- Arrange -- let opaqueView = UIView(frame: CGRect(x: 10, y: 10, width: 60, height: 60)) opaqueView.backgroundColor = .white rootView.addSubview(opaqueView) - - let sut = getSut() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) - + + // -- Assert -- + let region = try XCTUnwrap(result.first) + XCTAssertEqual(region.type, .clipOut) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 10)) + + // Assert that there are no other regions XCTAssertEqual(result.count, 1) - XCTAssertEqual(result.first?.type, .clipOut) - XCTAssertEqual(result.first?.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 10)) } - func testRedactALabelBehindATransparentView() { - let sut = getSut() + func testRedact_withLabelBehindATransparentView_shouldRedactLabel() throws { + // -- Arrange -- let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(label) + let topView = UIView(frame: CGRect(x: 10, y: 10, width: 60, height: 60)) topView.backgroundColor = .clear rootView.addSubview(topView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + let region = try XCTUnwrap(result.first) + // The text color of UITextView is not used for redaction + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 8, height: 8)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions XCTAssertEqual(result.count, 1) } - - func testIgnoreClasses() { - let sut = getSut() + + // MARK: - Class Ignoring + + func testAddIgnoreClasses_withSensitiveView_shouldNotRedactView() { + // -- Arrange -- + let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + rootView.addSubview(label) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + + // Check that the pre-condition applies so this tests doesn't rely on other tests + let preIgnoreResult = sut.redactRegionsFor(view: rootView) + sut.addIgnoreClass(UILabel.self) - rootView.addSubview(UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40))) - let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(preIgnoreResult.count, 1) XCTAssertEqual(result.count, 0) } - - func testRedactClasses() { - class AnotherView: UIView { - } - - let sut = getSut() + + // MARK: - Custom Class Redaction + + func testAddRedactClasses_withCustomView_shouldRedactView() { + // -- Arrange -- + class AnotherView: UIView {} + let view = AnotherView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) - sut.addRedactClass(AnotherView.self) rootView.addSubview(view) - + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + + // Check that the pre-condition applies so this tests doesn't rely on other tests + let preIgnoreResult = sut.redactRegionsFor(view: rootView) + + sut.addRedactClass(AnotherView.self) let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(preIgnoreResult.count, 0) XCTAssertEqual(result.count, 1) } - func testRedactSubClass() { - class AnotherView: UILabel { - } + func testAddRedactClass_withSubclassOfSensitiveView_shouldRedactView() throws { + // -- Arrange -- + class AnotherView: UILabel {} - let sut = getSut() let view = AnotherView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(view) - + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + let region = try XCTUnwrap(result.first) + // The text color of UILabel subclasses is not used for redaction + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions XCTAssertEqual(result.count, 1) } - func testIgnoreContainerChildView() { + // MARK: - Ignore Container + + func testIgnoreContainer_withSensitiveChildView_shouldRedactView() { + // -- Arrange -- class IgnoreContainer: UIView {} class AnotherLabel: UILabel {} - let sut = getSut() - sut.setIgnoreContainerClass(IgnoreContainer.self) - let ignoreContainer = IgnoreContainer(frame: CGRect(x: 0, y: 0, width: 60, height: 60)) let wrappedLabel = AnotherLabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) ignoreContainer.addSubview(wrappedLabel) rootView.addSubview(ignoreContainer) + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + + let preIgnoreResult = sut.redactRegionsFor(view: rootView) + + sut.setIgnoreContainerClass(IgnoreContainer.self) let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(preIgnoreResult.count, 1) XCTAssertEqual(result.count, 0) } - func testIgnoreContainerDirectChildView() { + func testIgnoreContainer_withDirectChildView_shouldRedactView() throws { + // -- Arrange -- class IgnoreContainer: UIView {} class AnotherLabel: UILabel {} - let sut = getSut() - sut.setIgnoreContainerClass(IgnoreContainer.self) - let ignoreContainer = IgnoreContainer(frame: CGRect(x: 0, y: 0, width: 60, height: 60)) let wrappedLabel = AnotherLabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) let redactedLabel = AnotherLabel(frame: CGRect(x: 10, y: 10, width: 10, height: 10)) @@ -319,16 +697,31 @@ class SentryUIRedactBuilderTests: XCTestCase { ignoreContainer.addSubview(wrappedLabel) rootView.addSubview(ignoreContainer) + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let preIgnoreResult = sut.redactRegionsFor(view: rootView) + + sut.setIgnoreContainerClass(IgnoreContainer.self) let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(preIgnoreResult.count, 0) + + // Assert that the ignore container is redacted + let region = try XCTUnwrap(result.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 60, height: 60)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0)) + + // Assert that there are no other regions XCTAssertEqual(result.count, 1) } - func testRedactIgnoreContainerAsChildOfMaskedView() { + func testIgnoreContainer_withIgnoreContainerAsChildOfMaskedView_shouldRedactAllViews() throws { + // -- Arrange -- class IgnoreContainer: UIView {} - let sut = getSut() - sut.setIgnoreContainerClass(IgnoreContainer.self) - let redactedLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 60, height: 60)) let ignoreContainer = IgnoreContainer(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) let redactedChildLabel = UILabel(frame: CGRect(x: 10, y: 10, width: 10, height: 10)) @@ -336,17 +729,48 @@ class SentryUIRedactBuilderTests: XCTestCase { redactedLabel.addSubview(ignoreContainer) rootView.addSubview(redactedLabel) + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let preIgnoreResult = sut.redactRegionsFor(view: rootView) + + sut.setIgnoreContainerClass(IgnoreContainer.self) let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(preIgnoreResult.count, 0) + + // Assert that the ignore container is redacted + let region = try XCTUnwrap(result.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 60, height: 60)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0)) + + // Assert that the redacted label is redacted + let region2 = try XCTUnwrap(result.element(at: 1)) + XCTAssertNil(region2.color) + XCTAssertEqual(region2.size, CGSize(width: 60, height: 60)) + XCTAssertEqual(region2.type, .redact) + XCTAssertEqual(region2.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0)) + + // Assert that the redacted child label is redacted + let region3 = try XCTUnwrap(result.element(at: 2)) + XCTAssertNil(region3.color) + XCTAssertEqual(region3.size, CGSize(width: 10, height: 10)) + XCTAssertEqual(region3.type, .redact) + XCTAssertEqual(region3.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 10)) + + // Assert that there are no other regions XCTAssertEqual(result.count, 3) } - func testRedactChildrenOfRedactContainer() { + // MARK: - Redact Container + + func testRedactContainer_withChildViews_shouldRedactAllViews() throws { + // -- Arrange -- class RedactContainer: UIView {} class AnotherView: UIView {} - let sut = getSut() - sut.setRedactContainerClass(RedactContainer.self) - let redactContainer = RedactContainer(frame: CGRect(x: 0, y: 0, width: 60, height: 60)) let redactedView = AnotherView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) let redactedView2 = AnotherView(frame: CGRect(x: 10, y: 10, width: 10, height: 10)) @@ -354,32 +778,85 @@ class SentryUIRedactBuilderTests: XCTestCase { redactContainer.addSubview(redactedView) rootView.addSubview(redactContainer) + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let preRedactResult = sut.redactRegionsFor(view: rootView) + + sut.setRedactContainerClass(RedactContainer.self) let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(preRedactResult.count, 0) + + // Assert that the redact container is redacted + let region = try XCTUnwrap(result.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 60, height: 60)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0)) + + // Assert that the redacted view is redacted + let region2 = try XCTUnwrap(result.element(at: 1)) + XCTAssertNil(region2.color) + XCTAssertEqual(region2.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region2.type, .redact) + XCTAssertEqual(region2.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that the redacted view2 is redacted + let region3 = try XCTUnwrap(result.element(at: 2)) + XCTAssertNil(region3.color) + XCTAssertEqual(region3.size, CGSize(width: 10, height: 10)) + XCTAssertEqual(region3.type, .redact) + XCTAssertEqual(region3.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 10)) + + // Assert that there are no other regions XCTAssertEqual(result.count, 3) } - func testRedactChildrenOfRedactedView() { + func testRedactContainer_withContainerAsSubviewOfSensitiveView_shouldRedactAllViews() throws { + // -- Arrange -- class AnotherView: UIView {} - - let sut = getSut() + class RedactContainer: UIView {} let redactedLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 60, height: 60)) let redactedView = AnotherView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) redactedLabel.addSubview(redactedView) rootView.addSubview(redactedLabel) + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let preRedactResult = sut.redactRegionsFor(view: rootView) + + sut.setRedactContainerClass(RedactContainer.self) let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(preRedactResult.count, 0) + + // Assert that the redact container is redacted + let region = try XCTUnwrap(result.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 60, height: 60)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0)) + + // Assert that the redacted view is redacted + let region2 = try XCTUnwrap(result.element(at: 1)) + XCTAssertNil(region2.color) + XCTAssertEqual(region2.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region2.type, .redact) + XCTAssertEqual(region2.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions XCTAssertEqual(result.count, 2) } - func testRedactContainerHasPriorityOverIgnoreContainer() { + func testRedactContainerHasPriorityOverIgnoreContainer() throws { + // -- Arrange -- class IgnoreContainer: UIView {} class RedactContainer: UIView {} class AnotherView: UIView {} - let sut = getSut() - sut.setRedactContainerClass(RedactContainer.self) - let ignoreContainer = IgnoreContainer(frame: CGRect(x: 0, y: 0, width: 80, height: 80)) let redactContainer = RedactContainer(frame: CGRect(x: 0, y: 0, width: 60, height: 60)) let redactedView = AnotherView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) @@ -391,64 +868,122 @@ class SentryUIRedactBuilderTests: XCTestCase { ignoreContainer.addSubview(redactContainer) rootView.addSubview(ignoreContainer) + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + sut.setRedactContainerClass(RedactContainer.self) let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // Assert that the redact container is redacted + let region = try XCTUnwrap(result.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 60, height: 60)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0)) + + // Assert that the redacted view is redacted + let region2 = try XCTUnwrap(result.element(at: 1)) + XCTAssertNil(region2.color) + XCTAssertEqual(region2.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region2.type, .redact) + XCTAssertEqual(region2.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that the redacted view2 is redacted + let region3 = try XCTUnwrap(result.element(at: 2)) + XCTAssertNil(region3.color) + XCTAssertEqual(region3.size, CGSize(width: 5, height: 5)) + XCTAssertEqual(region3.type, .redact) + XCTAssertEqual(region3.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 15, ty: 15)) + + // Assert that the redacted view2 is redacted + let region4 = try XCTUnwrap(result.element(at: 2)) + XCTAssertNil(region4.color) + XCTAssertEqual(region4.size, CGSize(width: 5, height: 5)) + XCTAssertEqual(region4.type, .redact) + XCTAssertEqual(region4.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 15, ty: 15)) + + // Assert that there are no other regions XCTAssertEqual(result.count, 4) } - func testIgnoreView() { - class AnotherLabel: UILabel { - } + func testUnmaskView_withSensitiveView_shouldNotRedactView() { + // -- Arrange -- + class AnotherLabel: UILabel {} - let sut = getSut() let label = AnotherLabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) - SentrySDK.replay.unmaskView(label) rootView.addSubview(label) - - let result = sut.redactRegionsFor(view: rootView) - XCTAssertEqual(result.count, 0) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + + let preUnmaskResult = sut.redactRegionsFor(view: rootView) + SentrySDK.replay.unmaskView(label) + let postUnmaskResult = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(preUnmaskResult.count, 1) + XCTAssertEqual(postUnmaskResult.count, 0) } - func testRedactView() { - class AnotherView: UIView { - } + func testMaskView_withInsensitiveView_shouldRedactView() { + // -- Arrange -- + class AnotherView: UIView {} - let sut = getSut() let view = AnotherView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) - SentrySDK.replay.maskView(view) rootView.addSubview(view) - - let result = sut.redactRegionsFor(view: rootView) - XCTAssertEqual(result.count, 1) - } - - func testIgnoreViewWithExtension() { - class AnotherLabel: UILabel { - } - - let sut = getSut() - let label = AnotherLabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) - label.sentryReplayUnmask() - rootView.addSubview(label) - - let result = sut.redactRegionsFor(view: rootView) - XCTAssertEqual(result.count, 0) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + + let preMaskResult = sut.redactRegionsFor(view: rootView) + SentrySDK.replay.maskView(view) + let postMaskResult = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(preMaskResult.count, 0) + XCTAssertEqual(postMaskResult.count, 1) } - func testRedactViewWithExtension() { - class AnotherView: UIView { - } - - let sut = getSut() + func testMaskView_withSensitiveView_withViewExtension_shouldNotRedactView() { + // -- Arrange -- + class AnotherView: UIView {} + let view = AnotherView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) - view.sentryReplayMask() rootView.addSubview(view) - - let result = sut.redactRegionsFor(view: rootView) - XCTAssertEqual(result.count, 1) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + + let preMaskResult = sut.redactRegionsFor(view: rootView) + view.sentryReplayMask() + let postMaskResult = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(preMaskResult.count, 0) + XCTAssertEqual(postMaskResult.count, 1) } - + + func testUnmaskView_withSensitiveView_withViewExtension_shouldNotRedactView() { + // -- Arrange -- + class AnotherLabel: UILabel {} + + let label = AnotherLabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + rootView.addSubview(label) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + + let preUnmaskResult = sut.redactRegionsFor(view: rootView) + label.sentryReplayUnmask() + let postUnmaskResult = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(preUnmaskResult.count, 0) + XCTAssertEqual(postUnmaskResult.count, 1) + } + func testIgnoreViewsBeforeARootSizedView() { - let sut = getSut() + // -- Arrange -- let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) label.textColor = .purple rootView.addSubview(label) @@ -456,103 +991,18 @@ class SentryUIRedactBuilderTests: XCTestCase { let overView = UIView(frame: rootView.bounds) overView.backgroundColor = .black rootView.addSubview(overView) - - let result = sut.redactRegionsFor(view: rootView) - - XCTAssertEqual(result.count, 0) - } - - func testDefaultRedactList_shouldContainAllPlatformSpecificClasses() { - // -- Arrange -- - let expectedListClassNames = [ - // SwiftUI Views - "_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView", - "_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView", - "SwiftUI._UIGraphicsView", "SwiftUI.ImageLayer", - // Web Views - "UIWebView", "SFSafariView", "WKWebView", - // Text Views (incl. HybridSDK) - "UILabel", "UITextView", "UITextField", "RCTTextView", "RCTParagraphComponentView", - // Document Views - "PDFView", - // Image Views (incl. HybridSDK) - "UIImageView", "RCTImageView", - // Audio / Video Views - "AVPlayerView" - ] - - let expectedList = expectedListClassNames.map { className -> (String, ObjectIdentifier?) in - guard let classType = NSClassFromString(className) else { - print("Class \(className) not found, skipping test") - return (className, nil) - } - return (className, ObjectIdentifier(classType)) - } // -- Act -- - let sut = getSut() + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) // -- Assert -- - // Build sets of expected and actual identifiers for comparison - let expectedIdentifiers = Set(expectedList.compactMap { $0.1 }) - let actualIdentifiers = Set(sut.redactClassesIdentifiers) - - // Check for identifiers that are expected but missing in the actual result - let missingIdentifiers = expectedIdentifiers.subtracting(actualIdentifiers) - // Check for identifiers that are present in the actual result but not expected - let unexpectedIdentifiers = actualIdentifiers.subtracting(expectedIdentifiers) - - // For each expected class, check that if we expect the class identifier to be nil, it is nil - for (expectedClassName, expectedNullableIdentifier) in expectedList { - if expectedNullableIdentifier == nil { - // If we expect nil, assert that no identifier in the actual list matches the class name - let found = sut.redactClassesIdentifiers.contains { $0.debugDescription.contains(expectedClassName) } - XCTAssertFalse(found, "Class \(expectedClassName) not found in runtime, but it is present in the redact list") - } else { - // If we expect a non-nil identifier, assert that it is present in the actual list - XCTAssertTrue(sut.redactClassesIdentifiers.contains(where: { $0 == expectedNullableIdentifier }), "Expected class \(expectedClassName) not found in redact list") - } - } - - // Assert that there are no missing identifiers - XCTAssertTrue(missingIdentifiers.isEmpty, "Missing expected class identifiers: \(missingIdentifiers)") - - // Assert that there are no unexpected identifiers - for identifier in unexpectedIdentifiers { - // Try to get the class name from the identifier - let classCount = objc_getClassList(nil, 0) - var className = "" - if classCount > 0 { - let classes = UnsafeMutablePointer.allocate(capacity: Int(classCount)) - defer { classes.deallocate() } - let autoreleasingClasses = AutoreleasingUnsafeMutablePointer(classes) - let count = objc_getClassList(autoreleasingClasses, classCount) - for i in 0.. cameraView -> label @@ -1016,7 +1567,7 @@ class SentryUIRedactBuilderTests: XCTestCase { cameraView.addSubview(label) // -- Act -- - let sut = getSut() + let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) // -- Assert -- @@ -1032,63 +1583,55 @@ class SentryUIRedactBuilderTests: XCTestCase { // MARK: - Helper Methods - /// Creates a CameraUI.ChromeSwiftUIView instance for testing isViewSubtreeIgnored functionality. - /// - Parameters: - /// - frame: The frame to set for the created view - /// - Returns: The created CameraUI view - /// - Throws: XCTSkip if CameraUI is not available, or other errors if creation fails - private func createCameraUIView(frame: CGRect) throws -> UIView { + /// Creates an instance of ``CameraUI.ChromeSwiftUIView`` + /// + /// - Parameter frame: The frame to set for the created view + /// - Returns: The created CameraUI view or `nil` if the type is absent + private func createCameraUIView(frame: CGRect) throws -> UIView? { // Load the private framework indirectly by creating an instance of UIImagePickerController let _ = UIImagePickerController() - // Get the CameraUI.ChromeSwiftUIView class - let cameraViewClass: AnyClass - if #available(iOS 26.0, *) { - cameraViewClass = try XCTUnwrap( - NSClassFromString("CameraUI.ChromeSwiftUIView"), - "Test case expects the CameraUI.ChromeSwiftUIView class to exist" - ) - } else { - throw XCTSkip("Type CameraUI.ChromeSwiftUIView is not available on this platform") - } - - // Create an instance of the CameraUI view - let cameraView = try XCTUnwrap(class_createInstance(cameraViewClass, 0) as? UIView) - - // Reinitialize storage using UIView.initWithFrame(_:) - typealias InitWithFrame = @convention(c) (AnyObject, Selector, CGRect) -> AnyObject - let sel = NSSelectorFromString("initWithFrame:") - let m = try XCTUnwrap(class_getInstanceMethod(UIView.self, sel)) - let f = unsafeBitCast(method_getImplementation(m), to: InitWithFrame.self) - _ = f(cameraView, sel, .zero) - - // Configure the view frame - cameraView.frame = frame + // Create a fake view with the type + return try createFakeView( + type: UIView.self, + name: "CameraUI.ChromeSwiftUIView", + frame: frame + ) + } - return cameraView + /// Creates an instance of ``UIKit._UICollectionViewListLayoutSectionBackgroundColorDecorationView`` + /// + /// - Parameter frame: The frame to set for the created view + /// - Returns: The created view or `nil` if the type is absent + private func createCollectionViewListBackgroundDecorationView(frame: CGRect) throws -> UIView? { + return try createFakeView( + type: UIView.self, + name: "_UICollectionViewListLayoutSectionBackgroundColorDecorationView", + frame: frame + ) } - /// Creates a `_UICollectionViewListLayoutSectionBackgroundColorDecorationView` instance for tests. - /// - Parameter frame: Frame to assign after allocation and storage reinitialization - /// - Returns: The created decoration background view - /// - Throws: `XCTSkip` if the class is not available on the platform - private func createCollectionViewListBackgroundDecorationView(frame: CGRect) throws -> UIView { - // Obtain class at runtime – skip if unavailable - guard let decorationClass = NSClassFromString("_UICollectionViewListLayoutSectionBackgroundColorDecorationView") else { - throw XCTSkip("Decoration view class not available on this platform/runtime") + /// Creates a fake instance of a view for tests. + /// + /// - Parameter frame: The frame to set for the created view + /// - Returns: The created view or `nil` if the type is absent + private func createFakeView(type: T.Type, name: String, frame: CGRect) throws -> T? { + // Obtain class at runtime – return nil if unavailable + guard let viewClass = NSClassFromString(name) else { + return nil } // Allocate instance without calling subclass initializers - let decorationView = try XCTUnwrap(class_createInstance(decorationClass, 0) as? UIView) + let instance = try XCTUnwrap(class_createInstance(viewClass, 0) as? T) // Reinitialize storage using UIView.initWithFrame(_:) similar to other helpers typealias InitWithFrame = @convention(c) (AnyObject, Selector, CGRect) -> AnyObject let sel = NSSelectorFromString("initWithFrame:") let m = try XCTUnwrap(class_getInstanceMethod(UIView.self, sel)) let f = unsafeBitCast(method_getImplementation(m), to: InitWithFrame.self) - _ = f(decorationView, sel, frame) + _ = f(instance, sel, frame) - return decorationView + return instance } } From 3b84342572ba504b59fb8efe91742232485a5212 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 3 Oct 2025 09:20:53 +0200 Subject: [PATCH 05/24] WIP --- .../ViewCapture/SentryUIRedactBuilder.swift | 11 +- .../SentryUIRedactBuilderTests.swift | 1552 ++++++++++++----- .../ViewCapture/TestRedactOptions.swift | 11 +- 3 files changed, 1116 insertions(+), 458 deletions(-) diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index f3ec6ecb18a..d0e4dc66f15 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift @@ -110,6 +110,12 @@ final class SentryUIRedactBuilder { // The following classes are used by React Native to display text. // We are including them here to avoid leaking text from RN apps with manually initialized sentry-cocoa. + // Used by React Native to render short text + redactClasses.insert(ExtendedClassIdentifier(classId: "RCTTextView")) + + // Used by React Native to render long text + redactClasses.insert(ExtendedClassIdentifier(classId: "RCTParagraphComponentView")) + // Used by SwiftUI to render text without UIKit, e.g. `Text("Hello World")`. // We include the class name without a layer filter because it is specifically // used to draw text glyphs in this context. @@ -118,11 +124,6 @@ final class SentryUIRedactBuilder { // Used to render SwiftUI.Text on iOS versions prior to iOS 18 redactClasses.insert(ExtendedClassIdentifier(classId: "_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView")) - // Used by React Native to render short text - redactClasses.insert(ExtendedClassIdentifier(classId: "RCTTextView")) - - // Used by React Native to render long text - redactClasses.insert(ExtendedClassIdentifier(classId: "RCTParagraphComponentView")) } if options.maskAllImages { diff --git a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift index f05f99ee581..d8dadf68fad 100644 --- a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift +++ b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift @@ -6,6 +6,7 @@ import SafariServices @testable import Sentry import SentryTestUtils import UIKit +import WebKit import XCTest /* @@ -29,6 +30,11 @@ class RCTParagraphComponentView: UIView { class RCTImageView: UIView { } +// The following command was used to derive the view hierarchy: +// +// ``` +// (lldb) po rootView.value(forKey: "recursiveDescription")! +// ``` class SentryUIRedactBuilderTests: XCTestCase { private class CustomVisibilityView: UIView { class CustomLayer: CALayer { @@ -44,10 +50,11 @@ class SentryUIRedactBuilderTests: XCTestCase { private var rootView: UIView! - private func getSut(maskAllText: Bool, maskAllImages: Bool) -> SentryUIRedactBuilder { + private func getSut(maskAllText: Bool, maskAllImages: Bool, maskedViewClasses: [AnyClass] = []) -> SentryUIRedactBuilder { return SentryUIRedactBuilder(options: TestRedactOptions( - maskAllText: true, - maskAllImages: true + maskAllText: maskAllText, + maskAllImages: maskAllImages, + maskedViewClasses: maskedViewClasses )) } @@ -76,6 +83,11 @@ class SentryUIRedactBuilderTests: XCTestCase { label.textColor = .purple rootView.addSubview(label) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -98,6 +110,11 @@ class SentryUIRedactBuilderTests: XCTestCase { label.textColor = .purple.withAlphaComponent(0.5) // Any color with an opacity below 1.0 is considered transparent rootView.addSubview(label) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -113,13 +130,18 @@ class SentryUIRedactBuilderTests: XCTestCase { // Assert that there are no other regions XCTAssertEqual(result.count, 1) } - + func testRedact_withUILabel_withMaskAllTextDisabled_shouldNotRedactView() { // -- Arrange -- let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) label.textColor = .purple rootView.addSubview(label) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: false, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -135,6 +157,11 @@ class SentryUIRedactBuilderTests: XCTestCase { label.textColor = .purple rootView.addSubview(label) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: false) let result = sut.redactRegionsFor(view: rootView) @@ -151,6 +178,15 @@ class SentryUIRedactBuilderTests: XCTestCase { textView.textColor = .purple // Set a specific color so it's definitiely set rootView.addSubview(textView) + // View Hierarchy: + // --------------- + // > + // | ; backgroundColor = ; layer = ; contentOffset: {0, 0}; contentSize: {40, 32}; adjustedContentInset: {0, 0, 0, 0}> + // | | <_UITextLayoutView: 0x12dd0ba00; frame = (0 0; 0 0); layer = > + // | | <<_UITextContainerView: 0x12dd0b440; frame = (0 0; 40 30); backgroundColor = UIExtendedGrayColorSpace 0 0; layer = > minSize = {0, 0}, maxSize = {1.7976931348623157e+308, 1.7976931348623157e+308}, textContainer = ; exclusionPaths = 0x1e5cbb9d0; lineBreakMode = 0> + // | | | <_UITextLayoutCanvasView: 0x12dd0b680; frame = (0 0; 0 0); backgroundColor = UIExtendedGrayColorSpace 0 0; layer = > + // | | | | > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -167,18 +203,50 @@ class SentryUIRedactBuilderTests: XCTestCase { XCTAssertEqual(result.count, 1) } - func testRedact_withUITextView_withMaskAllTextDisabled_shouldNotRedactView() { + func testRedact_withUITextView_withMaskAllTextDisabled_shouldNotRedactView() throws { // -- Arrange -- let textView = UITextView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) textView.textColor = .purple // Set a specific color so it's definitiely set rootView.addSubview(textView) + // View Hierarchy: + // --------------- + // > + // | ; backgroundColor = ; layer = ; contentOffset: {0, 0}; contentSize: {40, 32}; adjustedContentInset: {0, 0, 0, 0}> + // | | <_UITextLayoutView: 0x12c506cb0; frame = (0 0; 0 0); layer = > + // | | <<_UITextContainerView: 0x1325078b0; frame = (0 0; 40 30); backgroundColor = UIExtendedGrayColorSpace 0 0; layer = > minSize = {0, 0}, maxSize = {1.7976931348623157e+308, 1.7976931348623157e+308}, textContainer = ; exclusionPaths = 0x1e5cbb9d0; lineBreakMode = 0> + // | | | <_UITextLayoutCanvasView: 0x132507af0; frame = (0 0; 0 0); backgroundColor = UIExtendedGrayColorSpace 0 0; layer = > + // | | | | > + // -- Act -- let sut = getSut(maskAllText: false, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) // -- Assert -- - XCTAssertEqual(result.count, 0) + let region1 = try XCTUnwrap(result.element(at: 0)) + // The text color of UITextView is not used for redaction + XCTAssertNil(region1.color) + XCTAssertEqual(region1.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region1.type, .clipBegin) + XCTAssertEqual(region1.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + let region2 = try XCTUnwrap(result.element(at: 1)) + // The text color of UITextView is not used for redaction + XCTAssertNil(region2.color) + XCTAssertEqual(region2.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region2.type, .clipEnd) + XCTAssertEqual(region2.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // The text view is marked as opaque and will therefore cause a clip out of its frame + let region3 = try XCTUnwrap(result.element(at: 2)) + // The text color of UITextView is not used for redaction + XCTAssertNil(region3.color) + XCTAssertEqual(region3.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region3.type, .clipOut) + XCTAssertEqual(region3.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 3) } func testRedact_withUITextView_withMaskAllImagesDisabled_shouldRedactView() throws { @@ -187,6 +255,15 @@ class SentryUIRedactBuilderTests: XCTestCase { textView.textColor = .purple // Set a specific color so it's definitiely set rootView.addSubview(textView) + // View Hierarchy: + // --------------- + // > + // | ; backgroundColor = ; layer = ; contentOffset: {0, 0}; contentSize: {40, 32}; adjustedContentInset: {0, 0, 0, 0}> + // | | <_UITextLayoutView: 0x104547bc0; frame = (0 0; 0 0); layer = > + // | | <<_UITextContainerView: 0x104547400; frame = (0 0; 40 30); backgroundColor = UIExtendedGrayColorSpace 0 0; layer = > minSize = {0, 0}, maxSize = {1.7976931348623157e+308, 1.7976931348623157e+308}, textContainer = ; exclusionPaths = 0x1e5cbb9d0; lineBreakMode = 0> + // | | | <_UITextLayoutCanvasView: 0x104547840; frame = (0 0; 0 0); backgroundColor = UIExtendedGrayColorSpace 0 0; layer = > + // | | | | > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -203,20 +280,33 @@ class SentryUIRedactBuilderTests: XCTestCase { textField.textColor = .purple // Set a specific color so it's definitiely set rootView.addSubview(textField) + // View Hierarchy: + // --------------- + // > + // | >; layer = > + // | | <_UITextLayoutCanvasView: 0x104241040; frame = (0 0; 0 0); layer = > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) // -- Assert -- - let region = try XCTUnwrap(result.first) - // The text color of UITextView is not used for redaction - XCTAssertNil(region.color) - XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) - XCTAssertEqual(region.type, .redact) - XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + let region1 = try XCTUnwrap(result.element(at: 0)) // _UITextLayoutCanvasView + // The text color of UITextView is not used for redaction + XCTAssertNil(region1.color) + XCTAssertEqual(region1.size, CGSize(width: 0, height: 0)) + XCTAssertEqual(region1.type, .redact) + XCTAssertEqual(region1.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + let region2 = try XCTUnwrap(result.element(at: 1)) // UITextField + // The text color of UITextView is not used for redaction + XCTAssertNil(region2.color) + XCTAssertEqual(region2.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region2.type, .redact) + XCTAssertEqual(region2.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) // Assert that there are no other regions - XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.count, 2) } func testRedact_withUITextField_withMaskAllTextDisabled_shouldNotRedactView() { @@ -225,6 +315,12 @@ class SentryUIRedactBuilderTests: XCTestCase { textField.textColor = .purple // Set a specific color so it's definitiely set rootView.addSubview(textField) + // View Hierarchy: + // --------------- + // > + // | >; layer = > + // | | <_UITextLayoutCanvasView: 0x104241040; frame = (0 0; 0 0); layer = > + // -- Act -- let sut = getSut(maskAllText: false, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -239,12 +335,18 @@ class SentryUIRedactBuilderTests: XCTestCase { textField.textColor = .purple // Set a specific color so it's definitiely set rootView.addSubview(textField) + // View Hierarchy: + // --------------- + // > + // | >; layer = > + // | | <_UITextLayoutCanvasView: 0x104241040; frame = (0 0; 0 0); layer = > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: false) let result = sut.redactRegionsFor(view: rootView) // -- Assert -- - XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.count, 2) } // MARK: - RCTTextView Redaction @@ -254,6 +356,11 @@ class SentryUIRedactBuilderTests: XCTestCase { let textView = RCTTextView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(textView) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -275,6 +382,11 @@ class SentryUIRedactBuilderTests: XCTestCase { let textView = RCTTextView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(textView) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: false, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -288,12 +400,17 @@ class SentryUIRedactBuilderTests: XCTestCase { let textView = RCTTextView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(textView) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: false) let result = sut.redactRegionsFor(view: rootView) // -- Assert -- - XCTAssertEqual(result.count, 0) + XCTAssertEqual(result.count, 1) } // MARK: - RCTParagraphComponentView Redaction @@ -303,6 +420,11 @@ class SentryUIRedactBuilderTests: XCTestCase { let textView = RCTParagraphComponentView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(textView) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -324,6 +446,11 @@ class SentryUIRedactBuilderTests: XCTestCase { let textView = RCTParagraphComponentView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(textView) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: false, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -337,6 +464,11 @@ class SentryUIRedactBuilderTests: XCTestCase { let textView = RCTParagraphComponentView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(textView) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: false) let result = sut.redactRegionsFor(view: rootView) @@ -345,6 +477,48 @@ class SentryUIRedactBuilderTests: XCTestCase { XCTAssertEqual(result.count, 1) } + // MARK: - SwiftUI.Text Redaction + + func testRedact_withSwiftUIText_withMaskAllTextEnabled_shouldRedactView() throws { + XCTFail("not implemented") + } + + func testRedact_withSwiftUIText_withMaskAllTextDisabled_shouldNotRedactView() { + XCTFail("not implemented") + } + + func testRedact_withSwiftUIText_withMaskAllImagesDisabled_shouldRedactView() { + XCTFail("not implemented") + } + + // MARK: - SwiftUI.Label Redaction + + func testRedact_withSwiftUILabel_withMaskAllTextEnabled_shouldRedactView() throws { + XCTFail("not implemented") + } + + func testRedact_withSwiftUILabel_withMaskAllTextDisabled_shouldNotRedactView() { + XCTFail("not implemented") + } + + func testRedact_withSwiftUILabel_withMaskAllImagesDisabled_shouldRedactView() { + XCTFail("not implemented") + } + + // MARK: - SwiftUI.List Redaction + + func testRedact_withSwiftUIList_withMaskAllTextEnabled_shouldRedactView() throws { + XCTFail("not implemented") + } + + func testRedact_withSwiftUIList_withMaskAllTextDisabled_shouldNotRedactView() { + XCTFail("not implemented") + } + + func testRedact_withSwiftUIList_withMaskAllImagesDisabled_shouldRedactView() { + XCTFail("not implemented") + } + // MARK: - UIImageView Redaction func testRedact_withUIImageView_withMaskAllImagesEnabled_shouldRedactView() throws { @@ -357,6 +531,11 @@ class SentryUIRedactBuilderTests: XCTestCase { imageView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) rootView.addSubview(imageView) + // View Hierarchy: + // --------------- + // > + // | ; layer = > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -383,6 +562,11 @@ class SentryUIRedactBuilderTests: XCTestCase { imageView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) rootView.addSubview(imageView) + // View Hierarchy: + // --------------- + // > + // | ; layer = > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: false) let result = sut.redactRegionsFor(view: rootView) @@ -401,6 +585,11 @@ class SentryUIRedactBuilderTests: XCTestCase { imageView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) rootView.addSubview(imageView) + // View Hierarchy: + // --------------- + // > + // | ; layer = > + // -- Act -- let sut = getSut(maskAllText: false, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -421,6 +610,35 @@ class SentryUIRedactBuilderTests: XCTestCase { imageView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) rootView.addSubview(imageView) + // View Hierarchy: + // --------------- + // > + // | ; layer = > + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(result.count, 0) + } + + func testUIImageViewSmallImage_shouldNotRedact() { + // -- Arrange -- + // Create a tiny image (below 10x10 threshold) + let tiny = UIGraphicsImageRenderer(size: CGSize(width: 5, height: 5)).image { ctx in + UIColor.black.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 5, height: 5)) + } + let imageView = UIImageView(image: tiny) + imageView.frame = CGRect(x: 10, y: 10, width: 20, height: 20) + rootView.addSubview(imageView) + + // View Hierarchy: + // --------------- + // > + // | ; layer = > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -436,6 +654,11 @@ class SentryUIRedactBuilderTests: XCTestCase { let imageView = RCTImageView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(imageView) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -451,12 +674,17 @@ class SentryUIRedactBuilderTests: XCTestCase { // Assert that there are no other regions XCTAssertEqual(result.count, 1) } - + func testRedact_withRCTImageView_withMaskAllImagesDisabled_shouldNotRedactView() { // -- Arrange -- let imageView = RCTImageView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(imageView) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: false) let result = sut.redactRegionsFor(view: rootView) @@ -470,6 +698,11 @@ class SentryUIRedactBuilderTests: XCTestCase { let imageView = RCTImageView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) rootView.addSubview(imageView) + // View Hierarchy: + // --------------- + // > + // | > + // -- Act -- let sut = getSut(maskAllText: false, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) @@ -478,82 +711,181 @@ class SentryUIRedactBuilderTests: XCTestCase { XCTAssertEqual(result.count, 1) } - // - MARK: - Sensitive Views + // - MARK: - SwiftUI.Image Redaction - func testRedact_withSensitiveView_shouldNotRedactHiddenView() throws { - // -- Arrange -- - // We use any view here we know that should be redacted - let ignoredLabel = UILabel(frame: CGRect(x: 20, y: 10, width: 5, height: 5)) - ignoredLabel.isHidden = true - rootView.addSubview(ignoredLabel) + func testRedact_withSwiftUIImage_withMaskAllImagesEnabled_shouldRedactView() throws { + XCTFail("not implemented") + } - let redactedLabel = UILabel(frame: CGRect(x: 20, y: 20, width: 8, height: 8)) - redactedLabel.isHidden = false - rootView.addSubview(redactedLabel) + func testRedact_withSwiftUIImage_withMaskAllImagesDisabled_shouldNotRedactView() { + XCTFail("not implemented") + } + + func testRedact_withSwiftUIImage_withMaskAllTextDisabled_shouldRedactView() { + XCTFail("not implemented") + } + // MARK: - PDF View + + func testRedact_withPDFView_withMaskingEnabled_shouldBeRedacted() throws { // -- Arrange -- + let pdfView = PDFView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + rootView.addSubview(pdfView) + + // View Hierarchy: + // --------------- + // > + // | ; backgroundColor = ; layer = > + // | | ; layer = ; contentOffset: {0, 0}; contentSize: {0, 0}; adjustedContentInset: {0, 0, 0, 0}> + + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) // -- Assert -- - // Only the redacted label will result in a region + let pdfRegion = try XCTUnwrap(result.element(at: 0)) + XCTAssertEqual(pdfRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(pdfRegion.type, .redact) + XCTAssertEqual(pdfRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + XCTAssertNil(pdfRegion.color) - let region = try XCTUnwrap(result.first) - // The text color of UITextView is not used for redaction - XCTAssertNil(region.color) - XCTAssertEqual(region.size, CGSize(width: 8, height: 8)) - XCTAssertEqual(region.type, .redact) - XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + let pdfScrollViewRegion = try XCTUnwrap(result.element(at: 1)) + XCTAssertEqual(pdfScrollViewRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(pdfScrollViewRegion.type, .redact) + XCTAssertEqual(pdfScrollViewRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + XCTAssertNil(pdfScrollViewRegion.color) - // Assert that there are no other regions - XCTAssertEqual(result.count, 1) + // Assert no additional regions + XCTAssertEqual(result.count, 2) } - func testRedact_withSensitiveView_shouldNotRedactFullyTransparentView() throws { + func testRedact_withPDFView_withMaskingDisabled_shouldBeRedacted() throws { // -- Arrange -- - // We use any view here we know that should be redacted - let fullyTransparentLabel = UILabel(frame: CGRect(x: 20, y: 10, width: 5, height: 5)) - fullyTransparentLabel.alpha = 0 - rootView.addSubview(fullyTransparentLabel) + let pdfView = PDFView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + rootView.addSubview(pdfView) - let transparentLabel = UILabel(frame: CGRect(x: 20, y: 15, width: 3, height: 3)) - transparentLabel.alpha = 0.5 - rootView.addSubview(transparentLabel) + // View Hierarchy: + // --------------- + // > + // | ; backgroundColor = ; layer = > + // | | ; layer = ; contentOffset: {0, 0}; contentSize: {0, 0}; adjustedContentInset: {0, 0, 0, 0}> - let nonTransparentLabel = UILabel(frame: CGRect(x: 20, y: 20, width: 8, height: 8)) - nonTransparentLabel.alpha = 1 - rootView.addSubview(nonTransparentLabel) + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: false) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(result.count, 2) + let pdfRegion = try XCTUnwrap(result.element(at: 0)) + XCTAssertEqual(pdfRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(pdfRegion.type, .redact) + XCTAssertEqual(pdfRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + XCTAssertNil(pdfRegion.color) + + let pdfScrollViewRegion = try XCTUnwrap(result.element(at: 1)) + XCTAssertEqual(pdfScrollViewRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(pdfScrollViewRegion.type, .redact) + XCTAssertEqual(pdfScrollViewRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + XCTAssertNil(pdfScrollViewRegion.color) + } + + // MARK: - WKWebView + func testRedact_withWKWebView_withMaskingEnabled_shouldRedactView() throws { // -- Arrange -- + let webView = WKWebView(frame: .init(x: 20, y: 20, width: 40, height: 40), configuration: .init()) + rootView.addSubview(webView) + + // View Hierarchy: + // --------------- + // > + // | > + // | | ; backgroundColor = kCGColorSpaceModelRGB 1 1 1 1; layer = ; contentOffset: {0, 0}; contentSize: {0, 0}; adjustedContentInset: {0, 0, 0, 0}> + // | | | > + // | | | | > + // | | | | | > + // | | | | > + // | | | <_UIScrollViewScrollIndicator: 0x105c33170; frame = (34 30; 3 7); alpha = 0; autoresize = LM; layer = > + // | | | | > + // | | | <_UIScrollViewScrollIndicator: 0x105c3eb90; frame = (30 34; 7 3); alpha = 0; autoresize = TM; layer = > + // | | | | > + + // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) // -- Assert -- - // Only the transparent and opaque label will result in regions, not the fully transparent one. + let region = try XCTUnwrap(result.first) // WKWebView + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) - let transparentLabelRegion = try XCTUnwrap(result.element(at: 0)) - XCTAssertNil(transparentLabelRegion.color) - XCTAssertEqual(transparentLabelRegion.size, CGSize(width: 3, height: 3)) - XCTAssertEqual(transparentLabelRegion.type, .redact) - XCTAssertEqual(transparentLabelRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 15)) + let region2 = try XCTUnwrap(result.first) // WKScrollView + XCTAssertNil(region2.color) + XCTAssertEqual(region2.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region2.type, .redact) + XCTAssertEqual(region2.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) - let nonTransparentLabelRegion = try XCTUnwrap(result.element(at: 1)) - XCTAssertNil(nonTransparentLabelRegion.color) - XCTAssertEqual(nonTransparentLabelRegion.size, CGSize(width: 8, height: 8)) - XCTAssertEqual(nonTransparentLabelRegion.type, .redact) - XCTAssertEqual(nonTransparentLabelRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + // Assert no additional regions + XCTAssertEqual(result.count, 2) + } - // Assert that there are no other regions + func testRedact_withWKWebView_withMaskingDisabled_shouldRedactView() throws { + // -- Arrange -- + let webView = WKWebView(frame: .init(x: 20, y: 20, width: 40, height: 40), configuration: .init()) + rootView.addSubview(webView) + + // View Hierarchy: + // --------------- + // > + // | > + // | | ; backgroundColor = kCGColorSpaceModelRGB 1 1 1 1; layer = ; contentOffset: {0, 0}; contentSize: {0, 0}; adjustedContentInset: {0, 0, 0, 0}> + // | | | > + // | | | | > + // | | | | | > + // | | | | > + // | | | <_UIScrollViewScrollIndicator: 0x105c33170; frame = (34 30; 3 7); alpha = 0; autoresize = LM; layer = > + // | | | | > + // | | | <_UIScrollViewScrollIndicator: 0x105c3eb90; frame = (30 34; 7 3); alpha = 0; autoresize = TM; layer = > + // | | | | > + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + let region = try XCTUnwrap(result.first) + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) // WKWebView + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + let region2 = try XCTUnwrap(result.first) + XCTAssertNil(region2.color) + XCTAssertEqual(region2.size, CGSize(width: 40, height: 40)) // WKScrollView + XCTAssertEqual(region2.type, .redact) + XCTAssertEqual(region2.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert no additional regions XCTAssertEqual(result.count, 2) } - // MARK: - Clipping + // MARK: - UIWebView - func testClipping_withOpaqueView_shouldClipOutRegion() throws { + func testRedact_withUIWebView_withMaskingEnabled_shouldRedactView() throws { // -- Arrange -- - let opaqueView = UIView(frame: CGRect(x: 10, y: 10, width: 60, height: 60)) - opaqueView.backgroundColor = .white - rootView.addSubview(opaqueView) + let webView = try XCTUnwrap(createFakeView( + type: UIView.self, + name: "UIWebView", + frame: .init(x: 20, y: 20, width: 40, height: 40) + )) + rootView.addSubview(webView) + + // View Hierarchy: + // --------------- + // > + // | > // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) @@ -561,60 +893,448 @@ class SentryUIRedactBuilderTests: XCTestCase { // -- Assert -- let region = try XCTUnwrap(result.first) - XCTAssertEqual(region.type, .clipOut) - XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 10)) + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) - // Assert that there are no other regions + // Assert no additional regions XCTAssertEqual(result.count, 1) } - - func testRedact_withLabelBehindATransparentView_shouldRedactLabel() throws { + + func testRedact_withUIWebView_withMaskingDisabled_shouldRedactView() throws { // -- Arrange -- - let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) - rootView.addSubview(label) + let webView = try XCTUnwrap(createFakeView( + type: UIView.self, + name: "UIWebView", + frame: .init(x: 20, y: 20, width: 40, height: 40) + )) + rootView.addSubview(webView) - let topView = UIView(frame: CGRect(x: 10, y: 10, width: 60, height: 60)) - topView.backgroundColor = .clear - rootView.addSubview(topView) + // View Hierarchy: + // --------------- + // > + // | > // -- Act -- - let sut = getSut(maskAllText: true, maskAllImages: true) + let sut = getSut(maskAllText: false, maskAllImages: false) let result = sut.redactRegionsFor(view: rootView) // -- Assert -- let region = try XCTUnwrap(result.first) - // The text color of UITextView is not used for redaction XCTAssertNil(region.color) - XCTAssertEqual(region.size, CGSize(width: 8, height: 8)) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) XCTAssertEqual(region.type, .redact) XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) - // Assert that there are no other regions + // Assert no additional regions XCTAssertEqual(result.count, 1) } - // MARK: - Class Ignoring + // MARK: - SFSafariView Redaction - func testAddIgnoreClasses_withSensitiveView_shouldNotRedactView() { + func testRedact_withSFSafariView_withMaskingEnabled_shouldRedactViewHierarchy() throws { +#if targetEnvironment(macCatalyst) + throw XCTSkip("SFSafariViewController opens system browser on macOS, nothing to redact, skipping test") +#else // -- Arrange -- - let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) - rootView.addSubview(label) + let safariViewController = SFSafariViewController(url: URL(string: "https://example.com")!) + let safariView = try XCTUnwrap(safariViewController.view) + safariView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) + rootView.addSubview(safariView) // -- Act -- let sut = getSut(maskAllText: true, maskAllImages: true) - - // Check that the pre-condition applies so this tests doesn't rely on other tests - let preIgnoreResult = sut.redactRegionsFor(view: rootView) - - sut.addIgnoreClass(UILabel.self) let result = sut.redactRegionsFor(view: rootView) // -- Assert -- - XCTAssertEqual(preIgnoreResult.count, 1) - XCTAssertEqual(result.count, 0) - } + if #available(iOS 17, *) { // iOS 17+ - // MARK: - Custom Class Redaction + // View Hierarchy: + // --------------- + // > + // | > + + let region = try XCTUnwrap(result.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 1) + } else if #available(iOS 15, *) { // iOS 15 & iOS 16 + + // View Hierarchy: + // --------------- + // > + // | > + // | | ; layer = > + // | | | > delegate=0x12e60f600 no-scroll-edge-support + // | | | > + + let toolbarRegion = try XCTUnwrap(result.element(at: 0)) // UIToolbar + XCTAssertNil(toolbarRegion.color) + XCTAssertEqual(toolbarRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(toolbarRegion.type, .redact) + XCTAssertEqual(toolbarRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + let navigationBarRegion = try XCTUnwrap(result.element(at: 1)) // UINavigationBar + XCTAssertNil(navigationBarRegion.color) + XCTAssertEqual(navigationBarRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(navigationBarRegion.type, .redact) + XCTAssertEqual(navigationBarRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + let placeholderRegion = try XCTUnwrap(result.element(at: 2)) // SFSafariLaunchPlaceholderView + XCTAssertNil(placeholderRegion.color) + XCTAssertEqual(placeholderRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(placeholderRegion.type, .redact) + XCTAssertEqual(toolbarRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + let vcRegion = try XCTUnwrap(result.element(at: 3)) // SFSafariView + XCTAssertNil(vcRegion.color) + XCTAssertEqual(vcRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(vcRegion.type, .redact) + XCTAssertEqual(vcRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 4) + } else { + throw XCTSkip("Redaction of SFSafariViewController is not tested on iOS versions below 15") + } +#endif + } + + func testRedact_withSFSafariView_withMaskingDisabled_shouldRedactView() throws { +#if targetEnvironment(macCatalyst) + throw XCTSkip("SFSafariViewController opens system browser on macOS, nothing to redact, skipping test") +#else + // -- Arrange -- + // SFSafariView should always be redacted for security reasons, + // regardless of maskAllText and maskAllImages settings + let safariViewController = SFSafariViewController(url: URL(string: "https://example.com")!) + let safariView = try XCTUnwrap(safariViewController.view) + safariView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) + rootView.addSubview(safariView) + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: false) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + if #available(iOS 17, *) { // iOS 17+ + + // View Hierarchy: + // --------------- + // > + // | > + + let region = try XCTUnwrap(result.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 1) + } else if #available(iOS 15, *) { // iOS 15 & iOS 16 + + // View Hierarchy: + // --------------- + // > + // | > + // | | ; layer = > + // | | | > delegate=0x12e60f600 no-scroll-edge-support + // | | | > + + let toolbarRegion = try XCTUnwrap(result.element(at: 0)) // UIToolbar + XCTAssertNil(toolbarRegion.color) + XCTAssertEqual(toolbarRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(toolbarRegion.type, .redact) + XCTAssertEqual(toolbarRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + let navigationBarRegion = try XCTUnwrap(result.element(at: 1)) // UINavigationBar + XCTAssertNil(navigationBarRegion.color) + XCTAssertEqual(navigationBarRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(navigationBarRegion.type, .redact) + XCTAssertEqual(navigationBarRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + let placeholderRegion = try XCTUnwrap(result.element(at: 2)) // SFSafariLaunchPlaceholderView + XCTAssertNil(placeholderRegion.color) + XCTAssertEqual(toolbarRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(placeholderRegion.type, .redact) + XCTAssertEqual(placeholderRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + let vcRegion = try XCTUnwrap(result.element(at: 3)) // SFSafariView + XCTAssertNil(vcRegion.color) + XCTAssertEqual(vcRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(vcRegion.type, .redact) + XCTAssertEqual(vcRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + + // Assert that there are no other regions + XCTAssertEqual(result.count, 4) + } else { + throw XCTSkip("Redaction of SFSafariViewController is not tested on iOS versions below 15") + } +#endif + } + + // MARK: - AVPlayer Redaction + + func testRedact_withAVPlayerViewController_shouldBeRedacted() throws { + // -- Arrange -- + let avPlayerViewController = AVPlayerViewController() + let avPlayerView = try XCTUnwrap(avPlayerViewController.view) + avPlayerView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) + rootView.addSubview(avPlayerView) + + // View Hierarchy: + // --------------- + // > + // | > + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertGreaterThanOrEqual(result.count, 1) + let avPlayerRegion = try XCTUnwrap(result.first) + XCTAssertEqual(avPlayerRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(avPlayerRegion.type, .redact) + XCTAssertEqual(avPlayerRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + XCTAssertNil(avPlayerRegion.color) + } + + func testRedact_withAVPlayerViewControllerEvenWithMaskingDisabled_shouldBeRedacted() throws { + // -- Arrange -- + // AVPlayerViewController should always be redacted for security reasons, + // regardless of maskAllText and maskAllImages settings + let avPlayerViewController = AVPlayerViewController() + let avPlayerView = try XCTUnwrap(avPlayerViewController.view) + avPlayerView.frame = CGRect(x: 20, y: 20, width: 40, height: 40) + rootView.addSubview(avPlayerView) + + // View Hierarchy: + // --------------- + // > + // | > + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: false) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertGreaterThanOrEqual(result.count, 1) + let avPlayerRegion = try XCTUnwrap(result.first) + XCTAssertEqual(avPlayerRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(avPlayerRegion.type, .redact) + XCTAssertEqual(avPlayerRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + XCTAssertNil(avPlayerRegion.color) + } + + func testRedact_withAVPlayerViewInViewHierarchy_shouldBeRedacted() throws { + // -- Arrange -- + let view = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 300)) + rootView.addSubview(view) + + let videoPlayerView = try XCTUnwrap(createFakeView( + type: UIView.self, + name: "AVPlayerView", + frame: .init(x: 20, y: 20, width: 360, height: 260) + )) + view.addSubview(videoPlayerView) + + // View Hierarchy: + // --------------- + // > + // | > + // | | > + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + let videoPlayerRegion = try XCTUnwrap(result.first) + XCTAssertEqual(videoPlayerRegion.size, CGSize(width: 360, height: 260)) + XCTAssertEqual(videoPlayerRegion.type, .redact) + XCTAssertEqual(videoPlayerRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + XCTAssertNil(videoPlayerRegion.color) + + // Assert there are no other regions + XCTAssertEqual(result.count, 1) + } + + // - MARK: - Sensitive Views + + func testRedact_withSensitiveView_shouldNotRedactHiddenView() throws { + // -- Arrange -- + // We use any view here we know that should be redacted + let ignoredLabel = UILabel(frame: CGRect(x: 20, y: 10, width: 5, height: 5)) + ignoredLabel.textColor = UIColor.red + ignoredLabel.isHidden = true + rootView.addSubview(ignoredLabel) + + let redactedLabel = UILabel(frame: CGRect(x: 20, y: 20, width: 8, height: 8)) + ignoredLabel.textColor = UIColor.blue + redactedLabel.isHidden = false + rootView.addSubview(redactedLabel) + + // View Hierarchy: + // --------------- + // > + // |