diff --git a/.gitignore b/.gitignore index 69dad1ab6bb..93360e9ee75 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,9 @@ current_package_diff.patch uikit-check-build *.xcarchive + +# Output of `make analyze` +analyzer + +# Output of snapshot testing +**/__Snapshots__ diff --git a/CHANGELOG.md b/CHANGELOG.md index 690fb318714..47bf13387a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +### Fixes + +- Fix rendering method for fast view rendering (#6360) +- Session Replay masking improvements (#6292) + - Fix SwiftUI.List background decoration view causing incorrect clipping of screen content + - Fix sublayer rendering order by properly sorting by zPosition with insertion order as tie-breaker + - Fix UISwitch internal images being incorrectly redacted + - Fix UITextField placeholder text (UITextFieldLabel) not being detected for redaction + - Use string-based class comparison to avoid triggering Objective-C +initialize on background threads + - Add layer class filtering for views used in multiple contexts (e.g., SwiftUI._UIGraphicsView) + - Improve transform calculations for views with custom anchor points + - Fix axis-aligned transform detection for optimized opaque view clipping +- Fix conversion of frame rate to time interval for session replay (#6623) +- Change Session Replay masking to prevent semi‑transparent full‑screen overlays from clearing redactions by making opaque clipping stricter (#6629) + Views now need to be fully opaque (view and layer backgrounds with alpha == 1) and report opaque to qualify for clip‑out. + This avoids leaks at the cost of fewer clip‑out optimizations. + ## 8.57.1 ### Fixes diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 99a8f73ad81..9480c7e859f 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -804,6 +804,7 @@ D43B26D82D70A550007747FD /* SentryTraceOrigin.m in Sources */ = {isa = PBXBuildFile; fileRef = D43B26D72D70A54A007747FD /* SentryTraceOrigin.m */; }; D43B26DA2D70A612007747FD /* SentrySpanDataKey.m in Sources */ = {isa = PBXBuildFile; fileRef = D43B26D92D70A60E007747FD /* SentrySpanDataKey.m */; }; D4411DD52E02B74900EA4987 /* ArrayAccessesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4411DD42E02B74100EA4987 /* ArrayAccessesTests.swift */; }; + D44311312EB22812006CABE4 /* SentryUIRedactBuilderTests+ReactNative.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D212E93FFCA004F0F59 /* SentryUIRedactBuilderTests+ReactNative.swift */; }; D44B16722DE464AD006DBDB3 /* TestDispatchFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44B16712DE464A9006DBDB3 /* TestDispatchFactoryTests.swift */; }; D451ED5D2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D451ED5C2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift */; }; D451ED5F2D92ECDE00C9BEA8 /* SentryReplayFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = D451ED5E2D92ECDE00C9BEA8 /* SentryReplayFrame.swift */; }; @@ -849,6 +850,8 @@ 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 */; }; + D4AF7D2A2E940493004F0F59 /* SentryUIRedactBuilderTests+Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D292E940492004F0F59 /* SentryUIRedactBuilderTests+Common.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 */; }; @@ -2198,6 +2201,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+Common.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryUIRedactBuilderTests+Common.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 = ""; }; @@ -4243,8 +4251,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 */, D45E2D762E003EBF0072A6B7 /* TestRedactOptions.swift */, ); path = ViewCapture; @@ -4361,6 +4374,13 @@ path = InfoPlist; sourceTree = ""; }; + D4AF802E2E965188004F0F59 /* __Snapshots__ */ = { + isa = PBXGroup; + children = ( + ); + path = __Snapshots__; + sourceTree = ""; + }; D4CBA2522DE06D1600581618 /* SentryTestUtilsTests */ = { isa = PBXGroup; children = ( @@ -6072,6 +6092,7 @@ FAC62B652E15A4100003909D /* SentrySDKThreadTests.swift in Sources */, D82915632C85EF0C00A6CDD4 /* SentryViewPhotographerTests.swift in Sources */, D8DBE0CA2C0E093000FAB1FD /* SentryTouchTrackerTests.swift in Sources */, + D4AF7D2C2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift in Sources */, D8F67AF42BE10F9600C9197B /* SentryUIRedactBuilderTests.swift in Sources */, 92ECD7482E05B57C0063EC10 /* SentryLogAttributeTests.swift in Sources */, 63B819141EC352A7002FDF4C /* SentryInterfacesTests.m in Sources */, @@ -6126,6 +6147,7 @@ 7BC6EBF4255C044A0059822A /* SentryEventTests.swift in Sources */, 63FE721920DA66EC00CDBAE8 /* SentryCrashReportStore_Tests.m in Sources */, 7B6D98EB24C6E84F005502FA /* SentryCrashInstallationReporterTests.swift in Sources */, + D44311312EB22812006CABE4 /* SentryUIRedactBuilderTests+ReactNative.swift in Sources */, 7BA61EA625F21E660008CAA2 /* SentrySDKLogTests.swift in Sources */, 62CFD9A92C99741100834E1B /* SentryInvalidJSONStringTests.swift in Sources */, 7BB42EF124F3B7B700D7B39A /* SentrySession+Equality.m in Sources */, @@ -6156,6 +6178,7 @@ 62F4DDA12C04CB9700588890 /* SentryBaggageSerializationTests.swift in Sources */, 7BE912AF272166DD00E49E62 /* SentryNoOpSpanTests.swift in Sources */, D4F2B5352D0C69D500649E42 /* SentryCrashCTests.swift in Sources */, + D4AF7D2A2E940493004F0F59 /* SentryUIRedactBuilderTests+Common.swift in Sources */, 7B56D73524616E5600B842DA /* SentryConcurrentRateLimitsDictionaryTests.swift in Sources */, 7B7D8730248648AD00D2ECFF /* SentryStacktraceBuilderTests.swift in Sources */, FA21A2EF2E60E9CB00E7EADB /* EnvelopeComparison.swift in Sources */, @@ -6269,6 +6292,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 */, diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegion.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegion.swift index 9cd378bdb2a..9f39cdda5d3 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegion.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegion.swift @@ -4,7 +4,7 @@ import Foundation import ObjectiveC.NSObjCRuntime import UIKit -final class SentryRedactRegion { +struct SentryRedactRegion: Equatable { let size: CGSize let transform: CGAffineTransform let type: SentryRedactRegionType diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegionType.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegionType.swift index 04a35a5473c..3f08d38df00 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegionType.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryRedactRegionType.swift @@ -1,4 +1,4 @@ -public enum SentryRedactRegionType: String, Codable { +public enum SentryRedactRegionType: String, Codable, Equatable { /// Redacts the region. case redact = "redact" diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index a702ef8f06d..f4411453fa6 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,63 @@ import WebKit #endif final class SentryUIRedactBuilder { + // MARK: - Types + + /// Type used to represented a view that needs to be redacted + struct ClassIdentifier: 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 + } + + /// Initializes a new instance of the extended class identifier using a Swift class. + /// + /// - parameter class: The class. + /// - parameter layerId: The layer. + init(class: AnyClass, layer: AnyClass) { + self.classId = `class`.description() + self.layerId = layer.description() + } + } + // MARK: - Constants /// Class identifier for ``CameraUI.ChromeSwiftUIView``, if it exists. @@ -16,131 +74,266 @@ 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 = ClassIdentifier(classId: "CameraUI.ChromeSwiftUIView") - ///This is a wrapper which marks it's direct children to be ignored + // MARK: - Properties + + /// 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. + /// while still allowing read access from tests. Same semantics as `ignoreClassesIdentifiers`. + private var redactClassesIdentifiers: Set { + didSet { + rebuildOptimizedLookups() + } + } - - 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. + /// Optimized lookup: class IDs that should be redacted without layer constraints + private var unconstrainedRedactClasses: Set = [] + + /// Optimized lookup: class IDs with layer constraints (includes both classId and layerId) + private var constrainedRedactClasses: 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(ClassIdentifier(objcType: UILabel.self)) + redactClasses.insert(ClassIdentifier(objcType: UITextView.self)) + redactClasses.insert(ClassIdentifier(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(ClassIdentifier(classId: "RCTTextView")) + + // Used by React Native to render long text + redactClasses.insert(ClassIdentifier(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(ClassIdentifier(classId: "SwiftUI.CGDrawingView")) + + // Used to render SwiftUI.Text on iOS versions prior to iOS 18 + redactClasses.insert(ClassIdentifier(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(ClassIdentifier(objcType: UIImageView.self)) + + // Used by SwiftUI.Image to display SFSymbols, e.g. `Image(systemName: "star.fill")` + redactClasses.insert(ClassIdentifier(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(ClassIdentifier(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(ClassIdentifier(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(ClassIdentifier(objcType: PDFView.self)) + redactClasses.insert(ClassIdentifier(objcType: WKWebView.self)) + + // If we try to use 'UIWebView.self' it will not compile for macCatalyst, but the class does exists. + redactClasses.insert(ClassIdentifier(classId: "UIWebView")) + + // Used by: + // - https://developer.apple.com/documentation/SafariServices/SFSafariViewController + // - https://developer.apple.com/documentation/AuthenticationServices/ASWebAuthenticationSession + redactClasses.insert(ClassIdentifier(classId: "SFSafariView")) + + // Used by: + // - https://developer.apple.com/documentation/avkit/avplayerviewcontroller + redactClasses.insert(ClassIdentifier(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(ClassIdentifier(classId: "_UICollectionViewListLayoutSectionBackgroundColorDecorationView")) + + // These classes are standard UIKit controls that are ignored by default. + // The reason why exactly they are ignored is unknown. + ignoreClassesIdentifiers = [ + ClassIdentifier(objcType: UISlider.self), + ClassIdentifier(objcType: UISwitch.self) + ] #else ignoreClassesIdentifiers = [] #endif - redactClassesIdentifiers = Set(redactClasses.map({ ObjectIdentifier($0) })) - for type in options.unmaskedViewClasses { - self.ignoreClassesIdentifiers.insert(ObjectIdentifier(type)) + ignoreClassesIdentifiers.insert(ClassIdentifier(class: type)) } for type in options.maskedViewClasses { - self.redactClassesIdentifiers.insert(ObjectIdentifier(type)) + redactClasses.insert(ClassIdentifier(class: type)) } + + redactClassesIdentifiers = redactClasses + + // didSet doesn't run during initialization, so we need to manually build the optimization structures + rebuildOptimizedLookups() } - - func containsIgnoreClass(_ ignoreClass: AnyClass) -> Bool { - return ignoreClassesIdentifiers.contains(ObjectIdentifier(ignoreClass)) + + /// Rebuilds the optimized lookup structures from `redactClassesIdentifiers`. + /// + /// This method splits `redactClassesIdentifiers` into two sets for O(1) lookups: + /// - `unconstrainedRedactClasses`: Classes without layer constraints + /// - `constrainedRedactClasses`: Classes with specific layer constraints + /// + /// Called automatically by `didSet` when `redactClassesIdentifiers` is modified, + /// and manually during initialization (since `didSet` doesn't run during init). + private func rebuildOptimizedLookups() { + unconstrainedRedactClasses.removeAll() + constrainedRedactClasses.removeAll() + + for identifier in redactClassesIdentifiers { + if identifier.layerId == nil { + // No layer constraint - add to unconstrained set + unconstrainedRedactClasses.insert(ClassIdentifier(classId: identifier.classId)) + } else { + // Has layer constraint - add full identifier + constrainedRedactClasses.insert(identifier) + } + } } - - 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 provided class type is contained in the ignore list. + /// + /// - Note: This method does not check superclasses as we do in `containsRedactClass`, because it could unmask unwanted subclasses. + /// Example: + /// + /// ``` + /// class MyLabel: UILabel {} + /// class SuperSensitiveLabel: UILabel {} + /// ``` + /// + /// If we ignore `UILabel` it would also expose `MyLabel` and `SuperSensitiveLabel`, which might not be what the user wants. + /// + /// This compares by string description to avoid touching Objective‑C class objects directly. + func containsIgnoreClass(_ class: AnyClass) -> Bool { + return containsIgnoreClassId(ClassIdentifier(class: `class`)) + } + + /// Returns `true` if the provided class identifier is contained in the ignore list. + private func containsIgnoreClassId(_ id: ClassIdentifier) -> Bool { + /// Edge case: ``UITextField`` uses an internal type of ``UITextFieldLabel`` for the placeholder, which should also be ignored + if id.classId == "UITextFieldLabel" { + return ignoreClassesIdentifiers.contains(ClassIdentifier(classId: "UITextField")) + } + return ignoreClassesIdentifiers.contains(id) + } + + /// 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 { + // Check if this class is in the unconstrained set (O(1) lookup) + // This matches any layer type + if unconstrainedRedactClasses.contains(ClassIdentifier(class: iteratorClass)) { + return true + } + + // Check if this class+layer combination is in the constrained set (O(1) lookup) + // This only matches specific layer types + if constrainedRedactClasses.contains(ClassIdentifier(class: iteratorClass, layer: 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(ClassIdentifier(class: ignoreClass)) } + /// Adds a class to the redact list. func addRedactClass(_ redactClass: AnyClass) { - redactClassesIdentifiers.insert(ObjectIdentifier(redactClass)) + redactClassesIdentifiers.insert(ClassIdentifier(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(ClassIdentifier(class: containerClass)) } #if SENTRY_TEST || SENTRY_TEST_CI @@ -153,32 +346,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]() @@ -196,7 +391,7 @@ final class SentryUIRedactBuilder { } private func shouldIgnore(view: UIView) -> Bool { - return SentryRedactViewHelper.shouldUnmask(view) || containsIgnoreClass(type(of: view)) || shouldIgnoreParentContainer(view) + return SentryRedactViewHelper.shouldUnmask(view) || containsIgnoreClassId(ClassIdentifier(class: type(of: view))) || shouldIgnoreParentContainer(view) } private func shouldIgnoreParentContainer(_ view: UIView) -> Bool { @@ -214,73 +409,101 @@ 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. + // The pixel size of `10` is an undocumented threshold and should be considered a magic number. + 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: view.debugDescription + )) + } } } @@ -288,7 +511,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 +519,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 +567,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 +575,21 @@ 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" && containsIgnoreClassId(ClassIdentifier(classId: viewTypeId)) { + return true + } + #endif // os(iOS) + 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,26 +602,77 @@ 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. + /// + /// A view is considered opaque if it completely covers and hides any content behind it. + /// This is used to optimize redaction by clearing out regions that are fully covered. + /// + /// The method checks multiple properties because UIKit views can become transparent in several ways: + /// - `view.alpha` (mapped to `layer.opacity`) can make the entire view semi-transparent + /// - `view.backgroundColor` or `layer.backgroundColor` can have alpha components + /// - Either the view or layer can explicitly set their `isOpaque` property to false + /// + /// ## Implementation Notes: + /// - We use the presentation layer when available to get the actual rendered state during animations + /// - We require BOTH the view and the layer to appear opaque (alpha == 1 and marked opaque) + /// to classify a view as opaque. This avoids false positives where only one side is configured, + /// which previously caused semi‑transparent overlays or partially configured views to clear + /// redactions behind them. + /// - We use `SentryRedactViewHelper.shouldClipOut(view)` for views explicitly marked as opaque + /// + /// ## Bug Fix Context: + /// 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 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) + + // Allow explicit override: if a view is marked to clip out, treat it as opaque + if SentryRedactViewHelper.shouldClipOut(view) { + return true + } + + // First check: Ensure the layer opacity is 1.0 + // This catches views with `alpha < 1.0`, which are semi-transparent regardless of background color. + // For example, a view with `alpha = 0.2` should never be considered opaque, even if it has + // a solid background color, because the entire view (including the background) is semi-transparent. + guard layer.opacity == 1 else { + return false + } + + // Second check: Verify the view has an opaque background color + // We check the view's properties first because this is the most common pattern in UIKit. + let isViewOpaque = view.isOpaque && view.backgroundColor != nil && (view.backgroundColor?.cgColor.alpha ?? 0) == 1 + + // Third check: Verify the layer has an opaque background color + // We also check the layer's properties because: + // - Some views customize their CALayer directly without setting view.backgroundColor + // - Libraries or custom views might override backgroundColor to return different values + // - The layer's backgroundColor is the actual rendered property (view.backgroundColor is a convenience) + let isLayerOpaque = layer.isOpaque && layer.backgroundColor != nil && (layer.backgroundColor?.alpha ?? 0) == 1 + + // We REQUIRE BOTH: the view AND the layer must be opaque for the view to be treated as opaque. + // This stricter rule prevents semi‑transparent overlays or partially configured backgrounds + // (only view or only layer) from clearing previously collected redact regions. + return isViewOpaque && isLayerOpaque } } #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/Sources/Swift/Core/Tools/ViewCapture/SentryViewRendererV2.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryViewRendererV2.swift index f9016c06964..53ba4cd8abd 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryViewRendererV2.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryViewRendererV2.swift @@ -15,7 +15,7 @@ import UIKit let scale = (view as? UIWindow ?? view.window)?.screen.scale ?? 1 let image = SentryGraphicsImageRenderer(size: view.bounds.size, scale: scale).image { context in if enableFastViewRendering { - view.layer.draw(in: context.cgContext) + view.layer.render(in: context.cgContext) } else { view.drawHierarchy(in: view.bounds, afterScreenUpdates: false) } diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 43a73cc1318..dae945c0176 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -233,7 +233,7 @@ import UIKit return } - if now.timeIntervalSince(lastScreenShot) >= Double(1 / replayOptions.frameRate) { + if now.timeIntervalSince(lastScreenShot) >= 1.0 / Double(replayOptions.frameRate) { takeScreenshot() self.lastScreenShot = now diff --git a/Tests/Perf/metrics-test.yml b/Tests/Perf/metrics-test.yml index 5c96de101d4..f0e84eaacfa 100644 --- a/Tests/Perf/metrics-test.yml +++ b/Tests/Perf/metrics-test.yml @@ -11,4 +11,4 @@ startupTimeTest: binarySizeTest: diffMin: 200 KiB - diffMax: 975 KiB + diffMax: 1100 KiB diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index 6e832e2ea8b..862175b47cd 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/Integrations/SessionReplay/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift index a8d5eb0ad64..0c3519175bd 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -577,6 +577,120 @@ class SentrySessionReplayTests: XCTestCase { XCTAssertTrue(SentrySessionReplay.shouldEnableSessionReplay(environmentChecker: environmentChecker, experimentalOptions: experimentalOptions)) } + // MARK: - Frame Rate Tests + + func testFrameRate_1FPS_takesScreenshotsAtCorrectInterval() { + // Arrange + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 1 + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + fixture.screenshotProvider.lastImageCall = nil + + // Act & Assert - advance by 0.9 seconds, screenshot should NOT be taken + fixture.dateProvider.advance(by: 0.9) + Dynamic(sut).newFrame(nil) + XCTAssertNil(fixture.screenshotProvider.lastImageCall, "Screenshot should not be taken before 1 second interval") + + // Act & Assert - advance to exactly 1.0 seconds, screenshot SHOULD be taken + fixture.dateProvider.advance(by: 0.1) + Dynamic(sut).newFrame(nil) + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot should be taken at 1 second interval for 1 FPS") + } + + func testFrameRate_2FPS_takesScreenshotsAtCorrectInterval() { + // Arrange + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 2 + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + fixture.screenshotProvider.lastImageCall = nil + + // Act & Assert - advance by 0.4 seconds, screenshot should NOT be taken + fixture.dateProvider.advance(by: 0.4) + Dynamic(sut).newFrame(nil) + XCTAssertNil(fixture.screenshotProvider.lastImageCall, "Screenshot should not be taken before 0.5 second interval") + + // Act & Assert - advance to 0.5 seconds, screenshot SHOULD be taken + fixture.dateProvider.advance(by: 0.1) + Dynamic(sut).newFrame(nil) + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot should be taken at 0.5 second interval for 2 FPS") + + // Act & Assert - reset and test second screenshot + fixture.screenshotProvider.lastImageCall = nil + fixture.dateProvider.advance(by: 0.4) + Dynamic(sut).newFrame(nil) + XCTAssertNil(fixture.screenshotProvider.lastImageCall, "Screenshot should not be taken before another 0.5 seconds") + + fixture.dateProvider.advance(by: 0.1) + Dynamic(sut).newFrame(nil) + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot should be taken at next 0.5 second interval") + } + + func testFrameRate_10FPS_takesScreenshotsAtCorrectInterval() { + // Arrange + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 10 + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + // Expected interval: 1.0 / 10.0 = 0.1 seconds + // Take first screenshot to establish baseline + fixture.dateProvider.advance(by: 0.1) + Dynamic(sut).newFrame(nil) + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "First screenshot should be taken") + + fixture.screenshotProvider.lastImageCall = nil + + // Act & Assert - advance by 0.09 seconds, screenshot should NOT be taken + fixture.dateProvider.advance(by: 0.09) + Dynamic(sut).newFrame(nil) + XCTAssertNil(fixture.screenshotProvider.lastImageCall, "Screenshot should not be taken before 0.1 second interval") + + // Act & Assert - advance to reach 0.1 second interval, screenshot SHOULD be taken + fixture.dateProvider.advance(by: 0.01) + Dynamic(sut).newFrame(nil) + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot should be taken at 0.1 second interval for 10 FPS") + } + + func testFrameRate_multipleScreenshots_respectsInterval() { + // Arrange + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 5 + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + // Expected interval: 1.0 / 5.0 = 0.2 seconds + var screenshotCount = 0 + + // Act & Assert - take 5 screenshots over 1 second + // Each screenshot resets the timer, so we need to advance by the full interval each time + for i in 0..<5 { + // Advance by full interval + fixture.dateProvider.advance(by: 0.2) + Dynamic(sut).newFrame(nil) + + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot #\(i + 1) should be taken at \(Double(i + 1) * 0.2) seconds") + screenshotCount += 1 + fixture.screenshotProvider.lastImageCall = nil + + // Advance by less than interval and verify no screenshot + if i < 4 { // Don't test after the last screenshot + fixture.dateProvider.advance(by: 0.1) + Dynamic(sut).newFrame(nil) + XCTAssertNil(fixture.screenshotProvider.lastImageCall, "No screenshot should be taken at \(Double(i + 1) * 0.2 + 0.1) seconds") + } + } + + XCTAssertEqual(screenshotCount, 5, "Should have taken exactly 5 screenshots in 1 second for 5 FPS") + } + // MARK: - Helpers private func assertFullSession(_ sessionReplay: SentrySessionReplay, expected: Bool) { diff --git a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+Common.swift b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+Common.swift new file mode 100644 index 00000000000..4d45a85959c --- /dev/null +++ b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+Common.swift @@ -0,0 +1,1359 @@ +// swiftlint:disable file_length +#if os(iOS) && !targetEnvironment(macCatalyst) +import AVKit +import Foundation +import PDFKit +import SafariServices +@_spi(Private) @testable import Sentry +import SentryTestUtils +import SwiftUI +import UIKit +import WebKit +import XCTest + +/// See `SentryUIRedactBuilderTests.swift` for more information on how to print the internal view hierarchy of a view. +class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftlint:disable:this type_name + private func getSut(maskAllText: Bool, maskAllImages: Bool, maskedViewClasses: [AnyClass] = [], unmaskedViewClasses: [AnyClass] = []) -> SentryUIRedactBuilder { + return SentryUIRedactBuilder(options: TestRedactOptions( + maskAllText: maskAllText, + maskAllImages: maskAllImages, + maskedViewClasses: maskedViewClasses, + unmaskedViewClasses: unmaskedViewClasses + )) + } + + // MARK: - Baseline + + func testRedact_withNoSensitiveViews_shouldNotRedactAnything() { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + let view = UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + rootView.addSubview(view) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + XCTAssertEqual(result.count, 0) + } + + // - MARK: - Visibility & Opacity (layer.isHidden and layer.opacity) + + func testRedact_withHiddenSensitiveView_shouldNotRedactView() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + // Tests that views with isHidden=true are skipped in mapRedactRegion + // (early return when layer.isHidden == true) + let ignoredLabel = UILabel(frame: CGRect(x: 20, y: 10, width: 40, height: 5)) + ignoredLabel.textColor = UIColor.red + ignoredLabel.isHidden = true + ignoredLabel.text = "Ignored" + rootView.addSubview(ignoredLabel) + + let redactedLabel = UILabel(frame: CGRect(x: 20, y: 20, width: 50, height: 8)) + redactedLabel.textColor = UIColor.blue + redactedLabel.isHidden = false + redactedLabel.text = "Redacted" + rootView.addSubview(redactedLabel) + + // View Hierarchy: + // --------------- + // > + // |