Skip to content

Commit 1655116

Browse files
authored
fix(session-replay): Include layer background color when checking if a view is opaque (#6629)
1 parent 052f627 commit 1655116

File tree

7 files changed

+730
-31
lines changed

7 files changed

+730
-31
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@
6666
- Fix axis-aligned transform detection for optimized opaque view clipping
6767
- Rename `SentryMechanismMeta` to `SentryMechanismContext` to resolve Kotlin Multi-Platform build errors (#6607)
6868
- Fix conversion of frame rate to time interval for session replay (#6623)
69+
- Change Session Replay masking to prevent semi‑transparent full‑screen overlays from clearing redactions by making opaque clipping stricter (#6629)
70+
Views now need to be fully opaque (view and layer backgrounds with alpha == 1) and report opaque to qualify for clip‑out.
71+
This avoids leaks at the cost of fewer clip‑out optimizations.
6972

7073
### Improvements
7174

Sentry.xcodeproj/project.pbxproj

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -815,7 +815,6 @@
815815
D4AF00212D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m in Sources */ = {isa = PBXBuildFile; fileRef = D4AF00202D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m */; };
816816
D4AF00232D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h in Headers */ = {isa = PBXBuildFile; fileRef = D4AF00222D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h */; };
817817
D4AF00252D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4AF00242D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m */; };
818-
D4AF7D262E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D252E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift */; };
819818
D4AF7D2A2E940493004F0F59 /* SentryUIRedactBuilderTests+Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D292E940492004F0F59 /* SentryUIRedactBuilderTests+Common.swift */; };
820819
D4AF7D2C2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D2B2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift */; };
821820
D4B0DC7F2DA9257A00DE61B6 /* SentryRenderVideoResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B0DC7E2DA9257200DE61B6 /* SentryRenderVideoResult.swift */; };
@@ -6271,7 +6270,6 @@
62716270
D45E2D772E003EBF0072A6B7 /* TestRedactOptions.swift in Sources */,
62726271
63FE720520DA66EC00CDBAE8 /* FileBasedTestCase.m in Sources */,
62736272
51B15F802BE88D510026A2F2 /* URLSessionTaskHelperTests.swift in Sources */,
6274-
D4AF7D262E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift in Sources */,
62756273
63EED6C32237989300E02400 /* SentryOptionsTest.m in Sources */,
62766274
7BBD18B22451804C00427C76 /* SentryRetryAfterHeaderParserTests.swift in Sources */,
62776275
7BD337E424A356180050DB6E /* SentryCrashIntegrationTests.swift in Sources */,

Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,9 +618,58 @@ final class SentryUIRedactBuilder {
618618
}
619619

