diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 78a4e2aa171..22e6f8d7868 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -743,6 +743,7 @@ A8F17B342902870300990B25 /* SentryHttpStatusCodeRange.m in Sources */ = {isa = PBXBuildFile; fileRef = A8F17B332902870300990B25 /* SentryHttpStatusCodeRange.m */; }; D4009EB22D771BC20007AF30 /* SentryFileIOTrackerSwiftHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4009EB12D771BB90007AF30 /* SentryFileIOTrackerSwiftHelpersTests.swift */; }; D41415A72DEEE532003B14D5 /* SentryRedactViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41415A62DEEE532003B14D5 /* SentryRedactViewHelper.swift */; }; + D4145CF62EC60B910066BBC6 /* SentryUIRedactBuilderTests+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4145CF52EC60B910066BBC6 /* SentryUIRedactBuilderTests+SwiftUI.swift */; }; D4237B3D2EB39D9700FE027C /* SentryDsn+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = D4237B3C2EB39D9700FE027C /* SentryDsn+Private.h */; settings = {ATTRIBUTES = (Private, ); }; }; D4291A6D2DD62ACE00772088 /* SentryDispatchFactoryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4291A6C2DD62AC800772088 /* SentryDispatchFactoryTests.m */; }; D42ADEEF2E9CF43200753166 /* SentrySessionReplayEnvironmentChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42ADEE92E9CF42800753166 /* SentrySessionReplayEnvironmentChecker.swift */; }; @@ -2103,6 +2104,7 @@ A8F17B332902870300990B25 /* SentryHttpStatusCodeRange.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryHttpStatusCodeRange.m; sourceTree = ""; }; D4009EB12D771BB90007AF30 /* SentryFileIOTrackerSwiftHelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryFileIOTrackerSwiftHelpersTests.swift; sourceTree = ""; }; D41415A62DEEE532003B14D5 /* SentryRedactViewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactViewHelper.swift; sourceTree = ""; }; + D4145CF52EC60B910066BBC6 /* SentryUIRedactBuilderTests+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryUIRedactBuilderTests+SwiftUI.swift"; sourceTree = ""; }; D41909922D48FFF6002B83D0 /* SentryNSDictionarySanitize+Tests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SentryNSDictionarySanitize+Tests.h"; sourceTree = ""; }; D41909942D490006002B83D0 /* SentryNSDictionarySanitize+Tests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SentryNSDictionarySanitize+Tests.m"; sourceTree = ""; }; D4237B3C2EB39D9700FE027C /* SentryDsn+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentryDsn+Private.h"; path = "include/SentryDsn+Private.h"; sourceTree = ""; }; @@ -4199,13 +4201,13 @@ D4009EA02D77196F0007AF30 /* ViewCapture */ = { isa = PBXGroup; children = ( - D4AF802E2E965188004F0F59 /* __Snapshots__ */, - D82915622C85EF0C00A6CDD4 /* SentryViewPhotographerTests.swift */, D8F67AF22BE10F7600C9197B /* SentryUIRedactBuilderTests.swift */, D4AF7D292E940492004F0F59 /* SentryUIRedactBuilderTests+Common.swift */, D4AF7D2B2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift */, - D4AF7D252E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift */, D4AF7D212E93FFCA004F0F59 /* SentryUIRedactBuilderTests+ReactNative.swift */, + D4145CF52EC60B910066BBC6 /* SentryUIRedactBuilderTests+SwiftUI.swift */, + D4AF7D252E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift */, + D82915622C85EF0C00A6CDD4 /* SentryViewPhotographerTests.swift */, D45E2D762E003EBF0072A6B7 /* TestRedactOptions.swift */, ); path = ViewCapture; @@ -4425,13 +4427,6 @@ path = InfoPlist; sourceTree = ""; }; - D4AF802E2E965188004F0F59 /* __Snapshots__ */ = { - isa = PBXGroup; - children = ( - ); - path = __Snapshots__; - sourceTree = ""; - }; D4CBA2522DE06D1600581618 /* SentryTestUtilsTests */ = { isa = PBXGroup; children = ( @@ -6421,6 +6416,7 @@ D8AE48C12C57B1550092A2A6 /* SentryLevelTests.swift in Sources */, 63FE721820DA66EC00CDBAE8 /* TestThread.m in Sources */, 7B4D308A26FC616B00C94DE9 /* SentryHttpTransportTests.swift in Sources */, + D4145CF62EC60B910066BBC6 /* SentryUIRedactBuilderTests+SwiftUI.swift in Sources */, 6276E68B2E7A779B002A4A8F /* SentryNetworkTrackerIntegrationTestServerTests.swift in Sources */, 7B4E23B6251A07BD00060D68 /* SentryDispatchQueueWrapperTests.swift in Sources */, 63FE720720DA66EC00CDBAE8 /* SentryCrashReportFilter_Tests.m in Sources */, @@ -6700,6 +6696,7 @@ baseConfigurationReference = 63AA75C51EB8B00100D153DE /* Sentry.xcconfig */; buildSettings = { CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES; + MACOSX_DEPLOYMENT_TARGET = 10.10.0; OTHER_CFLAGS = ( "-Wnullable-to-nonnull-conversion", "-Wnullability-completeness", @@ -6712,6 +6709,7 @@ baseConfigurationReference = 63AA75C51EB8B00100D153DE /* Sentry.xcconfig */; buildSettings = { CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES; + MACOSX_DEPLOYMENT_TARGET = 10.10.0; OTHER_CFLAGS = ( "-Wnullable-to-nonnull-conversion", "-Wnullability-completeness", @@ -6807,6 +6805,7 @@ baseConfigurationReference = 63AA75C51EB8B00100D153DE /* Sentry.xcconfig */; buildSettings = { CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES; + MACOSX_DEPLOYMENT_TARGET = 10.10.0; OTHER_CFLAGS = ( "-Wnullable-to-nonnull-conversion", "-Wnullability-completeness", @@ -6868,6 +6867,7 @@ baseConfigurationReference = 63AA75C51EB8B00100D153DE /* Sentry.xcconfig */; buildSettings = { CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES; + MACOSX_DEPLOYMENT_TARGET = 10.10.0; OTHER_CFLAGS = ( "-Wnullable-to-nonnull-conversion", "-Wnullability-completeness", @@ -7275,6 +7275,7 @@ baseConfigurationReference = 63AA75C51EB8B00100D153DE /* Sentry.xcconfig */; buildSettings = { CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES; + MACOSX_DEPLOYMENT_TARGET = 10.10.0; OTHER_CFLAGS = ( "-Wnullable-to-nonnull-conversion", "-Wnullability-completeness", @@ -7570,6 +7571,7 @@ baseConfigurationReference = 63AA75C51EB8B00100D153DE /* Sentry.xcconfig */; buildSettings = { CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES; + MACOSX_DEPLOYMENT_TARGET = 10.10.0; OTHER_CFLAGS = ( "-Wnullable-to-nonnull-conversion", "-Wnullability-completeness", diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index f4411453fa6..7e79bf1fa9b 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift @@ -143,6 +143,10 @@ final class SentryUIRedactBuilder { // Used to render SwiftUI.Text on iOS versions prior to iOS 18 redactClasses.insert(ClassIdentifier(classId: "_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView")) + + // Used by SwiftUI on iOS 26+ to render text as a layer directly (without a view wrapper) + // The layer class name is a mangled Swift name that includes CGDrawingLayer + redactClasses.insert(ClassIdentifier(classId: "_TtC7SwiftUIP33_863CCF9D49B535DAEB1C7D61BEE53B5914CGDrawingLayer")) } @@ -156,6 +160,9 @@ final class SentryUIRedactBuilder { // 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(ClassIdentifier(classId: "SwiftUI._UIGraphicsView", layerId: "SwiftUI.ImageLayer")) + + // On iOS 26+, SwiftUI.Image may use ImageLayer directly without a view wrapper + redactClasses.insert(ClassIdentifier(classId: "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. @@ -505,6 +512,66 @@ final class SentryUIRedactBuilder { )) } } + } else { + // Handle layers without view delegates (e.g., SwiftUI layers on iOS 26+) + // Check if the layer class itself matches any redact identifiers + let layerType = type(of: layer) + let layerTypeId = layerType.description() + + var layerMatchesRedactClass = false + + // Check if this layer type matches any of our redact class identifiers + // We check both the exact layer class and look for matches in our identifier set + for identifier in redactClassesIdentifiers { + // If the identifier has a layerId, check if it matches this layer + if let requiredLayerId = identifier.layerId { + if layerTypeId == requiredLayerId { + // This layer matches a constrained identifier (e.g., SwiftUI.ImageLayer) + redacting.append(SentryRedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: .redact, + color: nil, + name: layer.debugDescription + )) + enforceRedact = true + layerMatchesRedactClass = true + break + } + } else { + // Check if the layer class name matches the identifier + // This handles cases where we register layer classes directly + if layerTypeId == identifier.classId { + redacting.append(SentryRedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: .redact, + color: nil, + name: layer.debugDescription + )) + enforceRedact = true + layerMatchesRedactClass = true + break + } + } + } + + // If the layer doesn't match a redact class but is opaque, it should create a clipOut region + // This handles background layers (e.g., SwiftUI background colors on iOS 26+) + if !layerMatchesRedactClass && isLayerOpaque(layer) { + let finalLayerFrame = CGRect(origin: .zero, size: layer.bounds.size).applying(newTransform) + if isAxisAligned(newTransform) && finalLayerFrame == rootFrame { + // Because the current layer is covering everything we found so far we can clear `redacting` list + redacting.removeAll() + } else { + redacting.append(SentryRedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: .clipOut, + name: layer.debugDescription + )) + } + } } // Traverse the sublayers to redact them if necessary @@ -639,6 +706,30 @@ final class SentryUIRedactBuilder { /// This implementation fixes the issue where semi-transparent overlays (e.g., with `alpha = 0.2`) /// were incorrectly treated as opaque, causing text behind them to not be redacted. /// See: https://github.com/getsentry/sentry-cocoa/pull/6629#issuecomment-3479730690 + private func isLayerOpaque(_ layer: CALayer) -> Bool { + // Check layer opacity + guard layer.opacity == 1 else { + return false + } + + // For layers without view delegates, we check if they have an opaque background color + // Layers may not have isOpaque explicitly set, but if they have a solid background color, + // they effectively block content behind them + if let backgroundColor = layer.backgroundColor, backgroundColor.alpha == 1 { + // If the layer is explicitly marked as opaque, definitely treat it as opaque + if layer.isOpaque { + return true + } + // Even if not explicitly marked, a layer with an opaque background color + // that covers a non-zero area should be treated as opaque + if !layer.bounds.isEmpty { + return true + } + } + + return false + } + private func isOpaque(_ view: UIView) -> Bool { let layer = view.layer.presentation() ?? view.layer diff --git a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+SwiftUI.swift b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+SwiftUI.swift new file mode 100644 index 00000000000..2934e6088c5 --- /dev/null +++ b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+SwiftUI.swift @@ -0,0 +1,1035 @@ +#if os(iOS) +import AVKit +import Foundation +import PDFKit +import SafariServices +@_spi(Private) @testable import Sentry +import SentryTestUtils +import SwiftUI +import UIKit +import WebKit +import XCTest + +// The following command was used to derive the view hierarchy: +// +// ``` +// (lldb) po rootView.value(forKey: "recursiveDescription")! +// ``` +class SentryUIRedactBuilderTests_SwiftUI: SentryUIRedactBuilderTests { // swiftlint:disable:this type_name + private func getSut(maskAllText: Bool, maskAllImages: Bool, maskedViewClasses: [AnyClass] = []) -> SentryUIRedactBuilder { + return SentryUIRedactBuilder(options: TestRedactOptions( + maskAllText: maskAllText, + maskAllImages: maskAllImages, + maskedViewClasses: maskedViewClasses + )) + } + + // MARK: - SwiftUI.Text Redaction + + private func setupSwiftUITextFixture() throws -> UIWindow { + let view = VStack { + VStack { + Text("Hello SwiftUI") + .padding(20) + } + .background(Color.green) + .font(.system(size: 20)) // Use a fixed font size as defaults could change frame + } + return hostSwiftUIViewInWindow(view, frame: CGRect(x: 0, y: 0, width: 250, height: 250)) + + // View Hierarchy: + // --------------- + // == iOS 26 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackGVS_15ModifiedContentGS1_GS2_VS_4TextVS_14_PaddingLayout__GVS_24_BackgroundStyleModifierVS_5Color____: 0x10900bc00; frame = (0 0; 120 60); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | (layer) + // | | | | <_TtC7SwiftUIP33_863CCF9D49B535DAEB1C7D61BEE53B5914CGDrawingLayer: 0x600002c21e80> (layer) + // + // == iOS 18 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackGVS_15ModifiedContentGS1_GS2_VS_4TextVS_14_PaddingLayout__GVS_24_BackgroundStyleModifierVS_5Color____: 0x104c27a00; frame = (0 0; 120 60); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | > + // | | | | > + // + // == iOS 17 & 16 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackGVS_15ModifiedContentGS2_GS1_GS2_VS_4TextVS_14_PaddingLayout__GVS_24_BackgroundStyleModifierVS_5Color__GVS_30_EnvironmentKeyWritingModifierGSqVS_4Font_____: 0x14781e800; frame = (0 0; 250 250); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | > + // | | | | <_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView: 0x141204280; frame = (68.6667 142.667; 112.667 24); anchorPoint = (0, 0); opaque = NO; autoresizesSubviews = NO; layer = <_TtCOCV7SwiftUI11DisplayList11ViewUpdater8PlatformP33_65A81BD07F0108B0485D2E15DE104A7514CGDrawingLayer: 0x600000228000>> + } + + private func assertSwiftUITextRegions(regions: [SentryRedactRegion]) throws { + let region = try XCTUnwrap(regions.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.type, .redact) + XCTAssertCGSizeEqual(region.size, CGSize(width: 112.666, height: 24), accuracy: 0.01) + if #available(iOS 18, *) { + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 68.666, ty: 144), + accuracy: 0.01 + ) + } else { + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 68.666, ty: 142.666), + accuracy: 0.01 + ) + } + + let region2 = try XCTUnwrap(regions.element(at: 1)) + XCTAssertNil(region2.color) + XCTAssertEqual(region2.type, .clipOut) + XCTAssertCGSizeEqual(region2.size, CGSize(width: 152.666, height: 64), accuracy: 0.01) + if #available(iOS 18, *) { + XCTAssertAffineTransformEqual( + region2.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 48.666, ty: 124), + accuracy: 0.01 + ) + } else { + XCTAssertAffineTransformEqual( + region2.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 48.666, ty: 122.666), + accuracy: 0.01 + ) + } + + // Assert that there are no other regions + XCTAssertEqual(regions.count, 2) + } + + func testRedact_withSwiftUIText_withMaskAllTextEnabled_shouldRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUITextFixture() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUITextRegions(regions: result) + } + + func testRedact_withSwiftUIText_withMaskAllTextDisabled_shouldNotRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUITextFixture() + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + let region = try XCTUnwrap(result.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.type, .clipOut) + XCTAssertCGSizeEqual(region.size, CGSize(width: 152.666, height: 64), accuracy: 0.01) + if #available(iOS 26, *) { + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 48.666, ty: 124), + accuracy: 0.01 + ) + } else { + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 48.666, ty: 122.666), + accuracy: 0.01 + ) + } + + // Assert no other regions + XCTAssertEqual(result.count, 1) + } + + func testRedact_withSwiftUIText_withMaskAllImagesDisabled_shouldRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUITextFixture() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: false) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUITextRegions(regions: result) + } + + // MARK: - SwiftUI.Label Redaction + + private func setupSwiftUILabelFixture() throws -> UIWindow { + let view = VStack { + Label("Hello SwiftUI", systemImage: "house") + .labelStyle(.titleAndIcon) + } + return hostSwiftUIViewInWindow(view, frame: CGRect(x: 0, y: 0, width: 300, height: 300)) + + // View Hierarchy: + // --------------- + // == iOS 26 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackGVS_15ModifiedContentGVS_5LabelVS_4TextVS_5Image_GVS_P10$1d976f51025LabelStyleWritingModifierVS_22TitleAndIconLabelStyle____: 0x107853850; frame = (0 0; 120 60); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | (layer) + // | | | | <_TtC7SwiftUIP33_863CCF9D49B535DAEB1C7D61BEE53B5914CGDrawingLayer: 0x600002c26680> (layer) + // + // == iOS 18 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackGVS_15ModifiedContentGVS_5LabelVS_4TextVS_5Image_GVS_P10$1d433610c25LabelStyleWritingModifierVS_22TitleAndIconLabelStyle____: 0x1049414e0; frame = (0 0; 120 60); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | > + // | | | | > + // + // == iOS 17 & 16 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackGVS_15ModifiedContentGVS_5LabelVS_4TextVS_5Image_GVS_P10$11e37ba8025LabelStyleWritingModifierVS_22TitleAndIconLabelStyle____: 0x130524b10; frame = (0 0; 300 300); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | > + // | | | | <_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView: 0x14073f040; frame = (117 169.333; 98 20.3333); anchorPoint = (0, 0); opaque = NO; autoresizesSubviews = NO; layer = <_TtCOCV7SwiftUI11DisplayList11ViewUpdater8PlatformP33_65A81BD07F0108B0485D2E15DE104A7514CGDrawingLayer: 0x6000028e1020>> + } + + private func assertSwiftUILabelRegions(regions: [SentryRedactRegion], expectText: Bool, expectImage: Bool) throws { + func assertTextRegion(region: SentryRedactRegion) { + XCTAssertNil(region.color) + XCTAssertEqual(region.type, .redact) + XCTAssertCGSizeEqual(region.size, CGSize(width: 98, height: 20.333), accuracy: 0.01) + if #available(iOS 26, *) { + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 117, ty: 171), + accuracy: 0.01 + ) + } else { + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 117, ty: 169.333), + accuracy: 0.01 + ) + } + } + + func assertImageRegion(region: SentryRedactRegion) { + XCTAssertNil(region.color) + XCTAssertEqual(region.type, .redact) + XCTAssertCGSizeEqual(region.size, CGSize(width: 20, height: 17.666), accuracy: 0.01) + if #available(iOS 26, *) { + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 87, ty: 172.333), + accuracy: 0.01 + ) + } else { + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 87, ty: 170.333), + accuracy: 0.01 + ) + } + } + + if expectText && expectImage { + assertTextRegion(region: try XCTUnwrap(regions.element(at: 0))) + assertImageRegion(region: try XCTUnwrap(regions.element(at: 1))) + + // Assert that there are no other regions + XCTAssertEqual(regions.count, 2) + } else if expectText { + assertTextRegion(region: try XCTUnwrap(regions.element(at: 0))) + + // Assert that there are no other regions + XCTAssertEqual(regions.count, 1) + } else if expectImage { + assertImageRegion(region: try XCTUnwrap(regions.element(at: 0))) + + // Assert that there are no other regions + XCTAssertEqual(regions.count, 1) + } else { + // Assert that there are no other regions + XCTAssertEqual(regions.count, 0) + } + } + + @available(iOS 14.5, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + func testRedact_withSwiftUILabel_withMaskAllTextEnabled_withMaskAllImagesEnabled_shouldRedactTextAndImage() throws { + // -- Arrange -- + let window = try setupSwiftUILabelFixture() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUILabelRegions(regions: result, expectText: true, expectImage: true) + } + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + func testRedact_withSwiftUILabel_withMaskAllTextEnabled_withMaskAllImagesDisabled_shouldRedactText() throws { + // -- Arrange -- + let window = try setupSwiftUILabelFixture() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: false) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUILabelRegions(regions: result, expectText: true, expectImage: false) + } + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + func testRedact_withSwiftUILabel_withMaskAllTextDisabled_withMaskAllImagesEnabled_shouldRedactImage() throws { + // -- Arrange -- + let window = try setupSwiftUILabelFixture() + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUILabelRegions(regions: result, expectText: false, expectImage: true) + } + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + func testRedact_withSwiftUILabel_withMaskAllTextDisabled_withMaskAllImagesDisabled_shouldRedactText() throws { + // -- Arrange -- + let window = try setupSwiftUILabelFixture() + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: false) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUILabelRegions(regions: result, expectText: false, expectImage: false) + } + + // MARK: - SwiftUI.List Redaction + + private func setupSwiftUIListFixture() throws -> UIWindow { + let view = VStack { + List { + Section("Section 1") { + Text("Item 1") + } + Section { + Text("Item 2") + } + } + } + return hostSwiftUIViewInWindow(view, frame: CGRect(x: 0, y: 0, width: 300, height: 500)) + + // View Hierarchy: + // --------------- + // === 16 === + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackGVS_4ListOs5NeverGVS_9TupleViewTGVS_7SectionVS_4TextS6_VS_9EmptyView_GS5_S7_S6_S7_______: 0x12f811200; frame = (0 0; 300 500); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | <_TtGC7SwiftUI16PlatformViewHostGVS_P10$111818dc817ListRepresentableGVS_28CollectionViewListDataSourceOs5Never_GOS_19SelectionManagerBoxS3____: 0x12f514910; baseClass = _UIConstraintBasedLayoutHostingView; frame = (0 0; 300 500); anchorPoint = (0, 0); tintColor = UIExtendedSRGBColorSpace 0 0.478431 1 1; layer = > + // | | | | | ; backgroundColor = ; layer = ; contentOffset: {0, -59}; contentSize: {300, 182}; adjustedContentInset: {59, 0, 0, 0}; layout: ; dataSource: <_TtGC7SwiftUI31UICollectionViewListCoordinatorGVS_28CollectionViewListDataSourceOs5Never_GOS_19SelectionManagerBoxS2___: 0x15f549e70>> + // | | | | | | <_UICollectionViewListLayoutSectionBackgroundColorDecorationView: 0x12f514bc0; frame = (-16 -1000; 332 1100.33); userInteractionEnabled = NO; backgroundColor = ; layer = > + // | | | | | | <_UICollectionViewListLayoutSectionBackgroundColorDecorationView: 0x12f512940; frame = (-16 100.333; 332 1081.67); userInteractionEnabled = NO; backgroundColor = ; layer = > + // | | | | | | > + // | | | | | | | <_UISystemBackgroundView: 0x14f5544e0; frame = (0 0; 268 44); layer = ; configuration = >> + // | | | | | | | | ; layer = > + // | | | | | | | <_UICollectionViewListCellContentView: 0x14f553b80; frame = (0 0; 268 44); gestureRecognizers = ; layer = > + // | | | | | | | | <_TtGC7SwiftUI15CellHostingViewGVS_15ModifiedContentVS_14_ViewList_ViewVS_26CollectionViewCellModifier__: 0x13f81a800; frame = (0 0; 268 44); autoresize = W+H; gestureRecognizers = ; layer = > + // | | | | | | | | | <_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView: 0x13a9053c0; frame = (16 12; 45.3333 20.3333); anchorPoint = (0, 0); opaque = NO; autoresizesSubviews = NO; layer = <_TtCOCV7SwiftUI11DisplayList11ViewUpdater8PlatformP33_65A81BD07F0108B0485D2E15DE104A7514CGDrawingLayer: 0x600001500540>> + // | | | | | | > + // | | | | | | | <_UISystemBackgroundView: 0x13f624100; frame = (0 0; 268 44); layer = ; configuration = >> + // | | | | | | | | ; layer = > + // | | | | | | | <_UICollectionViewListCellContentView: 0x13f623da0; frame = (0 0; 268 44); gestureRecognizers = ; layer = > + // | | | | | | | | <_TtGC7SwiftUI15CellHostingViewGVS_15ModifiedContentVS_14_ViewList_ViewVS_26CollectionViewCellModifier__: 0x14802f600; frame = (0 0; 268 44); autoresize = W+H; gestureRecognizers = ; layer = > + // | | | | | | | | | <_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView: 0x13a904cf0; frame = (16 12; 47.6667 20.3333); anchorPoint = (0, 0); opaque = NO; autoresizesSubviews = NO; layer = <_TtCOCV7SwiftUI11DisplayList11ViewUpdater8PlatformP33_65A81BD07F0108B0485D2E15DE104A7514CGDrawingLayer: 0x6000015000c0>> + // | | | | | | > + // | | | | | | | <_UISystemBackgroundView: 0x13f6274d0; frame = (0 0; 268 38.6667); layer = ; configuration = > + // | | | | | | | <_UICollectionViewListCellContentView: 0x13f626f00; frame = (0 0; 268 38.6667); gestureRecognizers = ; layer = > + // | | | | | | | | <_TtGC7SwiftUI15CellHostingViewGVS_15ModifiedContentVS_14_ViewList_ViewVS_26CollectionViewCellModifier__: 0x148026000; frame = (0 0; 268 38.6667); autoresize = W+H; gestureRecognizers = ; layer = > + // | | | | | | | | | <_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView: 0x13f521ef0; frame = (16 17; 65.6667 15.6667); anchorPoint = (0, 0); opaque = NO; autoresizesSubviews = NO; layer = <_TtCOCV7SwiftUI11DisplayList11ViewUpdater8PlatformP33_65A81BD07F0108B0485D2E15DE104A7514CGDrawingLayer: 0x60000150d020>> + // | | | | | | <_UIScrollViewScrollIndicator: 0x14f525ce0; frame = (294 431; 3 7); alpha = 0; autoresize = LM; layer = > + // | | | | | | | > + // | | | | | | <_UIScrollViewScrollIndicator: 0x14f551a80; frame = (290 435; 7 3); alpha = 0; autoresize = TM; layer = > + // | | | | | | | > + } + + private func assertSwiftUIListRegions(regions: [SentryRedactRegion], expectText: Bool) throws { + var offset = 0 + + // iOS 26 has different layout - cells are taller (52 vs 44) and coordinates differ + let isIOS26 = if #available(iOS 26, *) { true } else { false } + + let region0 = try XCTUnwrap(regions.element(at: offset + 0)) // clipBegin for main collection view + XCTAssertNil(region0.color) + XCTAssertCGSizeEqual(region0.size, CGSize(width: 300, height: 500), accuracy: 0.01) + XCTAssertEqual(region0.type, .clipBegin) + XCTAssertAffineTransformEqual( + region0.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0), + accuracy: 0.01 + ) + + if expectText { + let region1 = try XCTUnwrap(regions.element(at: offset + 1)) // redact for first cell's text + XCTAssertNil(region1.color) + if isIOS26 { + XCTAssertCGSizeEqual(region1.size, CGSize(width: 72.3333, height: 20.3333), accuracy: 0.01) + XCTAssertEqual(region1.type, .redact) + XCTAssertAffineTransformEqual( + region1.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 32, ty: 72), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region1.size, CGSize(width: 65.6667, height: 15.6667), accuracy: 0.01) + XCTAssertEqual(region1.type, .redact) + XCTAssertAffineTransformEqual( + region1.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 32, ty: 76), + accuracy: 0.01 + ) + } + offset += 1 + } + + let region2 = try XCTUnwrap(regions.element(at: offset + 1)) // clipBegin for second cell + XCTAssertNil(region2.color) + if isIOS26 { + XCTAssertCGSizeEqual(region2.size, CGSize(width: 268, height: 52), accuracy: 0.01) + XCTAssertEqual(region2.type, .clipBegin) + XCTAssertAffineTransformEqual( + region2.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 189.333), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region2.size, CGSize(width: 268, height: 44), accuracy: 0.01) + XCTAssertEqual(region2.type, .clipBegin) + XCTAssertAffineTransformEqual( + region2.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 177), + accuracy: 0.01 + ) + } + + if expectText { + let region3 = try XCTUnwrap(regions.element(at: offset + 2)) // redact for second cell's text + XCTAssertNil(region3.color) + XCTAssertCGSizeEqual(region3.size, CGSize(width: 47.6667, height: 20.3333), accuracy: 0.01) + XCTAssertEqual(region3.type, .redact) + if isIOS26 { + XCTAssertAffineTransformEqual( + region3.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 32, ty: 205.333), + accuracy: 0.01 + ) + } else { + XCTAssertAffineTransformEqual( + region3.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 32, ty: 189), + accuracy: 0.01 + ) + } + offset += 1 + } + + let region4 = try XCTUnwrap(regions.element(at: offset + 2)) // clipOut for second cell + XCTAssertNil(region4.color) + if isIOS26 { + XCTAssertCGSizeEqual(region4.size, CGSize(width: 268, height: 52), accuracy: 0.01) + XCTAssertEqual(region4.type, .clipOut) + XCTAssertAffineTransformEqual( + region4.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 189.333), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region4.size, CGSize(width: 268, height: 44), accuracy: 0.01) + XCTAssertEqual(region4.type, .clipOut) + XCTAssertAffineTransformEqual( + region4.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 177), + accuracy: 0.01 + ) + } + + let region5 = try XCTUnwrap(regions.element(at: offset + 3)) // clipEnd for second cell + XCTAssertNil(region5.color) + if isIOS26 { + XCTAssertCGSizeEqual(region5.size, CGSize(width: 268, height: 52), accuracy: 0.01) + XCTAssertEqual(region5.type, .clipEnd) + XCTAssertAffineTransformEqual( + region5.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 189.333), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region5.size, CGSize(width: 268, height: 44), accuracy: 0.01) + XCTAssertEqual(region5.type, .clipEnd) + XCTAssertAffineTransformEqual( + region5.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 177), + accuracy: 0.01 + ) + } + + let region6 = try XCTUnwrap(regions.element(at: offset + 4)) // clipBegin for first cell + XCTAssertNil(region6.color) + if isIOS26 { + XCTAssertCGSizeEqual(region6.size, CGSize(width: 268, height: 52), accuracy: 0.01) + XCTAssertEqual(region6.type, .clipBegin) + XCTAssertAffineTransformEqual( + region6.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 102.333), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region6.size, CGSize(width: 268, height: 44), accuracy: 0.01) + XCTAssertEqual(region6.type, .clipBegin) + XCTAssertAffineTransformEqual( + region6.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 97.6667), + accuracy: 0.01 + ) + } + + if expectText { + let region7 = try XCTUnwrap(regions.element(at: offset + 5)) // redact for first cell's text + XCTAssertNil(region7.color) + XCTAssertCGSizeEqual(region7.size, CGSize(width: 45.3333, height: 20.3333), accuracy: 0.01) + XCTAssertEqual(region7.type, .redact) + if isIOS26 { + XCTAssertAffineTransformEqual( + region7.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 32, ty: 118.333), + accuracy: 0.01 + ) + } else { + XCTAssertAffineTransformEqual( + region7.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 32, ty: 109.6667), + accuracy: 0.01 + ) + } + offset += 1 + } + + let region8 = try XCTUnwrap(regions.element(at: offset + 5)) // clipOut for first cell + XCTAssertNil(region8.color) + if isIOS26 { + XCTAssertCGSizeEqual(region8.size, CGSize(width: 268, height: 52), accuracy: 0.01) + XCTAssertEqual(region8.type, .clipOut) + XCTAssertAffineTransformEqual( + region8.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 102.333), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region8.size, CGSize(width: 268, height: 44), accuracy: 0.01) + XCTAssertEqual(region8.type, .clipOut) + XCTAssertAffineTransformEqual( + region8.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 97.6667), + accuracy: 0.01 + ) + } + + let region9 = try XCTUnwrap(regions.element(at: offset + 6)) // clipEnd for first cell + XCTAssertNil(region9.color) + if isIOS26 { + XCTAssertCGSizeEqual(region9.size, CGSize(width: 268, height: 52), accuracy: 0.01) + XCTAssertEqual(region9.type, .clipEnd) + XCTAssertAffineTransformEqual( + region9.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 102.333), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region9.size, CGSize(width: 268, height: 44), accuracy: 0.01) + XCTAssertEqual(region9.type, .clipEnd) + XCTAssertAffineTransformEqual( + region9.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 16, ty: 97.6667), + accuracy: 0.01 + ) + } + + let region10 = try XCTUnwrap(regions.element(at: offset + 7)) // redact for section background (bottom) + XCTAssertNil(region10.color) + if isIOS26 { + XCTAssertCGSizeEqual(region10.size, CGSize(width: 332, height: 1_110), accuracy: 0.01) + XCTAssertEqual(region10.type, .redact) + XCTAssertAffineTransformEqual( + region10.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: -16, ty: 172), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region10.size, CGSize(width: 332, height: 1_081.6667), accuracy: 0.01) + XCTAssertEqual(region10.type, .redact) + XCTAssertAffineTransformEqual( + region10.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: -16, ty: 159.3333), + accuracy: 0.01 + ) + } + + let region11 = try XCTUnwrap(regions.element(at: offset + 8)) // redact for section background (top) + XCTAssertNil(region11.color) + if isIOS26 { + XCTAssertCGSizeEqual(region11.size, CGSize(width: 300, height: 500), accuracy: 0.01) + XCTAssertEqual(region11.type, .redact) + XCTAssertAffineTransformEqual( + region11.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region11.size, CGSize(width: 332, height: 1_100.3333), accuracy: 0.01) + XCTAssertEqual(region11.type, .redact) + XCTAssertAffineTransformEqual( + region11.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: -16, ty: -941), + accuracy: 0.01 + ) + } + + let region12 = try XCTUnwrap(regions.element(at: offset + 9)) // clipEnd for main collection view + XCTAssertNil(region12.color) + XCTAssertCGSizeEqual(region12.size, CGSize(width: 300, height: 500), accuracy: 0.01) + XCTAssertEqual(region12.type, .clipEnd) + XCTAssertAffineTransformEqual( + region12.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0), + accuracy: 0.01 + ) + + // Assert that there are no other regions + XCTAssertEqual(regions.count, offset + 10) + } + + func testRedact_withSwiftUIList_withMaskAllTextEnabled_shouldRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUIListFixture() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUIListRegions(regions: result, expectText: true) + } + + func testRedact_withSwiftUIList_withMaskAllTextDisabled_withMaskAllImagesEnabled_shouldNotRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUIListFixture() + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUIListRegions(regions: result, expectText: false) + } + + func testRedact_withSwiftUIList_withMaskAllTextEnabled_withMaskAllImagesDisabled_shouldNotRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUIListFixture() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUIListRegions(regions: result, expectText: true) + } + + func testRedact_withSwiftUIList_withMaskAllTextDisabled_withMaskAllImagesDisabled_shouldNotRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUIListFixture() + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: false) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUIListRegions(regions: result, expectText: false) + } + + // MARK: Ignore background view + + 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. + guard let decorationView = try createCollectionViewListBackgroundDecorationView(frame: .zero) else { + throw XCTSkip("UICollectionView background decoration view is not available") + } + + // Configure a very large frame similar to what we see in production + decorationView.frame = CGRect(x: -20, y: -1_100, width: 440, height: 2_300) + 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 = "Sample Text" + + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 402, height: 874)) + rootView.addSubview(decorationView) + rootView.addSubview(titleLabel) + + // View Hierarchy: + // --------------- + // > + // | <_UICollectionViewListLayoutSectionBackgroundColorDecorationView: 0x119044de0; frame = (-20 -1100; 440 2300); backgroundColor = ; layer = > + // | > + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + 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 })) + } + + // - MARK: - SwiftUI.Image Redaction - SFSymbol + + private func setupSwiftUIImageFixture() throws -> UIWindow { + let view = VStack { + Image(systemName: "star.fill") + } + return hostSwiftUIViewInWindow(view, frame: CGRect(x: 20, y: 20, width: 240, height: 320)) + + // View Hierarchy: + // --------------- + // == iOS 26 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackVS_5Image__: 0x107623670; frame = (0 0; 0 0); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | (layer) + // + // == iOS 18 & 17 & 16 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackVS_5Image__: 0x13dd2fe30; frame = (0 0; 240 320); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | > + } + + private func assertSwiftUIImageRegions(regions: [SentryRedactRegion]) throws { + let region = try XCTUnwrap(regions.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.type, .redact) + + if #available(iOS 26, *) { + XCTAssertCGSizeEqual(region.size, CGSize(width: 18.666, height: 18), accuracy: 0.01) + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 130.666, ty: 192), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region.size, CGSize(width: 18.666, height: 18), accuracy: 0.01) + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 130.666, ty: 190.666), + accuracy: 0.01 + ) + } + + // Assert that there are no other regions + XCTAssertEqual(regions.count, 1) + } + + func testRedact_withSwiftUIImage_withMaskAllImagesEnabled_shouldRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUIImageFixture() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUIImageRegions(regions: result) + } + + func testRedact_withSwiftUIImage_withMaskAllImagesDisabled_shouldNotRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUIImageFixture() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: false) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + XCTAssertEqual(result.count, 0) + } + + func testRedact_withSwiftUIImage_withMaskAllTextDisabled_shouldRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUIImageFixture() + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUIImageRegions(regions: result) + } + + // - MARK: - SwiftUI.Image Redaction - UIImage + + private func setupSwiftUIImageWithUIImageFixture() throws -> UIWindow { + let image = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { context in + UIColor.green.setFill() + context.fill(CGRect(x: 0, y: 0, width: 20, height: 20)) + UIColor.purple.setFill() + context.fill(CGRect(x: 20, y: 0, width: 20, height: 20)) + UIColor.blue.setFill() + context.fill(CGRect(x: 0, y: 20, width: 20, height: 20)) + UIColor.orange.setFill() + context.fill(CGRect(x: 20, y: 20, width: 20, height: 20)) + } + + let view = VStack { + Image(uiImage: image) + } + return hostSwiftUIViewInWindow(view, frame: CGRect(x: 20, y: 20, width: 240, height: 320)) + + // View Hierarchy: + // --------------- + // == iOS 26 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackVS_5Image__: 0x107623670; frame = (0 0; 0 0); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | (layer) + // + // == iOS 18 & 17 & 16 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackVS_5Image__: 0x13dd2fe30; frame = (0 0; 240 320); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | > + } + + private func assertSwiftUIImageWithUIImageRegions(regions: [SentryRedactRegion]) throws { + let region = try XCTUnwrap(regions.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.type, .redact) + + if #available(iOS 26, *) { + // On iOS 26, UIImage-based images render at their actual size (40x40) instead of scaled + XCTAssertCGSizeEqual(region.size, CGSize(width: 40, height: 40), accuracy: 0.01) + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 120, ty: 181), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region.size, CGSize(width: 18.666, height: 18), accuracy: 0.01) + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 130.666, ty: 190.666), + accuracy: 0.01 + ) + } + + // Assert that there are no other regions + XCTAssertEqual(regions.count, 1) + } + + func testRedact_withSwiftUIImage_withUIImage_withMaskAllImagesEnabled_shouldRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUIImageWithUIImageFixture() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUIImageWithUIImageRegions(regions: result) + } + + func testRedact_withSwiftUIImage_withUIImage_withMaskAllImagesDisabled_shouldNotRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUIImageWithUIImageFixture() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: false) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + XCTAssertEqual(result.count, 0) + } + + func testRedact_withSwiftUIImage_withUIImage_withMaskAllTextDisabled_shouldRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUIImageWithUIImageFixture() + + // -- Act -- + let sut = getSut(maskAllText: false, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUIImageWithUIImageRegions(regions: result) + } + + // - MARK: - SwiftUI.Button Redaction + + private func setupSwiftUIButtonFixture() throws -> UIWindow { + let view = VStack { + Button(action: {}) { + Text("Tap Me") + } + } + return hostSwiftUIViewInWindow(view, frame: CGRect(x: 20, y: 20, width: 240, height: 320)) + + // View Hierarchy: + // --------------- + // == iOS 26 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackGVS_6ButtonVS_4Text___: 0x106821600; frame = (0 0; 240 320); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | <_TtC7SwiftUIP33_863CCF9D49B535DAEB1C7D61BEE53B5914CGDrawingLayer: 0x600002c29700> (layer) + // + // == iOS 18 & 17 & 16 == + // ; layer = > + // | > + // | | > + // | | | <_TtGC7SwiftUI14_UIHostingViewGVS_6VStackGVS_6ButtonVS_4Text___: 0x103016a00; frame = (0 0; 240 320); autoresize = W+H; gestureRecognizers = ; backgroundColor = ; layer = > + // | | | | > + } + + private func assertSwiftUIButtonRegions(regions: [SentryRedactRegion]) throws { + let region = try XCTUnwrap(regions.element(at: 0)) + XCTAssertNil(region.color) + XCTAssertEqual(region.type, .redact) + + if #available(iOS 26, *) { + // On iOS 26, Button text renders at a different size + XCTAssertCGSizeEqual(region.size, CGSize(width: 55.666, height: 20.333), accuracy: 0.01) + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 112.333, ty: 191), + accuracy: 0.01 + ) + } else { + XCTAssertCGSizeEqual(region.size, CGSize(width: 18.666, height: 18), accuracy: 0.01) + XCTAssertAffineTransformEqual( + region.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 112.333, ty: 190.666), + accuracy: 0.01 + ) + } + + // Assert that there are no other regions + XCTAssertEqual(regions.count, 1) + } + + func testRedact_withSwiftUIButton_withMaskAllTextEnabled_withMaskAllImagesEnabled_shouldRedactView() throws { + // -- Arrange -- + let window = try setupSwiftUIButtonFixture() + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: window) + + // -- Assert -- + try assertSwiftUIButtonRegions(regions: result) + } + + // MARK: - Helper Methods + + /// 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 + ) + } + + // MARK: - Layer Filtering Tests + + func testSwiftUIGraphicsView_withoutImageLayer_shouldNotRedact() throws { + // -- Arrange -- + // Create a fake SwiftUI._UIGraphicsView with a regular CALayer (not ImageLayer) + // This simulates SwiftUI using _UIGraphicsView for backgrounds + let graphicsView = try createFakeView( + type: UIView.self, + name: "SwiftUI._UIGraphicsView", + frame: CGRect(x: 20, y: 20, width: 40, height: 40) + ) + + guard let view = graphicsView else { + throw XCTSkip("SwiftUI._UIGraphicsView is not available") + } + + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + rootView.addSubview(view) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // Without ImageLayer, _UIGraphicsView should not be redacted + XCTAssertEqual(result.count, 0) + } + + func testSwiftUIGraphicsView_withImageLayer_shouldRedact() throws { + // -- Arrange -- + // Create a custom layer class to simulate SwiftUI.ImageLayer + class MockImageLayer: CALayer { + override class func description() -> String { + return "SwiftUI.ImageLayer" + } + } + + // Create a custom view that uses our mock image layer + class MockGraphicsView: UIView { + override class var layerClass: AnyClass { + return MockImageLayer.self + } + + override class func description() -> String { + return "SwiftUI._UIGraphicsView" + } + } + + let graphicsView = MockGraphicsView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + rootView.addSubview(graphicsView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // With ImageLayer, _UIGraphicsView should be redacted + XCTAssertEqual(result.count, 1) + let region = try XCTUnwrap(result.first) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.size, CGSize(width: 40, height: 40)) + } +} + +#endif // os(iOS) diff --git a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift index d3866711361..c1fabd73d80 100644 --- a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift +++ b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift @@ -97,12 +97,36 @@ class SentryUIRedactBuilderTests: XCTestCase { // Create a transient window to drive lifecycle/layout for SwiftUI let window = UIWindow(frame: frame) window.rootViewController = hostingVC + + // Ensure the view controller's view is loaded before making window visible + // This is critical for SwiftUI to properly initialize its view hierarchy + _ = hostingVC.view + window.makeKeyAndVisible() - // Pump the runloop and force layout to allow SwiftUI to build internals + // Force the window to be added to the application's window hierarchy if available + // This ensures proper view lifecycle and rendering + if #available(iOS 13.0, *) { + if let scene = window.windowScene ?? UIApplication.shared.connectedScenes.first as? UIWindowScene { + window.windowScene = scene + } + } + + // Force initial layout hostingVC.view.setNeedsLayout() hostingVC.view.layoutIfNeeded() - RunLoop.main.run(until: Date().addingTimeInterval(0.3)) + + // Wait for SwiftUI to render - multiple runloop passes ensure async rendering completes + // SwiftUI renders views asynchronously, so we need to give it time to build the view hierarchy + for _ in 0..<5 { + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + // Force layout on the entire view hierarchy to ensure all subviews are laid out + hostingVC.view.setNeedsLayout() + hostingVC.view.layoutIfNeeded() + // Also force layout on the window to ensure proper coordinate space setup + window.setNeedsLayout() + window.layoutIfNeeded() + } return window }