Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

- Don't capture replays for events dropped in `beforeSend` (#5916)
- Fix linking with SentrySwiftUI on Xcode 26 for visionOS (#5823)
- Add masking for AVPlayerView (#5910)

### Improvements

Expand Down
75 changes: 50 additions & 25 deletions Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard

Large diffs are not rendered by default.

Binary file added Samples/iOS-Swift/iOS-Swift/Sample.mp4
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import AVKit
import UIKit

/// Video view controller for displaying video using the ``AVKit`` framework.
///
/// See the expo-video video view for reference:
/// https://github.com/expo/expo/blob/sdk-53/packages/expo-video/ios/VideoView.swift
class SentryVideoViewController: UIViewController {
lazy var playerViewController = AVPlayerViewController()

weak var player: AVPlayer? {
didSet {
playerViewController.player = player
}
}

override func viewDidLoad() {
super.viewDidLoad()

setupPlayerUI()
setupPlayer()
}

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

// Start playing the video when the view appears.
player?.play()
}

func setupPlayerUI() {
// Use a distinct color to clearly indicate when the video content not being displayed.
playerViewController.view.backgroundColor = .systemOrange

// Disable updates to the Now Playing Info Center, to increase isolation of app to global system state.
playerViewController.updatesNowPlayingInfoCenter = false

// Reference for the correct life cycle calls:
// https://developer.apple.com/documentation/uikit/creating-a-custom-container-view-controller#Add-a-child-view-controller-programmatically-to-your-content
addChild(playerViewController)
view.addSubview(playerViewController.view)

playerViewController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
playerViewController.view.topAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.topAnchor),
playerViewController.view.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),

playerViewController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
playerViewController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
])

playerViewController.didMove(toParent: self)
}