620620
/// Indicates whether the view is opaque and will block other views behind it.
621+
///
622+
/// A view is considered opaque if it completely covers and hides any content behind it.
623+
/// This is used to optimize redaction by clearing out regions that are fully covered.
624+
///
625+
/// The method checks multiple properties because UIKit views can become transparent in several ways:
626+
/// - `view.alpha` (mapped to `layer.opacity`) can make the entire view semi-transparent
627+
/// - `view.backgroundColor` or `layer.backgroundColor` can have alpha components
628+
/// - Either the view or layer can explicitly set their `isOpaque` property to false
629+
///
630+
/// ## Implementation Notes:
631+
/// - We use the presentation layer when available to get the actual rendered state during animations
632+
/// - We require BOTH the view and the layer to appear opaque (alpha == 1 and marked opaque)
633+
/// to classify a view as opaque. This avoids false positives where only one side is configured,
634+
/// which previously caused semi‑transparent overlays or partially configured views to clear
635+
/// redactions behind them.
636+
/// - We use `SentryRedactViewHelper.shouldClipOut(view)` for views explicitly marked as opaque
637+
///
638+
/// ## Bug Fix Context:
639+
/// This implementation fixes the issue where semi-transparent overlays (e.g., with `alpha = 0.2`)
640+
/// were incorrectly treated as opaque, causing text behind them to not be redacted.
641+
/// See: https://github.com/getsentry/sentry-cocoa/pull/6629#issuecomment-3479730690
621642
private func isOpaque(_ view: UIView) -> Bool {
622643
let layer = view.layer.presentation() ?? view.layer
623-
return SentryRedactViewHelper.shouldClipOut(view) || (layer.opacity == 1 && view.backgroundColor != nil && (view.backgroundColor?.cgColor.alpha ?? 0) == 1)
644+
645+
// Allow explicit override: if a view is marked to clip out, treat it as opaque
646+
if SentryRedactViewHelper.shouldClipOut(view) {
647+
return true
648+
}
649+
650+
// First check: Ensure the layer opacity is 1.0
651+
// This catches views with `alpha < 1.0`, which are semi-transparent regardless of background color.
652+
// For example, a view with `alpha = 0.2` should never be considered opaque, even if it has
653+
// a solid background color, because the entire view (including the background) is semi-transparent.
654+
guard layer.opacity == 1 else {
655+
return false
656+
}
657+
658+
// Second check: Verify the view has an opaque background color
659+
// We check the view's properties first because this is the most common pattern in UIKit.
660+
let isViewOpaque = view.isOpaque && view.backgroundColor != nil && (view.backgroundColor?.cgColor.alpha ?? 0) == 1
661+
662+
// Third check: Verify the layer has an opaque background color
663+
// We also check the layer's properties because:
664+
// - Some views customize their CALayer directly without setting view.backgroundColor
665+
// - Libraries or custom views might override backgroundColor to return different values
666+
// - The layer's backgroundColor is the actual rendered property (view.backgroundColor is a convenience)
667+
let isLayerOpaque = layer.isOpaque && layer.backgroundColor != nil && (layer.backgroundColor?.alpha ?? 0) == 1
668+
669+
// We REQUIRE BOTH: the view AND the layer must be opaque for the view to be treated as opaque.
670+
// This stricter rule prevents semi‑transparent overlays or partially configured backgrounds
671+
// (only view or only layer) from clearing previously collected redact regions.
672+
return isViewOpaque && isLayerOpaque
624673
}
625674
}
626675

Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests+Common.swift

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli
143143

144144
let opaqueView = UIView(frame: CGRect(x: 10, y: 10, width: 60, height: 60))
145145
opaqueView.backgroundColor = .white
146+
opaqueView.isOpaque = true
147+
opaqueView.layer.isOpaque = true
148+
opaqueView.layer.backgroundColor = UIColor.white.cgColor
146149
rootView.addSubview(opaqueView)
147150

148151
// View Hierarchy:
@@ -837,6 +840,9 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli
837840

838841
let overView = UIView(frame: rootView.bounds)
839842
overView.backgroundColor = .black
843+
overView.isOpaque = true
844+
overView.layer.isOpaque = true
845+
overView.layer.backgroundColor = UIColor.black.cgColor
840846
rootView.addSubview(overView)
841847

842848
// View Hierarchy:
@@ -1098,7 +1104,7 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli
10981104

10991105
// View Hierarchy:
11001106
// ---------------
1101-
// == iOS 26 ==
1107+
// == iOS 26.1 - Xcode 26 ==
11021108
// <UIView: 0x11b209710; frame = (0 0; 100 100); layer = <CALayer: 0x600000cd1440>>
11031109
// | <UISlider: 0x11b23e2e0; frame = (10 10; 80 20); opaque = NO; gestureRecognizers = <NSArray: 0x600000276be0>; layer = <CALayer: 0x600000ce80c0>; value: 0.000000>
11041110
// | | <UIKit._UISliderGlassVisualElement: 0x11b25bdd0; frame = (0 0; 80 20); autoresize = W+H; layer = <CALayer: 0x600000cde0a0>>
@@ -1109,48 +1115,36 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli
11091115
// | | | | <UIView: 0x11b22bf60; frame = (0 0; 37 24); autoresize = W+H; userInteractionEnabled = NO; layer = <CALayer: 0x600000ce9410>>
11101116
// | | | | | <UIView: 0x11b434710; frame = (0 0; 37 24); autoresize = W+H; userInteractionEnabled = NO; backgroundColor = <UIDynamicSystemColor: 0x600001749000; name = _controlForegroundColor>; layer = <CALayer: 0x600000cdd740>>
11111117
//
1118+
// == iOS 26.1 - Xcode 16.4 ==
1119+
// <UIView: 0x10701dbf0; frame = (0 0; 100 100); layer = <CALayer: 0x600000cb96b0>>
1120+
// | <UISlider: 0x100e28d30; frame = (10 10; 80 20); opaque = NO; layer = <CALayer: 0x600000cae490>; value: 0.000000>
1121+
// | | <_UISlideriOSVisualElement: 0x100f06990; frame = (0 0; 80 20); opaque = NO; autoresize = W+H; layer = <CALayer: 0x600000c05fe0>>
1122+
//
11121123
// == iOS 18 & 17 & 16 ==
11131124
// <UIView: 0x12ed12bc0; frame = (0 0; 100 100); layer = <CALayer: 0x600001de3540>>
11141125
// | <UISlider: 0x13ed0f7e0; frame = (10 10; 80 20); opaque = NO; layer = <CALayer: 0x600001df0020>; value: 0.000000>
11151126
// | | <_UISlideriOSVisualElement: 0x13ed0fbd0; frame = (0 0; 80 20); opaque = NO; autoresize = W+H; layer = <CALayer: 0x600001da7f80>>
11161127

