diff --git a/CHANGELOG.md b/CHANGELOG.md index 6900fb151a..8583c9edcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,9 @@ - Fix axis-aligned transform detection for optimized opaque view clipping - Rename `SentryMechanismMeta` to `SentryMechanismContext` to resolve Kotlin Multi-Platform build errors (#6607) - 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. ### Improvements diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index a734ac621f..076fb72ab8 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -815,7 +815,6 @@ 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 */; }; - D4AF7D262E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D252E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift */; }; 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 */; }; @@ -6271,7 +6270,6 @@ 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 */, diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index e79c902ea6..f4411453fa 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift @@ -618,9 +618,58 @@ final class SentryUIRedactBuilder { } /// 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 } } diff --git a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+Common.swift b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+Common.swift index b267777baf..9176dc1dc6 100644 --- a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+Common.swift +++ b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+Common.swift @@ -143,6 +143,9 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli let opaqueView = UIView(frame: CGRect(x: 10, y: 10, width: 60, height: 60)) opaqueView.backgroundColor = .white + opaqueView.isOpaque = true + opaqueView.layer.isOpaque = true + opaqueView.layer.backgroundColor = UIColor.white.cgColor rootView.addSubview(opaqueView) // View Hierarchy: @@ -837,6 +840,9 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli let overView = UIView(frame: rootView.bounds) overView.backgroundColor = .black + overView.isOpaque = true + overView.layer.isOpaque = true + overView.layer.backgroundColor = UIColor.black.cgColor rootView.addSubview(overView) // View Hierarchy: @@ -1098,7 +1104,7 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli // View Hierarchy: // --------------- - // == iOS 26 == + // == iOS 26.1 - Xcode 26 == // > // | ; layer = ; value: 0.000000> // | | > @@ -1109,48 +1115,36 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli // | | | | > // | | | | | ; layer = > // + // == iOS 26.1 - Xcode 16.4 == + // > + // | ; value: 0.000000> + // | | <_UISlideriOSVisualElement: 0x100f06990; frame = (0 0; 80 20); opaque = NO; autoresize = W+H; layer = > + // // == iOS 18 & 17 & 16 == // > // | ; value: 0.000000> // | | <_UISlideriOSVisualElement: 0x13ed0fbd0; frame = (0 0; 80 20); opaque = NO; autoresize = W+H; layer = > // -- Act -- + print(rootView.value(forKey: "recursiveDescription")!) let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) // -- Assert -- - // UISlider behavior differs by iOS version - if #available(iOS 26.0, *) { - // On iOS 26, UISlider uses a new visual implementation that creates clipping regions - // even though the slider itself is in the ignore list - let region0 = try XCTUnwrap(result.element(at: 0)) - XCTAssertNil(region0.color) - XCTAssertEqual(region0.size, CGSize(width: 37, height: 24)) - XCTAssertEqual(region0.type, .clipOut) - XCTAssertEqual(region0.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 8)) - - let region1 = try XCTUnwrap(result.element(at: 1)) + if #available(iOS 26, *), isBuiltWithSDK26() { + // Only applies to Liquid Glass (enabled when built with Xcode 26+) + let region1 = try XCTUnwrap(result.element(at: 0)) XCTAssertNil(region1.color) XCTAssertEqual(region1.size, CGSize(width: 80, height: 6)) XCTAssertEqual(region1.type, .clipBegin) XCTAssertEqual(region1.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 17)) - let region2 = try XCTUnwrap(result.element(at: 2)) + let region2 = try XCTUnwrap(result.element(at: 1)) XCTAssertNil(region2.color) - XCTAssertEqual(region2.size, CGSize(width: 0, height: 6)) - XCTAssertEqual(region2.type, .clipOut) + XCTAssertEqual(region2.size, CGSize(width: 80, height: 6)) + XCTAssertEqual(region2.type, .clipEnd) XCTAssertEqual(region2.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 17)) - - let region3 = try XCTUnwrap(result.element(at: 3)) - XCTAssertNil(region3.color) - XCTAssertEqual(region3.size, CGSize(width: 80, height: 6)) - XCTAssertEqual(region3.type, .clipEnd) - XCTAssertEqual(region3.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 17)) - - // Assert that there are no other regions - XCTAssertEqual(result.count, 4) } else { - // On iOS < 26, UISlider is completely ignored (no regions) XCTAssertEqual(result.count, 0) } } @@ -1347,6 +1341,17 @@ private class TestGridView: UIView { ctx.setFillColor(UIColor.orange.cgColor) ctx.fill(CGRect(x: midX, y: midY, width: bounds.width - midX, height: bounds.height - midY)) } + +} + +private func isBuiltWithSDK26() -> Bool { + guard let value = Bundle.main.object(forInfoDictionaryKey: "DTXcode") as? String else { + return false + } + guard let xcodeVersion = Int(value) else { + return false + } + return xcodeVersion >= 2_600 } #endif // os(iOS) && !targetEnvironment(macCatalyst) diff --git a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+EdgeCases.swift b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+EdgeCases.swift index e470427139..7d0c4f3db4 100644 --- a/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+EdgeCases.swift +++ b/Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+EdgeCases.swift @@ -153,7 +153,36 @@ class SentryUIRedactBuilderTests_EdgeCases: SentryUIRedactBuilderTests { // swif let result = sut.redactRegionsFor(view: rootView) // -- Assert -- - // We still expect at least one redact (for the label); the rotated cover shouldn't clear all regions + // Without explicit opaque configuration, no clipOut should be added; label remains redacted + let onlyRegion = try XCTUnwrap(result.element(at: 0)) + XCTAssertEqual(onlyRegion.color, UIColor.purple) + XCTAssertEqual(onlyRegion.type, .redact) + XCTAssertEqual(onlyRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(onlyRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + XCTAssertEqual(result.count, 1) + } + + func testOpaqueRotatedView_coveringRoot_explicitOpaque_shouldCreateClipOut() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + label.text = "Hello World" + label.textColor = .purple + rootView.addSubview(label) + + let cover = UIView(frame: rootView.bounds) + cover.backgroundColor = .black + cover.isOpaque = true + cover.layer.isOpaque = true + cover.layer.backgroundColor = UIColor.black.cgColor + cover.transform = CGAffineTransform(rotationAngle: .pi / 8) + rootView.addSubview(cover) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- let region = try XCTUnwrap(result.element(at: 0)) XCTAssertNil(region.color) XCTAssertEqual(region.size, CGSize(width: 100, height: 100)) @@ -267,8 +296,47 @@ class SentryUIRedactBuilderTests_EdgeCases: SentryUIRedactBuilderTests { // swif let result = sut.redactRegionsFor(view: rootView) // -- Assert -- - // The rotated opaque view should create a clipOut region (not clear the redacting array) - // because isAxisAligned returns false + // Without explicit opaque configuration, expect only the rotated label redact region + let labelRegion = try XCTUnwrap(result.element(at: 0)) + XCTAssertEqual(labelRegion.color, UIColor.purple) + XCTAssertEqual(labelRegion.type, .redact) + XCTAssertEqual(labelRegion.size, CGSize(width: 40, height: 40)) + XCTAssertAffineTransformEqual( + labelRegion.transform, + CGAffineTransform( + a: 0.70710678118654757, + b: 0.70710678118654746, + c: -0.70710678118654746, + d: 0.70710678118654757, + tx: 40, + ty: 11.715728752538098 + ), + accuracy: 0.001 + ) + XCTAssertEqual(result.count, 1) + } + + func testIsAxisAligned_withRotation_explicitOpaque_shouldReturnFalse() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + label.transform = CGAffineTransform(rotationAngle: .pi / 4) + label.textColor = .purple + rootView.addSubview(label) + + let opaqueView = UIView(frame: rootView.bounds) + opaqueView.backgroundColor = .black + opaqueView.isOpaque = true + opaqueView.layer.isOpaque = true + opaqueView.layer.backgroundColor = UIColor.black.cgColor + opaqueView.transform = CGAffineTransform(rotationAngle: .pi / 4) + rootView.addSubview(opaqueView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- let containerRegion = try XCTUnwrap(result.element(at: 0)) XCTAssertNil(containerRegion.color) XCTAssertEqual(containerRegion.type, .clipOut) @@ -325,6 +393,47 @@ class SentryUIRedactBuilderTests_EdgeCases: SentryUIRedactBuilderTests { // swif let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) + // -- Assert -- + // Without explicit opaque configuration, view should not be treated as opaque; expect only label redact + let labelRegion = try XCTUnwrap(result.element(at: 0)) + XCTAssertEqual(labelRegion.color, UIColor.purple) + XCTAssertEqual(labelRegion.type, .redact) + XCTAssertEqual(labelRegion.size, CGSize(width: 40, height: 40)) + XCTAssertAffineTransformEqual( + labelRegion.transform, + CGAffineTransform( + a: 1, + b: 0, + c: 0, + d: 1, + tx: 20, + ty: 20 + ), + accuracy: 0.001 + ) + XCTAssertEqual(result.count, 1) + } + + func testIsAxisAligned_withScaleOnly_explicitOpaque_shouldReturnTrue() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + label.text = "Hello, World!" + label.textColor = .purple + rootView.addSubview(label) + + let opaqueView = UIView(frame: rootView.bounds) + opaqueView.backgroundColor = .black + opaqueView.isOpaque = true + opaqueView.layer.isOpaque = true + opaqueView.layer.backgroundColor = UIColor.black.cgColor + opaqueView.transform = CGAffineTransform(scaleX: 2, y: 2) + rootView.addSubview(opaqueView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + // -- Assert -- let containerRegion = try XCTUnwrap(result.element(at: 0)) XCTAssertNil(containerRegion.color) @@ -364,6 +473,159 @@ class SentryUIRedactBuilderTests_EdgeCases: SentryUIRedactBuilderTests { // swif XCTAssertEqual(result.count, 2) } + // MARK: - Opaque Behavior Without Explicit Config + + func testOpaqueRotatedView_coveringRoot_withoutExplicitOpaqueConfig_shouldNotInsertClipOut() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + label.text = "Hello World" + label.textColor = .purple + rootView.addSubview(label) + + // Add a rotated cover but DO NOT mark it explicitly opaque + let cover = UIView(frame: rootView.bounds) + cover.backgroundColor = .black + cover.transform = CGAffineTransform(rotationAngle: .pi / 8) + rootView.addSubview(cover) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // Without explicit opaque configuration, no clipOut should be added; label remains redacted + let onlyRegion = try XCTUnwrap(result.element(at: 0)) + XCTAssertEqual(onlyRegion.color, UIColor.purple) + XCTAssertEqual(onlyRegion.type, .redact) + XCTAssertEqual(onlyRegion.size, CGSize(width: 40, height: 40)) + XCTAssertEqual(onlyRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20)) + XCTAssertEqual(result.count, 1) + } + + func testIsAxisAligned_withRotation_withoutOpaqueConfig_shouldNotInsertClipOut() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + label.transform = CGAffineTransform(rotationAngle: .pi / 4) + label.textColor = .purple + rootView.addSubview(label) + + let cover = UIView(frame: rootView.bounds) + cover.backgroundColor = .black + cover.transform = CGAffineTransform(rotationAngle: .pi / 4) + rootView.addSubview(cover) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // Without explicit opaque configuration, expect only the rotated label redact region + let labelRegion = try XCTUnwrap(result.element(at: 0)) + XCTAssertEqual(labelRegion.color, UIColor.purple) + XCTAssertEqual(labelRegion.type, .redact) + XCTAssertEqual(labelRegion.size, CGSize(width: 40, height: 40)) + XCTAssertAffineTransformEqual( + labelRegion.transform, + CGAffineTransform( + a: 0.70710678118654757, + b: 0.70710678118654746, + c: -0.70710678118654746, + d: 0.70710678118654757, + tx: 40, + ty: 11.715728752538098 + ), + accuracy: 0.001 + ) + XCTAssertEqual(result.count, 1) + } + + func testIsAxisAligned_withScaleOnly_withoutOpaqueConfig_shouldNotInsertClipOut() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let label = UILabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + label.text = "Hello, World!" + label.textColor = .purple + rootView.addSubview(label) + + let cover = UIView(frame: rootView.bounds) + cover.backgroundColor = .black + cover.transform = CGAffineTransform(scaleX: 2, y: 2) + rootView.addSubview(cover) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // Without explicit opaque configuration, expect only the label redact region + let labelRegion = try XCTUnwrap(result.element(at: 0)) + XCTAssertEqual(labelRegion.color, UIColor.purple) + XCTAssertEqual(labelRegion.type, .redact) + XCTAssertEqual(labelRegion.size, CGSize(width: 40, height: 40)) + XCTAssertAffineTransformEqual( + labelRegion.transform, + CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20), + accuracy: 0.001 + ) + XCTAssertEqual(result.count, 1) + } + + func testRedactRegionsFor_withMixedRegionTypes_withoutOpaqueConfig_shouldNotInsertClipOut() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let label = UILabel(frame: CGRect(x: 10, y: 10, width: 20, height: 20)) + label.text = "Hello, World!" + label.textColor = .red + rootView.addSubview(label) + + let cover = UIView(frame: CGRect(x: 30, y: 30, width: 20, height: 20)) + cover.backgroundColor = .white + rootView.addSubview(cover) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // Without explicit opaque configuration, only the label should be redacted + let region = try XCTUnwrap(result.element(at: 0)) + XCTAssertEqual(region.color, UIColor.red) + XCTAssertEqual(region.type, .redact) + XCTAssertEqual(region.size, CGSize(width: 20, height: 20)) + XCTAssertEqual(region.transform, CGAffineTransform(a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 10.0, ty: 10.0)) + XCTAssertEqual(result.count, 1) + } + + func testFullyOpaqueView_withoutExplicitConfig_shouldNotClearRedactions() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + let label = UILabel(frame: CGRect(x: 10, y: 10, width: 80, height: 20)) + label.text = "Secret Text" + label.textColor = .purple + rootView.addSubview(label) + + // Add a cover view but DO NOT configure layer/background opacity here + let cover = UIView(frame: rootView.bounds) + cover.backgroundColor = .white + rootView.addSubview(cover) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // Without explicit opaque configuration, label should still be redacted + let labelRegions = result.filter { $0.type == .redact && $0.color == UIColor.purple } + XCTAssertEqual(labelRegions.count, 1) + + // Assert that no other regions + XCTAssertEqual(result.count, 1) + } + // MARK: - Region Ordering func testRedactRegionsFor_shouldReturnReversedOrder() throws { @@ -452,6 +714,35 @@ class SentryUIRedactBuilderTests_EdgeCases: SentryUIRedactBuilderTests { // swif let sut = getSut(maskAllText: true, maskAllImages: true) let result = sut.redactRegionsFor(view: rootView) + // -- Assert -- + // Without explicit opaque configuration, the 20x20 view should not clip; only label redact remains + let onlyRegion = try XCTUnwrap(result.element(at: 0)) + XCTAssertEqual(onlyRegion.color, UIColor.red) + XCTAssertEqual(onlyRegion.type, .redact) + XCTAssertEqual(onlyRegion.size, CGSize(width: 20, height: 20)) + XCTAssertEqual(onlyRegion.transform, CGAffineTransform(a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 10.0, ty: 10.0)) + XCTAssertEqual(result.count, 1) + } + + func testRedactRegionsFor_withMixedRegionTypes_explicitOpaque_shouldOrderCorrectly() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let label = UILabel(frame: CGRect(x: 10, y: 10, width: 20, height: 20)) + label.text = "Hello, World!" + label.textColor = .red + rootView.addSubview(label) + + let opaqueView = UIView(frame: CGRect(x: 30, y: 30, width: 20, height: 20)) + opaqueView.backgroundColor = .white + opaqueView.isOpaque = true + opaqueView.layer.isOpaque = true + opaqueView.layer.backgroundColor = UIColor.white.cgColor + rootView.addSubview(opaqueView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + // -- Assert -- let firstRegion = try XCTUnwrap(result.element(at: 0)) XCTAssertNil(firstRegion.color) @@ -469,6 +760,59 @@ class SentryUIRedactBuilderTests_EdgeCases: SentryUIRedactBuilderTests { // swif XCTAssertEqual(result.count, 2) } + // MARK: - Transparent Overlay (PopupDialog) Repro + + func testTransparentOverlay_shouldNotClearUnderlyingLabels_reproFromDump() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 402, height: 874)) + + // Container matching the dialog location + let contentHolder = UIView(frame: CGRect(x: 31, y: 377.6667, width: 340, height: 118.6667)) + contentHolder.backgroundColor = .black + rootView.addSubview(contentHolder) + + let contentInner = UIView(frame: CGRect(x: 0, y: 0, width: 340, height: 118.6667)) + contentInner.backgroundColor = .white + contentInner.clipsToBounds = true + contentHolder.addSubview(contentInner) + + let titleLabel = UILabel(frame: CGRect(x: 20, y: 30, width: 300, height: 17)) + titleLabel.text = "THIS IS THE DIALOG TITLE" + contentInner.addSubview(titleLabel) + + let messageLabel = UILabel(frame: CGRect(x: 20, y: 55, width: 300, height: 33.6667)) + messageLabel.text = "This is the message section of the popup dialog default view" + contentInner.addSubview(messageLabel) + + // Semi-transparent red overlay across the whole screen + let overlay = UIView(frame: rootView.bounds) + overlay.backgroundColor = .red + overlay.alpha = 0.2 + rootView.addSubview(overlay) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + let labelRegions = result.filter { $0.type == .redact } + XCTAssertGreaterThanOrEqual(labelRegions.count, 2) + + let title = try XCTUnwrap(labelRegions.first { region in + region.size == CGSize(width: 300, height: 17) && + abs(region.transform.tx - 51) < 0.01 && + abs(region.transform.ty - 407.6667) < 0.02 + }) + XCTAssertEqual(title.type, .redact) + + let message = try XCTUnwrap(labelRegions.first { region in + region.size == CGSize(width: 300, height: 33.6667) && + abs(region.transform.tx - 51) < 0.01 && + abs(region.transform.ty - 432.6667) < 0.02 + }) + XCTAssertEqual(message.type, .redact) + } + // MARK: - Sublayer Sorting (zPosition) func testMapRedactRegion_withDifferentZPositions_shouldSortCorrectly() throws { @@ -565,6 +909,241 @@ class SentryUIRedactBuilderTests_EdgeCases: SentryUIRedactBuilderTests { // swif XCTAssertEqual(result.count, 3) } + // MARK: - Opaque View Detection + + func testSemiTransparentOverlay_shouldNotClearRedactions() throws { + // -- Arrange -- + // This test reproduces the issue from https://github.com/getsentry/sentry-cocoa/pull/6629#issuecomment-3479730690 + // where a semi-transparent overlay (alpha = 0.2) was incorrectly treated as opaque and cleared all previous redactions. + + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + // Add labels that should be redacted + let label1 = UILabel(frame: CGRect(x: 10, y: 10, width: 80, height: 20)) + label1.text = "This is some text" + label1.textColor = .purple + rootView.addSubview(label1) + + let label2 = UILabel(frame: CGRect(x: 10, y: 40, width: 80, height: 20)) + label2.text = "This is the message section" + label2.textColor = .purple + rootView.addSubview(label2) + + // Add a semi-transparent overlay that covers the entire root (simulates PopupDialogOverlayView) + let overlay = UIView(frame: rootView.bounds) + overlay.backgroundColor = .red + overlay.alpha = 0.2 // Semi-transparent - should NOT be treated as opaque + rootView.addSubview(overlay) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // The semi-transparent overlay should NOT clear the label redactions + // We expect both labels to still be redacted + XCTAssertGreaterThanOrEqual(result.count, 2, "Semi-transparent overlay should not clear previous redactions") + + // Verify that both labels are in the redaction list + let labelRegions = result.filter { $0.type == .redact && $0.color == UIColor.purple } + XCTAssertEqual(labelRegions.count, 2, "Both labels should be redacted") + + // Verify label 1 is redacted + let label1Region = try XCTUnwrap(labelRegions.first { $0.size == CGSize(width: 80, height: 20) && $0.transform.tx == 10 && $0.transform.ty == 10 }) + XCTAssertEqual(label1Region.color, UIColor.purple) + XCTAssertEqual(label1Region.type, .redact) + + // Verify label 2 is redacted + let label2Region = try XCTUnwrap(labelRegions.first { $0.size == CGSize(width: 80, height: 20) && $0.transform.tx == 10 && $0.transform.ty == 40 }) + XCTAssertEqual(label2Region.color, UIColor.purple) + XCTAssertEqual(label2Region.type, .redact) + } + + func testFullyOpaqueView_shouldClearRedactions() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + // Add a label that should be redacted + let label = UILabel(frame: CGRect(x: 10, y: 10, width: 80, height: 20)) + label.text = "Secret Text" + label.textColor = .purple + rootView.addSubview(label) + + // Add a fully opaque view that covers the entire root + let opaqueView = UIView(frame: rootView.bounds) + opaqueView.backgroundColor = .white + opaqueView.alpha = 1.0 // Fully opaque + opaqueView.isOpaque = true + // Ensure both view and layer background colors are set and opaque + opaqueView.layer.backgroundColor = UIColor.white.cgColor + opaqueView.layer.isOpaque = true + rootView.addSubview(opaqueView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // The fully opaque view should clear all previous redactions + // We expect no redact regions for the label (it's completely covered) + let labelRegions = result.filter { $0.type == .redact && $0.color == UIColor.purple } + XCTAssertEqual(labelRegions.count, 0, "Label should be cleared by fully opaque view") + } + + func testClipOutOverride_withSemiTransparentFullCover_shouldClearRedactions() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + // Add a label that should be redacted + let label = UILabel(frame: CGRect(x: 10, y: 10, width: 80, height: 20)) + label.text = "Secret Text" + label.textColor = .purple + rootView.addSubview(label) + + // Add a semi-transparent overlay covering the entire root and force clip-out via override + let overlay = UIView(frame: rootView.bounds) + overlay.backgroundColor = .black + overlay.alpha = 0.2 // Semi-transparent; would not be opaque without override + SentryRedactViewHelper.clipOutView(overlay) + rootView.addSubview(overlay) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // Axis-aligned full-cover with clipOut override should clear prior redactions entirely + XCTAssertEqual(result.count, 0) + } + + func testClipOutOverride_withSemiTransparentSubview_shouldInsertClipOutRegion() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + let label = UILabel(frame: CGRect(x: 10, y: 10, width: 20, height: 20)) + label.text = "Hello, World!" + label.textColor = .red + rootView.addSubview(label) + + // Semi-transparent subview; override to clip-out + let subview = UIView(frame: CGRect(x: 30, y: 30, width: 20, height: 20)) + subview.backgroundColor = .black + subview.alpha = 0.2 // Not opaque by itself + SentryRedactViewHelper.clipOutView(subview) + rootView.addSubview(subview) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + let firstRegion = try XCTUnwrap(result.element(at: 0)) + XCTAssertNil(firstRegion.color) + XCTAssertEqual(firstRegion.type, .clipOut) + XCTAssertEqual(firstRegion.size, CGSize(width: 20, height: 20)) + XCTAssertEqual(firstRegion.transform, CGAffineTransform(a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 30.0, ty: 30.0)) + + let secondRegion = try XCTUnwrap(result.element(at: 1)) + XCTAssertEqual(secondRegion.color, UIColor.red) + XCTAssertEqual(secondRegion.type, .redact) + XCTAssertEqual(secondRegion.size, CGSize(width: 20, height: 20)) + XCTAssertEqual(secondRegion.transform, CGAffineTransform(a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 10.0, ty: 10.0)) + + XCTAssertEqual(result.count, 2) + } + + func testViewWithSemiTransparentBackground_shouldNotBeTreatedAsOpaque() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + let label = UILabel(frame: CGRect(x: 10, y: 10, width: 80, height: 20)) + label.text = "Secret Text" + label.textColor = .purple + rootView.addSubview(label) + + // Add a view with semi-transparent background color (alpha in the color itself) + let semiTransparentView = UIView(frame: rootView.bounds) + semiTransparentView.backgroundColor = UIColor.red.withAlphaComponent(0.5) + rootView.addSubview(semiTransparentView) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // The semi-transparent view should NOT clear the label redactions + let labelRegions = result.filter { $0.type == .redact && $0.color == UIColor.purple } + XCTAssertEqual(labelRegions.count, 1, "Label should still be redacted") + } + + func testViewWithTransparentLayerBackground_shouldNotBeTreatedAsOpaque() throws { + // -- Arrange -- + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + let label = UILabel(frame: CGRect(x: 10, y: 10, width: 80, height: 20)) + label.text = "Secret Text" + label.textColor = .purple + rootView.addSubview(label) + + // Add a view with transparent layer background + let viewWithTransparentLayer = UIView(frame: rootView.bounds) + viewWithTransparentLayer.backgroundColor = .red + viewWithTransparentLayer.layer.backgroundColor = UIColor.red.withAlphaComponent(0.3).cgColor + rootView.addSubview(viewWithTransparentLayer) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // The view with transparent layer background should NOT clear the label redactions + let labelRegions = result.filter { $0.type == .redact && $0.color == UIColor.purple } + XCTAssertEqual(labelRegions.count, 1, "Label should still be redacted") + } + + func testSemiTransparentOverlayWithBackgroundText_shouldMaskAllText() throws { + // -- Arrange -- + // This test verifies that text in the background is still masked when there's a semi-transparent overlay on top + let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + // Add background text that should be masked + let backgroundLabel = UILabel(frame: CGRect(x: 10, y: 10, width: 80, height: 20)) + backgroundLabel.text = "Background Secret" + backgroundLabel.textColor = .blue + rootView.addSubview(backgroundLabel) + + // Add a semi-transparent overlay + let overlay = UIView(frame: rootView.bounds) + overlay.backgroundColor = .white + overlay.alpha = 0.5 // Semi-transparent + rootView.addSubview(overlay) + + // Add foreground text that should also be masked + let foregroundLabel = UILabel(frame: CGRect(x: 10, y: 40, width: 80, height: 20)) + foregroundLabel.text = "Foreground Secret" + foregroundLabel.textColor = .green + rootView.addSubview(foregroundLabel) + + // -- Act -- + let sut = getSut(maskAllText: true, maskAllImages: true) + let result = sut.redactRegionsFor(view: rootView) + + // -- Assert -- + // Both labels should be redacted regardless of the semi-transparent overlay + let backgroundLabelRegion = result.first { $0.type == .redact && $0.color == UIColor.blue } + XCTAssertNotNil(backgroundLabelRegion, "Background label should be redacted") + + let foregroundLabelRegion = result.first { $0.type == .redact && $0.color == UIColor.green } + XCTAssertNotNil(foregroundLabelRegion, "Foreground label should be redacted") + + // Verify both labels are in the redaction list + let labelRegions = result.filter { $0.type == .redact && ($0.color == UIColor.blue || $0.color == UIColor.green) } + XCTAssertEqual(labelRegions.count, 2, "Both labels should be redacted") + + // Assert that no other regions + XCTAssertEqual(result.count, 2) + } + // MARK: - Nested Clipping func testMapRedactRegion_withNestedClipToBounds_shouldCreateNestedClipRegions() throws { diff --git a/Tests/SentryTests/ViewCapture/SentryViewPhotographerTests.swift b/Tests/SentryTests/ViewCapture/SentryViewPhotographerTests.swift index e047c21dc8..b4c088d5c7 100644 --- a/Tests/SentryTests/ViewCapture/SentryViewPhotographerTests.swift +++ b/Tests/SentryTests/ViewCapture/SentryViewPhotographerTests.swift @@ -52,6 +52,9 @@ class SentryViewPhotographerTests: XCTestCase { label.text = "Test" let viewOnTop = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) viewOnTop.backgroundColor = .green + viewOnTop.isOpaque = true + viewOnTop.layer.isOpaque = true + viewOnTop.layer.backgroundColor = UIColor.green.cgColor let image = try XCTUnwrap(prepare(views: [label, viewOnTop])) let pixel = color(at: CGPoint(x: 10, y: 10), in: image) @@ -64,9 +67,15 @@ class SentryViewPhotographerTests: XCTestCase { label.text = "Test" let viewOnTop1 = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) viewOnTop1.backgroundColor = .red + viewOnTop1.isOpaque = true + viewOnTop1.layer.isOpaque = true + viewOnTop1.layer.backgroundColor = UIColor.red.cgColor let viewOnTop2 = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) viewOnTop2.backgroundColor = .green + viewOnTop2.isOpaque = true + viewOnTop2.layer.isOpaque = true + viewOnTop2.layer.backgroundColor = UIColor.green.cgColor let image = try XCTUnwrap(prepare(views: [label, viewOnTop1, viewOnTop2])) let pixel = color(at: CGPoint(x: 10, y: 10), in: image) @@ -118,6 +127,9 @@ class SentryViewPhotographerTests: XCTestCase { label.text = "Test" let viewOnTop = UIView(frame: CGRect(x: 20, y: 0, width: 20, height: 50)) viewOnTop.backgroundColor = .green + viewOnTop.isOpaque = true + viewOnTop.layer.isOpaque = true + viewOnTop.layer.backgroundColor = UIColor.green.cgColor let image = try XCTUnwrap(prepare(views: [label, viewOnTop])) let pixel1 = color(at: CGPoint(x: 10, y: 10), in: image) @@ -134,6 +146,9 @@ class SentryViewPhotographerTests: XCTestCase { let viewOnTop = UIView(frame: CGRect(x: 0, y: 15, width: 50, height: 20)) viewOnTop.backgroundColor = .green viewOnTop.transform = CGAffineTransform(rotationAngle: 90 * .pi / 180.0) + viewOnTop.isOpaque = true + viewOnTop.layer.isOpaque = true + viewOnTop.layer.backgroundColor = UIColor.green.cgColor let image = try XCTUnwrap(prepare(views: [label, viewOnTop])) let pixel1 = color(at: CGPoint(x: 10, y: 10), in: image) @@ -151,6 +166,9 @@ class SentryViewPhotographerTests: XCTestCase { let parentView = UIView(frame: CGRect(x: 0, y: 12.5, width: 50, height: 25)) parentView.backgroundColor = .green parentView.transform = CGAffineTransform(rotationAngle: .pi / 2) + parentView.isOpaque = true + parentView.layer.isOpaque = true + parentView.layer.backgroundColor = UIColor.green.cgColor parentView.addSubview(label) let image = try XCTUnwrap(prepare(views: [parentView] )) @@ -240,6 +258,9 @@ class SentryViewPhotographerTests: XCTestCase { func testNotMaskingLabelInsideClippedViewHiddenByAnOpaqueExternalView() throws { let topView = UIView(frame: CGRect(x: 25, y: 0, width: 25, height: 25)) topView.backgroundColor = .green + topView.isOpaque = true + topView.layer.isOpaque = true + topView.layer.backgroundColor = UIColor.green.cgColor let label1 = UILabel(frame: CGRect(x: 0, y: 0, width: 50, height: 25)) label1.text = "Test" diff --git a/docs/opaque-masking.md b/docs/opaque-masking.md new file mode 100644 index 0000000000..0935004683 --- /dev/null +++ b/docs/opaque-masking.md @@ -0,0 +1,44 @@ +# Opaque Clipping and Redaction – Design Notes + +## Summary + +We treat a view as “opaque enough to clear/redact content behind it” only when we are certain it fully hides what’s below. To avoid leaks like a semi‑transparent full‑screen overlay clearing sensitive text, we use a strict definition of opacity. + +## Why strict? + +- Semi‑transparent overlays (e.g. PopupDialog) often render a dimming tint in a child subview with alpha < 1. The container may look like a blocker, but the effective result is translucent. +- A false positive (classifying translucent as opaque) can leak sensitive content. A false negative only loses an optimization (we still redact lower views). + +## The rule we use + +We classify a view as opaque only if: + +1. presentation layer `opacity == 1.0` (no global transparency) +2. `view.backgroundColor` is fully opaque (alpha == 1.0) +3. `layer.backgroundColor` is fully opaque (alpha == 1.0) +4. `view.isOpaque == true` and `layer.isOpaque == true` + +Additionally, we allow an explicit override via `SentryRedactViewHelper.shouldClipOut(view)` to force clip‑out. + +This avoids misclassifying composite overlays where a child view (not the container) provides the semitransparent effect. + +## Tests: Arrange explicitly + +When a test expects “opaque clipping”, explicitly arrange the view to satisfy the strict rule: + +- `view.alpha = 1.0` +- `view.backgroundColor` with alpha 1.0 +- `view.isOpaque = true` +- `view.layer.isOpaque = true` +- `view.layer.backgroundColor` with alpha 1.0 + +For scenarios that should not clip (e.g. translucent overlay), omit these properties or use alpha < 1.0. + +## PopupDialog edge case + +- Structure: a clear container with a child “overlay” subview filling bounds with `alpha < 1`. The container is not truly opaque; requiring BOTH view and layer to present fully opaque backgrounds (and isOpaque hints) prevents misclassification. + +## Tradeoffs + +- Strict rule: prevents leaks, might skip some clip‑outs (optimization only). +- If an app truly has a fully opaque blocker, it can set the explicit opaque properties or use `shouldClipOut`.