func setupPlayer() {
guard let videoUrl = Bundle.main.url(forResource: "Sample", withExtension: "mp4") else {
preconditionFailure("Sample video not found in main bundle")
}
let player = AVPlayer(url: videoUrl)
player.isMuted = true
self.player = player
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ final class SentryUIRedactBuilder {

///This is a list of UIView subclasses that will be ignored during redact process
private var ignoreClassesIdentifiers: Set<ObjectIdentifier>
///This is a list of UIView subclasses that need to be redacted from screenshot
private var redactClassesIdentifiers: Set<ObjectIdentifier>


/// 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<ObjectIdentifier>

/**
Initializes a new instance of the redaction process with the specified options.

Expand Down Expand Up @@ -66,7 +70,10 @@ final class SentryUIRedactBuilder {
// Used by:
// - https://developer.apple.com/documentation/SafariServices/SFSafariViewController
// - https://developer.apple.com/documentation/AuthenticationServices/ASWebAuthenticationSession
"SFSafariView"
"SFSafariView",
// Used by:
// - https://developer.apple.com/documentation/avkit/avplayerviewcontroller
"AVPlayerView"
].compactMap(NSClassFromString(_:))

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

func containsIgnoreClass(_ ignoreClass: AnyClass) -> Bool {
return ignoreClassesIdentifiers.contains(ObjectIdentifier(ignoreClass))
return ignoreClassesIdentifiers.contains(ObjectIdentifier(ignoreClass))
}

func containsRedactClass(_ redactClass: AnyClass) -> Bool {
Expand Down
144 changes: 137 additions & 7 deletions Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#if os(iOS)
import AVKit
import Foundation
import PDFKit
import SafariServices
Expand Down Expand Up @@ -461,16 +462,84 @@
XCTAssertEqual(result.count, 0)
}

func testRedactList() {
let expectedList = ["_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView",
func testDefaultRedactList_shouldContainAllPlatformSpecificClasses() {
// -- Arrange --
var expectedListClassNames = [

Check failure on line 467 in Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift

View workflow job for this annotation

GitHub Actions / Unit iOS 17 Sentry

variable 'expectedListClassNames' was never mutated; consider changing to 'let' constant

Check failure on line 467 in Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift

View workflow job for this annotation

GitHub Actions / Unit iOS 18 Sentry

variable 'expectedListClassNames' was never mutated; consider changing to 'let' constant

Check failure on line 467 in Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift

View workflow job for this annotation

GitHub Actions / Unit Catalyst 15 Sentry

variable 'expectedListClassNames' was never mutated; consider changing to 'let' constant

Check failure on line 467 in Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift

View workflow job for this annotation

GitHub Actions / Unit Catalyst 14 Sentry

variable 'expectedListClassNames' was never mutated; consider changing to 'let' constant

Check failure on line 467 in Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift

View workflow job for this annotation

GitHub Actions / Unit iOS 16 Sentry

variable 'expectedListClassNames' was never mutated; consider changing to 'let' constant
// SwiftUI Views
"_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView",
"_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView",
"SwiftUI._UIGraphicsView", "SwiftUI.ImageLayer", "UIWebView", "SFSafariView", "UILabel", "UITextView", "UITextField", "WKWebView", "PDFView"
].compactMap { NSClassFromString($0) }

"SwiftUI._UIGraphicsView", "SwiftUI.ImageLayer",
// Web Views
"UIWebView", "SFSafariView", "WKWebView",
// Text Views (incl. HybridSDK)
"UILabel", "UITextView", "UITextField", "RCTTextView", "RCTParagraphComponentView",
// Document Views
"PDFView",
// Image Views (incl. HybridSDK)
"UIImageView", "RCTImageView",
// Audio / Video Views
"AVPlayerView"
]

let expectedList = expectedListClassNames.map { className -> (String, ObjectIdentifier?) in
guard let classType = NSClassFromString(className) else {
print("Class \(className) not found, skipping test")
return (className, nil)
}
return (className, ObjectIdentifier(classType))
}

// -- Act --
let sut = getSut()
expectedList.forEach { element in
XCTAssertTrue(sut.containsRedactClass(element), "\(element) not found")

// -- Assert --
// Build sets of expected and actual identifiers for comparison
let expectedIdentifiers = Set(expectedList.compactMap { $0.1 })
let actualIdentifiers = Set(sut.redactClassesIdentifiers)

// Check for identifiers that are expected but missing in the actual result
let missingIdentifiers = expectedIdentifiers.subtracting(actualIdentifiers)
// Check for identifiers that are present in the actual result but not expected
let unexpectedIdentifiers = actualIdentifiers.subtracting(expectedIdentifiers)

// For each expected class, check that if we expect the class identifier to be nil, it is nil
for (expectedClassName, expectedNullableIdentifier) in expectedList {
if expectedNullableIdentifier == nil {
// If we expect nil, assert that no identifier in the actual list matches the class name
let found = sut.redactClassesIdentifiers.contains { $0.debugDescription.contains(expectedClassName) }
XCTAssertFalse(found, "Class \(expectedClassName) not found in runtime, but it is present in the redact list")
} else {
// If we expect a non-nil identifier, assert that it is present in the actual list
XCTAssertTrue(sut.redactClassesIdentifiers.contains(where: { $0 == expectedNullableIdentifier }), "Expected class \(expectedClassName) not found in redact list")
}
}

// Assert that there are no missing identifiers
XCTAssertTrue(missingIdentifiers.isEmpty, "Missing expected class identifiers: \(missingIdentifiers)")

// Assert that there are no unexpected identifiers
for identifier in unexpectedIdentifiers {
// Try to get the class name from the identifier
let classCount = objc_getClassList(nil, 0)
var className = "<unknown>"
if classCount > 0 {
let classes = UnsafeMutablePointer<AnyClass?>.allocate(capacity: Int(classCount))
defer { classes.deallocate() }
let autoreleasingClasses = AutoreleasingUnsafeMutablePointer<AnyClass>(classes)
let count = objc_getClassList(autoreleasingClasses, classCount)
for i in 0..<Int(count) {
if let cls = classes[i], ObjectIdentifier(cls) == identifier {
className = NSStringFromClass(cls)
break
}
}
}
XCTFail("Unexpected class identifier found: \(identifier) (\(className))")
}
XCTAssertTrue(unexpectedIdentifiers.isEmpty, "Unexpected class identifiers found: \(unexpectedIdentifiers)")

// Assert that the sets are equal (final check)
XCTAssertEqual(actualIdentifiers, expectedIdentifiers, "Mismatch between expected and actual class identifiers")
}

func testIgnoreList() {
Expand Down Expand Up @@ -638,6 +707,67 @@
// -- Act & Assert --
XCTAssertTrue(sut.containsRedactClass(PDFView.self), "PDFView should be in the redact class list")
}

func testRedactAVPlayerViewController() throws {
// -- Arrange --
let sut = getSut()
let avPlayerViewController = AVPlayerViewController()
let avPlayerView = try XCTUnwrap(avPlayerViewController.view)
avPlayerView.frame = CGRect(x: 20, y: 20, width: 40, height: 40)
rootView.addSubview(avPlayerView)

// -- Act --
let result = sut.redactRegionsFor(view: rootView)

// -- Assert --
// Root View
// └ AVPlayerViewController.view (Public API)
// └ AVPlayerView (Private API)
XCTAssertGreaterThanOrEqual(result.count, 1)
let avPlayerRegion = try XCTUnwrap(result.first)
XCTAssertEqual(avPlayerRegion.size, CGSize(width: 40, height: 40))
XCTAssertEqual(avPlayerRegion.type, .redact)
XCTAssertEqual(avPlayerRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20))
XCTAssertNil(avPlayerRegion.color)
}

func testRedactAVPlayerViewControllerEvenWithMaskingDisabled() throws {
// -- Arrange --
// AVPlayerViewController should always be redacted for security reasons,
// regardless of maskAllText and maskAllImages settings
let sut = getSut(TestRedactOptions(maskAllText: false, maskAllImages: false))
let avPlayerViewController = AVPlayerViewController()
let avPlayerView = try XCTUnwrap(avPlayerViewController.view)
avPlayerView.frame = CGRect(x: 20, y: 20, width: 40, height: 40)
rootView.addSubview(avPlayerView)

// -- Act --
let result = sut.redactRegionsFor(view: rootView)

// -- Assert --
// Root View
// └ AVPlayerViewController.view (Public API)
// └ AVPlayerView (Private API)
XCTAssertGreaterThanOrEqual(result.count, 1)
let avPlayerRegion = try XCTUnwrap(result.first)
XCTAssertEqual(avPlayerRegion.size, CGSize(width: 40, height: 40))
XCTAssertEqual(avPlayerRegion.type, .redact)
XCTAssertEqual(avPlayerRegion.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 20, ty: 20))
XCTAssertNil(avPlayerRegion.color)
}

func testAVPlayerViewInRedactList() throws {
// -- Arrange --
let sut = getSut()

// -- Act & Assert --
// Note: The redaction system uses "AVPlayerView" as the class name string
// which should resolve to the internal view hierarchy of AVPlayerViewController
guard let avPlayerViewClass = NSClassFromString("AVPlayerView") else {
throw XCTSkip("AVPlayerView class not found, skipping test")
}
XCTAssertTrue(sut.containsRedactClass(avPlayerViewClass), "AVPlayerView should be in the redact class list")
}
}

#endif
Loading