11171128
// -- Act --
1129+
print(rootView.value(forKey: "recursiveDescription")!)
11181130
let sut = getSut(maskAllText: true, maskAllImages: true)
11191131
let result = sut.redactRegionsFor(view: rootView)
11201132

11211133
// -- Assert --
1122-
// UISlider behavior differs by iOS version
1123-
if #available(iOS 26.0, *) {
1124-
// On iOS 26, UISlider uses a new visual implementation that creates clipping regions
1125-
// even though the slider itself is in the ignore list
1126-
let region0 = try XCTUnwrap(result.element(at: 0))
1127-
XCTAssertNil(region0.color)
1128-
XCTAssertEqual(region0.size, CGSize(width: 37, height: 24))
1129-
XCTAssertEqual(region0.type, .clipOut)
1130-
XCTAssertEqual(region0.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 8))
1131-
1132-
let region1 = try XCTUnwrap(result.element(at: 1))
1134+
if #available(iOS 26, *), isBuiltWithSDK26() {
1135+
// Only applies to Liquid Glass (enabled when built with Xcode 26+)
1136+
let region1 = try XCTUnwrap(result.element(at: 0))
11331137
XCTAssertNil(region1.color)
11341138
XCTAssertEqual(region1.size, CGSize(width: 80, height: 6))
11351139
XCTAssertEqual(region1.type, .clipBegin)
11361140
XCTAssertEqual(region1.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 17))
11371141

1138-
let region2 = try XCTUnwrap(result.element(at: 2))
1142+
let region2 = try XCTUnwrap(result.element(at: 1))
11391143
XCTAssertNil(region2.color)
1140-
XCTAssertEqual(region2.size, CGSize(width: 0, height: 6))
1141-
XCTAssertEqual(region2.type, .clipOut)
1144+
XCTAssertEqual(region2.size, CGSize(width: 80, height: 6))
1145+
XCTAssertEqual(region2.type, .clipEnd)
11421146
XCTAssertEqual(region2.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 17))
1143-
1144-
let region3 = try XCTUnwrap(result.element(at: 3))
1145-
XCTAssertNil(region3.color)
1146-
XCTAssertEqual(region3.size, CGSize(width: 80, height: 6))
1147-
XCTAssertEqual(region3.type, .clipEnd)
1148-
XCTAssertEqual(region3.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 17))
1149-
1150-
// Assert that there are no other regions
1151-
XCTAssertEqual(result.count, 4)
11521147
} else {
1153-
// On iOS < 26, UISlider is completely ignored (no regions)
11541148
XCTAssertEqual(result.count, 0)
11551149
}
11561150
}
@@ -1347,6 +1341,17 @@ private class TestGridView: UIView {
13471341
ctx.setFillColor(UIColor.orange.cgColor)
13481342
ctx.fill(CGRect(x: midX, y: midY, width: bounds.width - midX, height: bounds.height - midY))
13491343
}
1344+
1345+
}
1346+
1347+
private func isBuiltWithSDK26() -> Bool {
1348+
guard let value = Bundle.main.object(forInfoDictionaryKey: "DTXcode") as? String else {
1349+
return false
1350+
}
1351+
guard let xcodeVersion = Int(value) else {
1352+
return false
1353+
}
1354+
return xcodeVersion >= 2_600
13501355
}
13511356

13521357
#endif // os(iOS) && !targetEnvironment(macCatalyst)

0 commit comments

Comments
 (0)