Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -11,6 +11,7 @@
- Don't capture replays for events dropped in `beforeSend` (#5916)
- Fix linking with SentrySwiftUI on Xcode 26 for visionOS (#5823)
- Structured Logging: Logger called before `SentrySDK.start` becomes unusable (#5984)
- Add masking for AVPlayerView (#5910)
- Fix missing view hierachy when enabling `attachScreenshot` too (#5989)

### 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 @@ class SentryUIRedactBuilderTests: XCTestCase {
XCTAssertEqual(result.count, 0)
}

func testRedactList() {
let expectedList = ["_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView",
func testDefaultRedactList_shouldContainAllPlatformSpecificClasses() {
// -- Arrange --
let expectedListClassNames = [
// 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 @@ class SentryUIRedactBuilderTests: XCTestCase {
// -- 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