diff --git a/CHANGELOG.md b/CHANGELOG.md index f91a82b7fb6..4d201a61e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Fix rendering method for fast view rendering (#6360) - Fix issue where the thread that generated an event could be missing when more than 100 threads are running (#6377) - Fix wrong Frame Delay when becoming active, which lead to false reported app hangs when the app moves to the foreground after being in the background (#6381) +- Add SwiftUI.List's background decoration view to ignored redaction views (#6292) ### Improvements diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index da4cb4fd896..4071332dc04 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -782,6 +782,7 @@ D43B26D62D70964C007747FD /* SentrySpanOperation.m in Sources */ = {isa = PBXBuildFile; fileRef = D43B26D52D709648007747FD /* SentrySpanOperation.m */; }; D43B26D82D70A550007747FD /* SentryTraceOrigin.m in Sources */ = {isa = PBXBuildFile; fileRef = D43B26D72D70A54A007747FD /* SentryTraceOrigin.m */; }; D43B26DA2D70A612007747FD /* SentrySpanDataKey.m in Sources */ = {isa = PBXBuildFile; fileRef = D43B26D92D70A60E007747FD /* SentrySpanDataKey.m */; }; + D43C1BE82E8FB85400CD5D67 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D43C1BE72E8FB85400CD5D67 /* SnapshotTesting */; }; D4411DD52E02B74900EA4987 /* ArrayAccessesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4411DD42E02B74100EA4987 /* ArrayAccessesTests.swift */; }; D44B16722DE464AD006DBDB3 /* TestDispatchFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44B16712DE464A9006DBDB3 /* TestDispatchFactoryTests.swift */; }; D451ED5D2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D451ED5C2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift */; }; @@ -816,6 +817,11 @@ D4AF00212D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m in Sources */ = {isa = PBXBuildFile; fileRef = D4AF00202D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m */; }; D4AF00232D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h in Headers */ = {isa = PBXBuildFile; fileRef = D4AF00222D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h */; }; D4AF00252D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4AF00242D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m */; }; + D4AF7D222E93FFCA004F0F59 /* SentryUIRedactBuilderTests+ReactNative.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D212E93FFCA004F0F59 /* SentryUIRedactBuilderTests+ReactNative.swift */; }; + D4AF7D262E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D252E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift */; }; + D4AF7D282E9402AC004F0F59 /* SentryUIRedactBuilderTests+SpecialViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D272E9402AC004F0F59 /* SentryUIRedactBuilderTests+SpecialViews.swift */; }; + D4AF7D2A2E940493004F0F59 /* SentryUIRedactBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D292E940492004F0F59 /* SentryUIRedactBuilderTests.swift */; }; + D4AF7D2C2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D2B2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift */; }; D4B0DC7F2DA9257A00DE61B6 /* SentryRenderVideoResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B0DC7E2DA9257200DE61B6 /* SentryRenderVideoResult.swift */; }; D4C5F59A2D4249E6002A9BF6 /* DataSentryTracingIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4C5F5992D4249E0002A9BF6 /* DataSentryTracingIntegrationTests.swift */; }; D4CA34832E378C9900E92A61 /* SentryArrayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CA34822E378C9000E92A61 /* SentryArrayTests.swift */; }; @@ -823,6 +829,7 @@ D4CBA2532DE06D1600581618 /* TestConstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CBA2512DE06D1600581618 /* TestConstantTests.swift */; }; D4CD2A802DE9F91900DA9F59 /* SentryRedactRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7C2DE9F91900DA9F59 /* SentryRedactRegion.swift */; }; D4CD2A812DE9F91900DA9F59 /* SentryRedactRegionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7D2DE9F91900DA9F59 /* SentryRedactRegionType.swift */; }; + D4CD38AD2E9F946400585285 /* SentryUIRedactBuilderTests+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD38AC2E9F946400585285 /* SentryUIRedactBuilderTests+SwiftUI.swift */; }; D4D12E7A2DFC608800DC45C4 /* SentryScreenshotOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D12E792DFC607F00DC45C4 /* SentryScreenshotOptionsTests.swift */; }; D4DEE6592E439B2E00FCA5A9 /* SentryProfileTimeseriesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4DEE6582E439B2E00FCA5A9 /* SentryProfileTimeseriesTests.m */; }; D4E3F35D2D4A864600F79E2B /* SentryNSDictionarySanitizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42E48582D48FC8F00D251BC /* SentryNSDictionarySanitizeTests.swift */; }; @@ -962,7 +969,7 @@ D8DBE0CA2C0E093000FAB1FD /* SentryTouchTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8DBE0C92C0E093000FAB1FD /* SentryTouchTrackerTests.swift */; }; D8DBE0D22C0EFFC300FAB1FD /* SentryReplayOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8DBE0D12C0EFFC300FAB1FD /* SentryReplayOptionsTests.swift */; }; D8F67AF12BE0D33F00C9197B /* UIImageHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F67AEF2BE0D31A00C9197B /* UIImageHelperTests.swift */; }; - D8F67AF42BE10F9600C9197B /* SentryUIRedactBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F67AF22BE10F7600C9197B /* SentryUIRedactBuilderTests.swift */; }; + D8F67AF42BE10F9600C9197B /* SentryUIRedactBuilderTests+Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F67AF22BE10F7600C9197B /* SentryUIRedactBuilderTests+Common.swift */; }; D8F67B1B2BE9728600C9197B /* SentrySRDefaultBreadcrumbConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F67B1A2BE9728600C9197B /* SentrySRDefaultBreadcrumbConverter.swift */; }; D8F67B222BEAB6CC00C9197B /* SentryRRWebEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F67B212BEAB6CC00C9197B /* SentryRRWebEvent.swift */; }; D8F6A2472885512100320515 /* SentryPredicateDescriptor.m in Sources */ = {isa = PBXBuildFile; fileRef = D8F6A2452885512100320515 /* SentryPredicateDescriptor.m */; }; @@ -2143,6 +2150,11 @@ D4AF00202D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryNSFileManagerSwizzling.m; sourceTree = ""; }; D4AF00222D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryNSFileManagerSwizzling.h; path = include/SentryNSFileManagerSwizzling.h; sourceTree = ""; }; D4AF00242D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryNSFileManagerSwizzlingTests.m; sourceTree = ""; }; + D4AF7D212E93FFCA004F0F59 /* SentryUIRedactBuilderTests+ReactNative.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryUIRedactBuilderTests+ReactNative.swift"; sourceTree = ""; }; + D4AF7D252E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryUIRedactBuilderTests+UIKit.swift"; sourceTree = ""; }; + D4AF7D272E9402AC004F0F59 /* SentryUIRedactBuilderTests+SpecialViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryUIRedactBuilderTests+SpecialViews.swift"; sourceTree = ""; }; + D4AF7D292E940492004F0F59 /* SentryUIRedactBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUIRedactBuilderTests.swift; sourceTree = ""; }; + D4AF7D2B2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryUIRedactBuilderTests+EdgeCases.swift"; sourceTree = ""; }; D4B0DC7E2DA9257200DE61B6 /* SentryRenderVideoResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRenderVideoResult.swift; sourceTree = ""; }; D4BCA0C22DA93C25009E49AB /* SentrySessionReplayIntegration+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SentrySessionReplayIntegration+Test.h"; sourceTree = ""; }; D4C5F5992D4249E0002A9BF6 /* DataSentryTracingIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSentryTracingIntegrationTests.swift; sourceTree = ""; }; @@ -2151,6 +2163,7 @@ D4CBA2512DE06D1600581618 /* TestConstantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstantTests.swift; sourceTree = ""; }; D4CD2A7C2DE9F91900DA9F59 /* SentryRedactRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactRegion.swift; sourceTree = ""; }; D4CD2A7D2DE9F91900DA9F59 /* SentryRedactRegionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactRegionType.swift; sourceTree = ""; }; + D4CD38AC2E9F946400585285 /* SentryUIRedactBuilderTests+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryUIRedactBuilderTests+SwiftUI.swift"; sourceTree = ""; }; D4D12E792DFC607F00DC45C4 /* SentryScreenshotOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryScreenshotOptionsTests.swift; sourceTree = ""; }; D4DEE6582E439B2E00FCA5A9 /* SentryProfileTimeseriesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryProfileTimeseriesTests.m; sourceTree = ""; }; D4ECA3FF2E3CBEDE00C757EA /* SentryDummyPrivateEmptyClass.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryDummyPrivateEmptyClass.m; sourceTree = ""; }; @@ -2305,7 +2318,7 @@ D8F01DE42A126B62008F4996 /* HybridPod.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; path = HybridPod.podspec; sourceTree = ""; }; D8F01DE52A126BF5008F4996 /* HybridTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HybridTest.swift; sourceTree = ""; }; D8F67AEF2BE0D31A00C9197B /* UIImageHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImageHelperTests.swift; sourceTree = ""; }; - D8F67AF22BE10F7600C9197B /* SentryUIRedactBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUIRedactBuilderTests.swift; sourceTree = ""; }; + D8F67AF22BE10F7600C9197B /* SentryUIRedactBuilderTests+Common.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryUIRedactBuilderTests+Common.swift"; sourceTree = ""; }; D8F67B1A2BE9728600C9197B /* SentrySRDefaultBreadcrumbConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySRDefaultBreadcrumbConverter.swift; sourceTree = ""; }; D8F67B212BEAB6CC00C9197B /* SentryRRWebEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRRWebEvent.swift; sourceTree = ""; }; D8F6A2452885512100320515 /* SentryPredicateDescriptor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryPredicateDescriptor.m; sourceTree = ""; }; @@ -2485,6 +2498,7 @@ buildActionMask = 2147483647; files = ( 8431F01C29B2854200D8DC56 /* libSentryTestUtils.a in Frameworks */, + D43C1BE82E8FB85400CD5D67 /* SnapshotTesting in Frameworks */, D84DAD592B1742C1003CF120 /* SentryTestUtilsDynamic.framework in Frameworks */, 63AA766A1EB8CB2F00D153DE /* Sentry.framework in Frameworks */, ); @@ -4179,8 +4193,15 @@ D4009EA02D77196F0007AF30 /* ViewCapture */ = { isa = PBXGroup; children = ( + D4AF802E2E965188004F0F59 /* __Snapshots__ */, D82915622C85EF0C00A6CDD4 /* SentryViewPhotographerTests.swift */, - D8F67AF22BE10F7600C9197B /* SentryUIRedactBuilderTests.swift */, + D4AF7D292E940492004F0F59 /* SentryUIRedactBuilderTests.swift */, + D8F67AF22BE10F7600C9197B /* SentryUIRedactBuilderTests+Common.swift */, + D4AF7D2B2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift */, + D4AF7D272E9402AC004F0F59 /* SentryUIRedactBuilderTests+SpecialViews.swift */, + D4CD38AC2E9F946400585285 /* SentryUIRedactBuilderTests+SwiftUI.swift */, + D4AF7D252E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift */, + D4AF7D212E93FFCA004F0F59 /* SentryUIRedactBuilderTests+ReactNative.swift */, D45E2D762E003EBF0072A6B7 /* TestRedactOptions.swift */, ); path = ViewCapture; @@ -4286,6 +4307,13 @@ path = Screenshot; sourceTree = ""; }; + D4AF802E2E965188004F0F59 /* __Snapshots__ */ = { + isa = PBXGroup; + children = ( + ); + path = __Snapshots__; + sourceTree = ""; + }; D4CBA2522DE06D1600581618 /* SentryTestUtilsTests */ = { isa = PBXGroup; children = ( @@ -5277,6 +5305,7 @@ ); name = SentryTests; packageProductDependencies = ( + D43C1BE72E8FB85400CD5D67 /* SnapshotTesting */, ); productName = "Tests-iOS"; productReference = 63AA76651EB8CB2F00D153DE /* SentryTests.xctest */; @@ -5447,6 +5476,7 @@ ); mainGroup = 6327C5C91EB8A783004E799B; packageReferences = ( + D43C1BE62E8FB85400CD5D67 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, ); productRefGroup = 6327C5D41EB8A783004E799B /* Products */; projectDirPath = ""; @@ -6002,7 +6032,8 @@ FAC62B652E15A4100003909D /* SentrySDKThreadTests.swift in Sources */, D82915632C85EF0C00A6CDD4 /* SentryViewPhotographerTests.swift in Sources */, D8DBE0CA2C0E093000FAB1FD /* SentryTouchTrackerTests.swift in Sources */, - D8F67AF42BE10F9600C9197B /* SentryUIRedactBuilderTests.swift in Sources */, + D8F67AF42BE10F9600C9197B /* SentryUIRedactBuilderTests+Common.swift in Sources */, + D4AF7D2C2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift in Sources */, 92ECD7482E05B57C0063EC10 /* SentryLogAttributeTests.swift in Sources */, 63B819141EC352A7002FDF4C /* SentryInterfacesTests.m in Sources */, 92B6BDA92E05B8F600D538B3 /* SentryLogLevelTests.swift in Sources */, @@ -6074,6 +6105,7 @@ D45E2D772E003EBF0072A6B7 /* TestRedactOptions.swift in Sources */, 63FE720520DA66EC00CDBAE8 /* FileBasedTestCase.m in Sources */, 51B15F802BE88D510026A2F2 /* URLSessionTaskHelperTests.swift in Sources */, + D4AF7D262E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift in Sources */, 63EED6C32237989300E02400 /* SentryOptionsTest.m in Sources */, 7BBD18B22451804C00427C76 /* SentryRetryAfterHeaderParserTests.swift in Sources */, 7BD337E424A356180050DB6E /* SentryCrashIntegrationTests.swift in Sources */, @@ -6086,6 +6118,7 @@ 62F4DDA12C04CB9700588890 /* SentryBaggageSerializationTests.swift in Sources */, 7BE912AF272166DD00E49E62 /* SentryNoOpSpanTests.swift in Sources */, D4F2B5352D0C69D500649E42 /* SentryCrashCTests.swift in Sources */, + D4AF7D2A2E940493004F0F59 /* SentryUIRedactBuilderTests.swift in Sources */, 7B56D73524616E5600B842DA /* SentryConcurrentRateLimitsDictionaryTests.swift in Sources */, 7B7D8730248648AD00D2ECFF /* SentryStacktraceBuilderTests.swift in Sources */, FA21A2EF2E60E9CB00E7EADB /* EnvelopeComparison.swift in Sources */, @@ -6174,6 +6207,7 @@ 630C01941EC3402C00C52CEF /* SentryCrashReportConverterTests.m in Sources */, 7B59398424AB481B0003AAD2 /* NotificationCenterTestCase.swift in Sources */, 7B0A542E2521C62400A71716 /* SentryFrameRemoverTests.swift in Sources */, + D4CD38AD2E9F946400585285 /* SentryUIRedactBuilderTests+SwiftUI.swift in Sources */, 7BE912B12721C76000E49E62 /* SentryPerformanceTrackingIntegrationTests.swift in Sources */, 7BA61CCC247D14E600C130A8 /* SentryDefaultThreadInspectorTests.swift in Sources */, 623C45B02A651D8200D9E88B /* SentryCoreDataTracker+Test.m in Sources */, @@ -6195,6 +6229,7 @@ D80694C42B7CC9AE00B820E6 /* SentryReplayEventTests.swift in Sources */, 7B34721728086A9D0041F047 /* SentrySwizzleWrapperTests.swift in Sources */, 8EC4CF5025C3A0070093DEE9 /* SentrySpanContextTests.swift in Sources */, + D4AF7D282E9402AC004F0F59 /* SentryUIRedactBuilderTests+SpecialViews.swift in Sources */, 6281C5742D3E50DF009D0978 /* ArbitraryDataTests.swift in Sources */, 7BE0DC2F272ABAF6004FA8B7 /* SentryAutoBreadcrumbTrackingIntegrationTests.swift in Sources */, 7B869EBE249B964D004F4FDB /* SentryThreadEquality.swift in Sources */, @@ -6277,6 +6312,7 @@ 7B68D93625FF5F1A0082D139 /* SentryAppState+Equality.m in Sources */, 7B5CAF7E27F5AD3500ED0DB6 /* TestNSURLRequestBuilder.m in Sources */, D467125E2DCCFF2500D4074A /* SentryReplayOptionsObjcTests.m in Sources */, + D4AF7D222E93FFCA004F0F59 /* SentryUIRedactBuilderTests+ReactNative.swift in Sources */, 7BF69E072987D1FE002EBCA4 /* SentryCrashDoctorTests.swift in Sources */, 7B4F22DC294089530067EA17 /* FormatHexAddress.swift in Sources */, 8EAC7FF8265C8910005B44E5 /* SentryTracerTests.swift in Sources */, @@ -8818,6 +8854,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D43C1BE62E8FB85400CD5D67 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.18.7; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D43C1BE72E8FB85400CD5D67 /* SnapshotTesting */ = { + isa = XCSwiftPackageProductDependency; + package = D43C1BE62E8FB85400CD5D67 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; + productName = SnapshotTesting; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 6327C5CA1EB8A783004E799B /* Project object */; } diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index a702ef8f06d..101522a842c 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length type_body_length #if canImport(UIKit) && !SENTRY_NO_UIKIT #if os(iOS) || os(tvOS) import Foundation @@ -9,6 +10,66 @@ 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 + } + + func matches(viewClass: AnyClass, layerClass: AnyClass) -> Bool { + guard viewClass.description() == classId 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 + guard let filterLayerClass = layerId else { + return true + } + return layerClass.description() == filterLayerClass + } + } + // MARK: - Constants /// Class identifier for ``CameraUI.ChromeSwiftUIView``, if it exists. @@ -16,131 +77,199 @@ 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" + 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 - - /** - 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. + /// while still allowing read access from tests. Same semantics as `ignoreClassesIdentifiers`. + private var redactClassesIdentifiers: Set - - 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 += ["RCTTextView", "RCTParagraphComponentView"].compactMap(NSClassFromString(_:)) + + // 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. + redactClasses.insert(ExtendedClassIdentifier(classId: "SwiftUI.CGDrawingView")) + + // Used to render SwiftUI.Text on iOS versions prior to iOS 18 + redactClasses.insert(ExtendedClassIdentifier(classId: "_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView")) + } 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"].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 += [ - // 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" - ].compactMap(NSClassFromString(_:)) - - ignoreClassesIdentifiers = [ ObjectIdentifier(UISlider.self), ObjectIdentifier(UISwitch.self) ] + redactClasses.insert(ExtendedClassIdentifier(objcType: PDFView.self)) + redactClasses.insert(ExtendedClassIdentifier(objcType: WKWebView.self)) + + // 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")) + + // 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)) } } + /// 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. func containsIgnoreClass(_ ignoreClass: AnyClass) -> Bool { - return ignoreClassesIdentifiers.contains(ObjectIdentifier(ignoreClass)) + return ignoreClassesIdentifiers.contains(where: { $0.classId == ignoreClass.description() }) } - func containsRedactClass(_ redactClass: AnyClass) -> Bool { - var currentClass: AnyClass? = redactClass - while currentClass != nil && currentClass != UIView.self { - if let currentClass = currentClass, redactClassesIdentifiers.contains(ObjectIdentifier(currentClass)) { + /// 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:)`. + func containsRedactClass(viewClass: AnyClass, layerClass: AnyClass) -> Bool { + var currentClass: AnyClass? = viewClass + while let iteratorClass = currentClass { + if redactClassesIdentifiers.contains(where: { $0.matches(viewClass: iteratorClass, layerClass: layerClass) }) { return true } - currentClass = currentClass?.superclass() + currentClass = iteratorClass.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 @@ -153,32 +282,34 @@ 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, - relativeTo: nil, - redacting: &redactingRegions, - rootFrame: view.frame, - transform: .identity) + + self.mapRedactRegion( + fromLayer: view.layer.presentation() ?? view.layer, + relativeTo: nil, + redacting: &redactingRegions, + rootFrame: view.frame, + transform: .identity + ) var swiftUIRedact = [SentryRedactRegion]() var otherRegions = [SentryRedactRegion]() @@ -214,73 +345,100 @@ 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 } - // swiftlint:disable:next function_body_length + // swiftlint:disable:next function_body_length cyclomatic_complexity private func mapRedactRegion(fromLayer layer: CALayer, relativeTo parentLayer: CALayer?, redacting: inout [SentryRedactRegion], rootFrame: CGRect, transform: CGAffineTransform, forceRedact: Bool = false) { - guard !redactClassesIdentifiers.isEmpty && !layer.isHidden && layer.opacity != 0, let view = layer.delegate as? UIView else { + guard !redactClassesIdentifiers.isEmpty && !layer.isHidden && layer.opacity != 0 else { return } let newTransform = concatenateTranform(transform, from: layer, withParent: parentLayer) - - // Check if the subtree should be ignored to avoid crashes with some special views. - // If a subtree is ignored, it will be fully redacted and we return early to prevent duplicates. - if isViewSubtreeIgnored(view) { - redacting.append(SentryRedactRegion( - size: layer.bounds.size, - transform: newTransform, - type: .redact, - color: self.color(for: view), - name: view.debugDescription - )) - return - } - - let ignore = !forceRedact && shouldIgnore(view: view) - let swiftUI = SentryRedactViewHelper.shouldRedactSwiftUI(view) - let redact = forceRedact || shouldRedact(view: view) || swiftUI var enforceRedact = forceRedact - - if !ignore && redact { - redacting.append(SentryRedactRegion( - size: layer.bounds.size, - transform: newTransform, - type: swiftUI ? .redactSwiftUI : .redact, - color: self.color(for: view), - name: view.debugDescription - )) - guard !view.clipsToBounds else { + if let view = layer.delegate as? UIView { + // Check if the subtree should be ignored to avoid crashes with some special views. + if isViewSubtreeIgnored(view) { + // If a subtree is ignored, it should be fully redacted and we return early to prevent duplicates, unless the view was marked explicitly to be ignored (e.g. UISwitch). + if !shouldIgnore(view: view) { + redacting.append(SentryRedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: .redact, + color: self.color(for: view), + name: view.debugDescription + )) + } return } - enforceRedact = true - } else if isOpaque(view) { - let finalViewFrame = CGRect(origin: .zero, size: layer.bounds.size).applying(newTransform) - if isAxisAligned(newTransform) && finalViewFrame == rootFrame { - //Because the current view is covering everything we found so far we can clear `redacting` list - redacting.removeAll() - } else { + + let ignore = !forceRedact && shouldIgnore(view: view) + let swiftUI = SentryRedactViewHelper.shouldRedactSwiftUI(view) + let redact = forceRedact || shouldRedact(view: view) || swiftUI + + if !ignore && redact { redacting.append(SentryRedactRegion( size: layer.bounds.size, transform: newTransform, - type: .clipOut, + type: swiftUI ? .redactSwiftUI : .redact, + color: self.color(for: view), name: view.debugDescription )) + + guard !view.clipsToBounds else { + return + } + enforceRedact = true + } else if isOpaque(view) { + let finalViewFrame = CGRect(origin: .zero, size: layer.bounds.size).applying(newTransform) + if isAxisAligned(newTransform) && finalViewFrame == rootFrame { + //Because the current view 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 + )) + } } } @@ -288,7 +446,7 @@ final class SentryUIRedactBuilder { guard let subLayers = layer.sublayers, subLayers.count > 0 else { return } - let clipToBounds = view.clipsToBounds + let clipToBounds = layer.masksToBounds if clipToBounds { /// Because the order in which we process the redacted regions is reversed, we add the end of the clip region first. /// The beginning will be added after all the subviews have been mapped. @@ -296,18 +454,32 @@ final class SentryUIRedactBuilder { size: layer.bounds.size, transform: newTransform, type: .clipEnd, - name: view.debugDescription + name: layer.debugDescription )) } - for subLayer in subLayers.sorted(by: { $0.zPosition < $1.zPosition }) { - mapRedactRegion(fromLayer: subLayer, relativeTo: layer, redacting: &redacting, rootFrame: rootFrame, transform: newTransform, forceRedact: enforceRedact) + // Preserve Core Animation's sibling order when zPosition ties to mirror real render order. + let sortedSubLayers = subLayers.enumerated().sorted { lhs, rhs in + if lhs.element.zPosition == rhs.element.zPosition { + return lhs.offset < rhs.offset + } + return lhs.element.zPosition < rhs.element.zPosition + } + for (_, subLayer) in sortedSubLayers { + mapRedactRegion( + fromLayer: subLayer, + relativeTo: layer, + redacting: &redacting, + rootFrame: rootFrame, + transform: newTransform, + forceRedact: enforceRedact + ) } if clipToBounds { redacting.append(SentryRedactRegion( size: layer.bounds.size, transform: newTransform, type: .clipBegin, - name: view.debugDescription + name: layer.debugDescription )) } } @@ -330,7 +502,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' @@ -338,12 +510,23 @@ final class SentryUIRedactBuilder { // This crash only occurs when building with Xcode 16 for iOS 26, so we add a runtime check return true } + + #if os(iOS) + // UISwitch uses UIImageView internally, which can be in the list of redacted views. + // But UISwitch is in the list of ignored class identifiers by default, because it uses + // non-sensitive images. Therefore we want to ignore the subtree of UISwitch, unless + // it was removed from the list of ignored classes + if viewTypeId == UISwitch.self.description(), ignoreClassesIdentifiers.contains( + where: { $0.classId == UISwitch.self.description() + }) { + return true + } + #endif + 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) @@ -356,21 +539,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) @@ -379,3 +563,4 @@ final class SentryUIRedactBuilder { #endif #endif +// swiftlint:enable file_length type_body_length diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift index 2abc75698e2..8356e10f10e 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryViewPhotographer.swift @@ -34,8 +34,12 @@ 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 + + // The redact regions are expected to be thread-safe data structures let redactRegions = redactBuilder.redactRegionsFor(view: view) + // The render method is synchronous and must be called on the main thread. // This is because the render method accesses the view hierarchy which is managed from the main thread. let renderedScreenshot = renderer.render(view: view) diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index 0700833769d..fb97dfc9fa6 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -342,28 +342,32 @@ class SentrySessionReplayIntegrationTests: XCTestCase { } func testMaskViewFromSDK() throws { - class AnotherLabel: UILabel { - } - + // -- Arrange -- + 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)) + + // -- Act -- + let redactBuilder = try getSut().viewPhotographer.getRedactBuilder() + + // -- Assert -- + XCTAssertTrue(redactBuilder.containsRedactClass(viewClass: AnotherLabel.self, layerClass: CALayer.self)) } func testIgnoreViewFromSDK() throws { - class AnotherLabel: UILabel { - } - + // -- Arrange -- + class AnotherLabel: UILabel {} + startSDK(sessionSampleRate: 1, errorSampleRate: 1) { options in options.sessionReplay.unmaskedViewClasses = [AnotherLabel.self] } - - let sut = try getSut() - let redactBuilder = sut.viewPhotographer.getRedactBuilder() + + // -- Act -- + let redactBuilder = try getSut().viewPhotographer.getRedactBuilder() + + // -- Assert -- XCTAssertTrue(redactBuilder.containsIgnoreClass(AnotherLabel.self)) } diff --git a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+Common.swift b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+Common.swift new file mode 100644 index 00000000000..a0c065450ce --- /dev/null +++ b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+Common.swift @@ -0,0 +1,1392 @@ +// swiftlint:disable file_length +#if os(iOS) +import AVKit +import Foundation +import PDFKit +import SafariServices +@_spi(Private) @testable import Sentry +import SentryTestUtils +import SnapshotTesting +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_Common: SentryUIRedactBuilderTests { // swiftlint:disable:this type_name + private class CustomVisibilityView: UIView { + class CustomLayer: CALayer { + override var opacity: Float { + get { 0.5 } + set { } + } + } + override class var layerClass: AnyClass { + return CustomLayer.self + } + } + + private var rootView: UIView! + + private func getSut(maskAllText: Bool, maskAllImages: Bool, maskedViewClasses: [AnyClass] = [], unmaskedViewClasses: [AnyClass] = []) -> SentryUIRedactBuilder { + return SentryUIRedactBuilder(options: TestRedactOptions( + maskAllText: maskAllText, + maskAllImages: maskAllImages, + maskedViewClasses: maskedViewClasses, + unmaskedViewClasses: unmaskedViewClasses + )) + } + + override func setUp() { + rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + } + + // MARK: - Baseline + + 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) + let masked = createMaskedScreenshot(view: rootView, regions: result) + + // -- Assert -- + assertSnapshot(of: masked, as: .image) + XCTAssertEqual(result.count, 0) + } + + // - 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)) + redactedLabel.textColor = UIColor.blue + redactedLabel.isHidden = false + rootView.addSubview(redactedLabel) + + // View Hierarchy: + // --------------- + // > + // |