Skip to content

Commit 6ce17f6

Browse files
itaybrephilprime
authored andcommitted
fix(session-replay): Include layer background color when checking if a view is opaque (#6629)
1 parent bb667b9 commit 6ce17f6

File tree

8 files changed

+730
-34
lines changed

8 files changed

+730
-34
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
- Improve transform calculations for views with custom anchor points
2222
- Fix axis-aligned transform detection for optimized opaque view clipping
2323
- Fix conversion of frame rate to time interval for session replay (#6623)
24+
- Change Session Replay masking to prevent semi‑transparent full‑screen overlays from clearing redactions by making opaque clipping stricter (#6629)
25+
Views now need to be fully opaque (view and layer backgrounds with alpha == 1) and report opaque to qualify for clip‑out.
26+
This avoids leaks at the cost of fewer clip‑out optimizations.
2427

2528
### Improvements
2629

Sentry.xcodeproj/project.pbxproj

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -851,7 +851,6 @@
851851
D4AF00212D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m in Sources */ = {isa = PBXBuildFile; fileRef = D4AF00202D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m */; };
852852
D4AF00232D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h in Headers */ = {isa = PBXBuildFile; fileRef = D4AF00222D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h */; };
853853
D4AF00252D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4AF00242D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m */; };
854-
D4AF7D262E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D252E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift */; };
855854
D4AF7D2A2E940493004F0F59 /* SentryUIRedactBuilderTests+Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D292E940492004F0F59 /* SentryUIRedactBuilderTests+Common.swift */; };
856855
D4AF7D2C2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AF7D2B2E9404ED004F0F59 /* SentryUIRedactBuilderTests+EdgeCases.swift */; };
857856
D4B0DC7F2DA9257A00DE61B6 /* SentryRenderVideoResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B0DC7E2DA9257200DE61B6 /* SentryRenderVideoResult.swift */; };
@@ -6171,7 +6170,6 @@
61716170
D45E2D772E003EBF0072A6B7 /* TestRedactOptions.swift in Sources */,
61726171
63FE720520DA66EC00CDBAE8 /* FileBasedTestCase.m in Sources */,
61736172
51B15F802BE88D510026A2F2 /* URLSessionTaskHelperTests.swift in Sources */,
6174-
D4AF7D262E9401EB004F0F59 /* SentryUIRedactBuilderTests+UIKit.swift in Sources */,
61756173
63EED6C32237989300E02400 /* SentryOptionsTest.m in Sources */,
61766174
7BBD18B22451804C00427C76 /* SentryRetryAfterHeaderParserTests.swift in Sources */,
61776175
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: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli
152152

153153
let opaqueView = UIView(frame: CGRect(x: 10, y: 10, width: 60, height: 60))
154154
opaqueView.backgroundColor = .white
155+
opaqueView.isOpaque = true
156+
opaqueView.layer.isOpaque = true
157+
opaqueView.layer.backgroundColor = UIColor.white.cgColor
155158
rootView.addSubview(opaqueView)
156159

157160
// View Hierarchy:
@@ -922,6 +925,9 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli
922925

923926
let overView = UIView(frame: rootView.bounds)
924927
overView.backgroundColor = .black
928+
overView.isOpaque = true
929+
overView.layer.isOpaque = true
930+
overView.layer.backgroundColor = UIColor.black.cgColor
925931
rootView.addSubview(overView)
926932

927933
// View Hierarchy:
@@ -1208,7 +1214,7 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli
12081214

