Skip to content

Commit d66f082

Browse files
authored
fix(session-replay): Add masking for AVPlayerView (#5910)
1 parent 2e79f5f commit d66f082

File tree

6 files changed

+263
-37
lines changed

6 files changed

+263
-37
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- Don't capture replays for events dropped in `beforeSend` (#5916)
1212
- Fix linking with SentrySwiftUI on Xcode 26 for visionOS (#5823)
1313
- Structured Logging: Logger called before `SentrySDK.start` becomes unusable (#5984)
14+
- Add masking for AVPlayerView (#5910)
1415
- Fix missing view hierachy when enabling `attachScreenshot` too (#5989)
1516

1617
### Improvements

Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard

Lines changed: 50 additions & 25 deletions
Large diffs are not rendered by default.
1.76 MB
Binary file not shown.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import AVKit
2+
import UIKit
3+
4+
/// Video view controller for displaying video using the ``AVKit`` framework.
5+
///
6+
/// See the expo-video video view for reference:
7+
/// https://github.com/expo/expo/blob/sdk-53/packages/expo-video/ios/VideoView.swift
8+
class SentryVideoViewController: UIViewController {
9+
lazy var playerViewController = AVPlayerViewController()
10+
11+
weak var player: AVPlayer? {
12+
didSet {
13+
playerViewController.player = player
14+
}
15+
}
16+
17+
override func viewDidLoad() {
18+
super.viewDidLoad()
19+
20+
setupPlayerUI()
21+
setupPlayer()
22+
}
23+
24+
override func viewWillAppear(_ animated: Bool) {
25+
super.viewWillAppear(animated)
26+
27+
// Start playing the video when the view appears.
28+
player?.play()
29+
}
30+
31+
func setupPlayerUI() {
32+
// Use a distinct color to clearly indicate when the video content not being displayed.
33+
playerViewController.view.backgroundColor = .systemOrange
34+
35+
// Disable updates to the Now Playing Info Center, to increase isolation of app to global system state.
36+
playerViewController.updatesNowPlayingInfoCenter = false
37+
38+
// Reference for the correct life cycle calls:
39+
// https://developer.apple.com/documentation/uikit/creating-a-custom-container-view-controller#Add-a-child-view-controller-programmatically-to-your-content
40+
addChild(playerViewController)
41+
view.addSubview(playerViewController.view)
42+
43+
playerViewController.view.translatesAutoresizingMaskIntoConstraints = false
44+
NSLayoutConstraint.activate([
45+
playerViewController.view.topAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.topAnchor),
46+
playerViewController.view.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
47+
48+
playerViewController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
49+
playerViewController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
50+
])
51+
52+
playerViewController.didMove(toParent: self)
53+
}
54+
55+
func setupPlayer() {
56+
guard let videoUrl = Bundle.main.url(forResource: "Sample", withExtension: "mp4") else {
57+
preconditionFailure("Sample video not found in main bundle")
58+
}
59+
let player = AVPlayer(url: videoUrl)
60+
player.isMuted = true
61+
self.player = player
62+
}
63+
}

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@ final class SentryUIRedactBuilder {
1616

1717
///This is a list of UIView subclasses that will be ignored during redact process
1818
private var ignoreClassesIdentifiers: Set<ObjectIdentifier>
19-
///This is a list of UIView subclasses that need to be redacted from screenshot
20-
private var redactClassesIdentifiers: Set<ObjectIdentifier>
21-
19+
20+
/// This is a list of UIView subclasses that need to be redacted from screenshot
21+
///
22+
/// This set is configured as `private(set)` to allow modification only from within this class,
23+
/// while still allowing read access from tests.
24+
private(set) var redactClassesIdentifiers: Set<ObjectIdentifier>
25+
2226
/**
2327
Initializes a new instance of the redaction process with the specified options.
2428

@@ -66,7 +70,10 @@ final class SentryUIRedactBuilder {
6670
// Used by:
6771
// - https://developer.apple.com/documentation/SafariServices/SFSafariViewController
6872
// - https://developer.apple.com/documentation/AuthenticationServices/ASWebAuthenticationSession
69-
"SFSafariView"
73+
"SFSafariView",
74+
// Used by:
75+
// - https://developer.apple.com/documentation/avkit/avplayerviewcontroller
76+
"AVPlayerView"
7077
].compactMap(NSClassFromString(_:))
7178

7279
ignoreClassesIdentifiers = [ ObjectIdentifier(UISlider.self), ObjectIdentifier(UISwitch.self) ]
@@ -86,7 +93,7 @@ final class SentryUIRedactBuilder {
8693
}
8794

8895
func containsIgnoreClass(_ ignoreClass: AnyClass) -> Bool {
89-
return ignoreClassesIdentifiers.contains(ObjectIdentifier(ignoreClass))
96+
return ignoreClassesIdentifiers.contains(ObjectIdentifier(ignoreClass))
9097
}
9198

9299
func containsRedactClass(_ redactClass: AnyClass) -> Bool {

Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift

Lines changed: 137 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#if os(iOS)
2+
import AVKit
23
import Foundation
34
import PDFKit
45
import SafariServices
@@ -461,16 +462,84 @@ class SentryUIRedactBuilderTests: XCTestCase {
461462
XCTAssertEqual(result.count, 0)
462463
}
463464

464-
func testRedactList() {
465-
let expectedList = ["_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView",
465+
func testDefaultRedactList_shouldContainAllPlatformSpecificClasses() {
466+
// -- Arrange --
467+
let expectedListClassNames = [
468+
// SwiftUI Views
469+
"_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView",
466470
"_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView",
467-
"SwiftUI._UIGraphicsView", "SwiftUI.ImageLayer", "UIWebView", "SFSafariView", "UILabel", "UITextView", "UITextField", "WKWebView", "PDFView"
468-
].compactMap { NSClassFromString($0) }
469-
471+
"SwiftUI._UIGraphicsView", "SwiftUI.ImageLayer",
472+
// Web Views
473+
"UIWebView", "SFSafariView", "WKWebView",
474+
// Text Views (incl. HybridSDK)
475+
"UILabel", "UITextView", "UITextField", "RCTTextView", "RCTParagraphComponentView",
476+
// Document Views
477+
"PDFView",
478+
// Image Views (incl. HybridSDK)
479+
"UIImageView", "RCTImageView",
480+
// Audio / Video Views
481+
"AVPlayerView"
482+
]
483+
484+
let expectedList = expectedListClassNames.map { className -> (String, ObjectIdentifier?) in
485+
guard let classType = NSClassFromString(className) else {
486+
print("Class \(className) not found, skipping test")
487+
return (className, nil)
488+
}
489+
return (className, ObjectIdentifier(classType))
490+
}
491+
492+
// -- Act --
470493
let sut = getSut()
471-
expectedList.forEach { element in
472-
XCTAssertTrue(sut.containsRedactClass(element), "\(element) not found")
494+
495+
// -- Assert --
496+
// Build sets of expected and actual identifiers for comparison
497+
let expectedIdentifiers = Set(expectedList.compactMap { $0.1 })
498+
let actualIdentifiers = Set(sut.redactClassesIdentifiers)
499+
500+
// Check for identifiers that are expected but missing in the actual result
501+
let missingIdentifiers = expectedIdentifiers.subtracting(actualIdentifiers)
502+
// Check for identifiers that are present in the actual result but not expected
503+
let unexpectedIdentifiers = actualIdentifiers.subtracting(expectedIdentifiers)
504+
505+
// For each expected class, check that if we expect the class identifier to be nil, it is nil
506+
for (expectedClassName, expectedNullableIdentifier) in expectedList {
507+
if expectedNullableIdentifier == nil {
508+
// If we expect nil, assert that no identifier in the actual list matches the class name
509+
let found = sut.redactClassesIdentifiers.contains { $0.debugDescription.contains(expectedClassName) }
510+
XCTAssertFalse(found, "Class \(expectedClassName) not found in runtime, but it is present in the redact list")
511+
} else {
512+
// If we expect a non-nil identifier, assert that it is present in the actual list
513+
XCTAssertTrue(sut.redactClassesIdentifiers.contains(where: { $0 == expectedNullableIdentifier }), "Expected class \(expectedClassName) not found in redact list")
514+
}
473515
}
516+
517+
// Assert that there are no missing identifiers
518+
XCTAssertTrue(missingIdentifiers.isEmpty, "Missing expected class identifiers: \(missingIdentifiers)")
519+
520+
// Assert that there are no unexpected identifiers
521+
for identifier in unexpectedIdentifiers {
522+
// Try to get the class name from the identifier
523+
let classCount = objc_getClassList(nil, 0)
524+
var className = "<unknown>"
525+
if classCount > 0 {
526+
let classes = UnsafeMutablePointer<AnyClass?>.allocate(capacity: Int(classCount))
527+
defer { classes.deallocate() }
528+
let autoreleasingClasses = AutoreleasingUnsafeMutablePointer<AnyClass>(classes)
529+
let count = objc_getClassList(autoreleasingClasses, classCount)
530+
for i in 0..<Int(count) {
531+
if let cls = classes[i], ObjectIdentifier(cls) == identifier {
532+
className = NSStringFromClass(cls)
533+
break
534+
}
535+
}
536+
}
537+
XCTFail("Unexpected class identifier found: \(identifier) (\(className))")
538+
}
539+
XCTAssertTrue(unexpectedIdentifiers.isEmpty, "Unexpected class identifiers found: \(unexpectedIdentifiers)")
540+
541+
// Assert that the sets are equal (final check)
542+
XCTAssertEqual(actualIdentifiers, expectedIdentifiers, "Mismatch between expected and actual class identifiers")
474543
}
475544

476545
func testIgnoreList() {
@@ -638,6 +707,67 @@ class SentryUIRedactBuilderTests: XCTestCase {
638707
// -- Act & Assert --
639708
XCTAssertTrue(sut.containsRedactClass(PDFView.self), "PDFView should be in the redact class list")
640709
}
710+
711+
func testRedactAVPlayerViewController() throws {
712+
// -- Arrange --
713+
let sut = getSut()
714+
let avPlayerViewController = AVPlayerViewController()
715+
let avPlayerView = try XCTUnwrap(avPlayerViewController.view)
716+
avPlayerView.frame = CGRect(x: 20, y: 20, width: 40, height: 40)
717+
rootView.addSubview(avPlayerView)
718+
719+
// -- Act --
720+
let result = sut.redactRegionsFor(view: rootView)
721+
722+
// -- Assert --
723+
// Root View
724+
// └ AVPlayerViewController.view (Public API)
725+
// └ AVPlayerView (Private API)
726+
XCTAssertGreaterThanOrEqual(result.count, 1)
727+
let avPlayerRegion = try XCTUnwrap(result.first)
728+
XCTAssertEqual(avPlayerRegion.size, CGSize(width: 40, height: 40))
729+
XCTAssertEqual(avPlayerRegion.type, .redact)
730+
XCTAssertEqual(avPlayerRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20))
731+
XCTAssertNil(avPlayerRegion.color)
732+
}
733+
734+
func testRedactAVPlayerViewControllerEvenWithMaskingDisabled() throws {
735+
// -- Arrange --
736+
// AVPlayerViewController should always be redacted for security reasons,
737+
// regardless of maskAllText and maskAllImages settings
738+
let sut = getSut(TestRedactOptions(maskAllText: false, maskAllImages: false))
739+
let avPlayerViewController = AVPlayerViewController()
740+
let avPlayerView = try XCTUnwrap(avPlayerViewController.view)
741+
avPlayerView.frame = CGRect(x: 20, y: 20, width: 40, height: 40)
742+
rootView.addSubview(avPlayerView)
743+
744+
// -- Act --
745+
let result = sut.redactRegionsFor(view: rootView)
746+
747+
// -- Assert --
748+
// Root View
749+
// └ AVPlayerViewController.view (Public API)
750+
// └ AVPlayerView (Private API)
751+
XCTAssertGreaterThanOrEqual(result.count, 1)
752+
let avPlayerRegion = try XCTUnwrap(result.first)
753+
XCTAssertEqual(avPlayerRegion.size, CGSize(width: 40, height: 40))
754+
XCTAssertEqual(avPlayerRegion.type, .redact)
755+
XCTAssertEqual(avPlayerRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20))
756+
XCTAssertNil(avPlayerRegion.color)
757+
}
758+
759+
func testAVPlayerViewInRedactList() throws {
760+
// -- Arrange --
761+
let sut = getSut()
762+
763+
// -- Act & Assert --
764+
// Note: The redaction system uses "AVPlayerView" as the class name string
765+
// which should resolve to the internal view hierarchy of AVPlayerViewController
766+
guard let avPlayerViewClass = NSClassFromString("AVPlayerView") else {
767+
throw XCTSkip("AVPlayerView class not found, skipping test")
768+
}
769+
XCTAssertTrue(sut.containsRedactClass(avPlayerViewClass), "AVPlayerView should be in the redact class list")
770+
}
641771
}
642772

643773
#endif

0 commit comments

Comments
 (0)