12091215
// View Hierarchy:
12101216
// ---------------
1211-
// == iOS 26 ==
1217+
// == iOS 26.1 - Xcode 26 ==
12121218
// <UIView: 0x11b209710; frame = (0 0; 100 100); layer = <CALayer: 0x600000cd1440>>
12131219
// | <UISlider: 0x11b23e2e0; frame = (10 10; 80 20); opaque = NO; gestureRecognizers = <NSArray: 0x600000276be0>; layer = <CALayer: 0x600000ce80c0>; value: 0.000000>
12141220
// | | <UIKit._UISliderGlassVisualElement: 0x11b25bdd0; frame = (0 0; 80 20); autoresize = W+H; layer = <CALayer: 0x600000cde0a0>>
@@ -1219,6 +1225,11 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli
12191225
// | | | | <UIView: 0x11b22bf60; frame = (0 0; 37 24); autoresize = W+H; userInteractionEnabled = NO; layer = <CALayer: 0x600000ce9410>>
12201226
// | | | | | <UIView: 0x11b434710; frame = (0 0; 37 24); autoresize = W+H; userInteractionEnabled = NO; backgroundColor = <UIDynamicSystemColor: 0x600001749000; name = _controlForegroundColor>; layer = <CALayer: 0x600000cdd740>>
12211227
//
1228+
// == iOS 26.1 - Xcode 16.4 ==
1229+
// <UIView: 0x10701dbf0; frame = (0 0; 100 100); layer = <CALayer: 0x600000cb96b0>>
1230+
// | <UISlider: 0x100e28d30; frame = (10 10; 80 20); opaque = NO; layer = <CALayer: 0x600000cae490>; value: 0.000000>
1231+
// | | <_UISlideriOSVisualElement: 0x100f06990; frame = (0 0; 80 20); opaque = NO; autoresize = W+H; layer = <CALayer: 0x600000c05fe0>>
1232+
//
12221233
// == iOS 18 & 17 & 16 ==
12231234
// <UIView: 0x12ed12bc0; frame = (0 0; 100 100); layer = <CALayer: 0x600001de3540>>
12241235
// | <UISlider: 0x13ed0f7e0; frame = (10 10; 80 20); opaque = NO; layer = <CALayer: 0x600001df0020>; value: 0.000000>
@@ -1230,40 +1241,20 @@ class SentryUIRedactBuilderTests_Common: SentryUIRedactBuilderTests { // swiftli
12301241
let masked = createMaskedScreenshot(view: rootView, regions: result)
12311242

12321243
// -- Assert --
1233-
assertSnapshot(of: masked, as: .image, named: createTestDeviceOSBoundSnapshotName(name: "unmasked"))
1234-
1235-
// UISlider behavior differs by iOS version
1236-
if #available(iOS 26.0, *) {
1237-
// On iOS 26, UISlider uses a new visual implementation that creates clipping regions
1238-
// even though the slider itself is in the ignore list
1239-
let region0 = try XCTUnwrap(result.element(at: 0))
1240-
XCTAssertNil(region0.color)
1241-
XCTAssertEqual(region0.size, CGSize(width: 37, height: 24))
1242-
XCTAssertEqual(region0.type, .clipOut)
1243-
XCTAssertEqual(region0.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 8))
1244-
1245-
let region1 = try XCTUnwrap(result.element(at: 1))
1244+
if #available(iOS 26, *), isBuiltWithSDK26() {
1245+
// Only applies to Liquid Glass (enabled when built with Xcode 26+)
1246+
let region1 = try XCTUnwrap(result.element(at: 0))
12461247
XCTAssertNil(region1.color)
12471248
XCTAssertEqual(region1.size, CGSize(width: 80, height: 6))
12481249
XCTAssertEqual(region1.type, .clipBegin)
12491250
XCTAssertEqual(region1.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 17))
12501251

1251-
let region2 = try XCTUnwrap(result.element(at: 2))
1252+
let region2 = try XCTUnwrap(result.element(at: 1))
12521253
XCTAssertNil(region2.color)
1253-
XCTAssertEqual(region2.size, CGSize(width: 0, height: 6))
1254-
XCTAssertEqual(region2.type, .clipOut)
1254+
XCTAssertEqual(region2.size, CGSize(width: 80, height: 6))
1255+
XCTAssertEqual(region2.type, .clipEnd)
12551256
XCTAssertEqual(region2.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 17))
1256-
1257-
let region3 = try XCTUnwrap(result.element(at: 3))
1258-
XCTAssertNil(region3.color)
1259-
XCTAssertEqual(region3.size, CGSize(width: 80, height: 6))
1260-
XCTAssertEqual(region3.type, .clipEnd)
1261-
XCTAssertEqual(region3.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 17))
1262-
1263-
// Assert that there are no other regions
1264-
XCTAssertEqual(result.count, 4)
12651257
} else {
1266-
// On iOS < 26, UISlider is completely ignored (no regions)
12671258
XCTAssertEqual(result.count, 0)
12681259
}
12691260
}
@@ -1469,6 +1460,17 @@ private class TestGridView: UIView {
14691460
ctx.setFillColor(UIColor.orange.cgColor)
14701461
ctx.fill(CGRect(x: midX, y: midY, width: bounds.width - midX, height: bounds.height - midY))
14711462
}
1463+
1464+
}
1465+
1466+
private func isBuiltWithSDK26() -> Bool {
1467+
guard let value = Bundle.main.object(forInfoDictionaryKey: "DTXcode") as? String else {
1468+
return false
1469+
}
1470+
guard let xcodeVersion = Int(value) else {
1471+
return false
1472+
}
1473+
return xcodeVersion >= 2_600
14721474
}
14731475

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

0 commit comments

Comments
 (0)