Skip to content
Draft
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
8 changes: 8 additions & 0 deletions Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ class ErrorsViewController: UIViewController {
if SentrySDKOverrides.Feedback.injectScreenshot.boolValue {
NotificationCenter.default.post(name: UIApplication.userDidTakeScreenshotNotification, object: nil)
}

DispatchQueue.main.async {
let imagePicker = UIImagePickerController()
imagePicker.sourceType = .camera
imagePicker.allowsEditing = false
imagePicker.cameraCaptureMode = .photo
self.present(imagePicker, animated: true, completion: nil)
}
}

@IBAction func useAfterFree(_ sender: UIButton) {
Expand Down
2 changes: 2 additions & 0 deletions Samples/iOS-Swift/iOS-Swift/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Testing camera permissions</string>
<key>NSFaceIDUsageDescription</key>
<string>$(PRODUCT_NAME) Authentication with TouchId or FaceID for testing purposes of the Sentry Cocoa SDK.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@ import WebKit
#endif

final class SentryUIRedactBuilder {
// MARK: - Constants

/// Class identifier for ``CameraUI.ChromeSwiftUIView``, if it exists.
///
/// This object identifier is used to identify views of this class type during the redaction process.
/// This workaround is specifically for Xcode 16 building for iOS 26 where accessing CameraUI.ModeLoupeLayer
/// causes a crash due to unimplemented init(layer:) initializer.
private static let cameraSwiftUIViewClassId = "CameraUI.ChromeSwiftUIView"

///This is a wrapper which marks it's direct children to be ignored
private var ignoreContainerClassIdentifier: ObjectIdentifier?

///This is a wrapper which marks it's direct children to be redacted
private var redactContainerClassIdentifier: ObjectIdentifier?

Expand Down Expand Up @@ -186,7 +196,7 @@ final class SentryUIRedactBuilder {
}

private func shouldIgnore(view: UIView) -> Bool {
return SentryRedactViewHelper.shouldUnmask(view) || containsIgnoreClass(type(of: view)) || shouldIgnoreParentContainer(view)
return SentryRedactViewHelper.shouldUnmask(view) || containsIgnoreClass(type(of: view)) || shouldIgnoreParentContainer(view)
}

private func shouldIgnoreParentContainer(_ view: UIView) -> Bool {
Expand Down Expand Up @@ -239,7 +249,7 @@ final class SentryUIRedactBuilder {
transform: newTransform,
type: swiftUI ? .redactSwiftUI : .redact,
color: self.color(for: view),
name: layer.name ?? layer.debugDescription
name: view.debugDescription
))

guard !view.clipsToBounds else {
Expand All @@ -256,11 +266,25 @@ final class SentryUIRedactBuilder {
size: layer.bounds.size,
transform: newTransform,
type: .clipOut,
name: layer.name ?? layer.debugDescription
name: view.debugDescription
))
}
}


// Check if the subtree should be ignored to avoid crashes with some special views.
// If a subtree is ignored, it will be fully redacted.
if isViewSubtreeIgnored(view) {
redacting.append(SentryRedactRegion(
size: layer.bounds.size,
transform: newTransform,
type: .redact,
color: self.color(for: view),
name: view.debugDescription
))
return
}

// Traverse the sublayers to redact them if necessary
guard let subLayers = layer.sublayers, subLayers.count > 0 else {
return
}
Expand All @@ -272,7 +296,7 @@ final class SentryUIRedactBuilder {
size: layer.bounds.size,
transform: newTransform,
type: .clipEnd,
name: layer.name ?? layer.debugDescription
name: view.debugDescription
))
}
for subLayer in subLayers.sorted(by: { $0.zPosition < $1.zPosition }) {
Expand All @@ -283,11 +307,27 @@ final class SentryUIRedactBuilder {
size: layer.bounds.size,
transform: newTransform,
type: .clipBegin,
name: layer.name ?? layer.debugDescription
name: view.debugDescription
))
}
}

private func isViewSubtreeIgnored(_ view: UIView) -> Bool {
// We are using the string description of the type instead of converting it to ObjectIdentifier, because
// the conversion would require an to use `NSClassFromString` which can lead to crashes in some cases, as it
// calls the `+initialize` methods of the class.
let viewTypeId = type(of: view).description()
if #available(iOS 26.0, *), viewTypeId == Self.cameraSwiftUIViewClassId {
// CameraUI.ChromeSwiftUIView is a special case because it contains layers which can not be iterated due to this error:
//
// Fatal error: Use of unimplemented initializer 'init(layer:)' for class 'CameraUI.ModeLoupeLayer'
//
// This crash only occurs when building with Xcode 16 for iOS 26, so we add a runtime check
return true
}
return false
}

/**
Gets a transform that represents the layer global position.
*/
Expand Down
144 changes: 144 additions & 0 deletions Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ class RCTParagraphComponentView: UIView {
class RCTImageView: UIView {
}

/*
* Test class to simulate camera view behavior for testing the subtree skipping functionality.
*/
class TestCameraView: UIView {
}

class SentryUIRedactBuilderTests: XCTestCase {
private class CustomVisibilityView: UIView {
class CustomLayer: CALayer {
Expand Down Expand Up @@ -768,6 +774,144 @@ class SentryUIRedactBuilderTests: XCTestCase {
}
XCTAssertTrue(sut.containsRedactClass(avPlayerViewClass), "AVPlayerView should be in the redact class list")
}

func testViewSubtreeIgnoredFunctionExists() {
// -- Arrange --
let sut = getSut()

// -- Act & Assert --
// This test verifies that the isViewSubtreeIgnored functionality exists
// We test with a regular view that should NOT be ignored
let regularView = TestCameraView(frame: CGRect(x: 10, y: 10, width: 60, height: 60))
let labelInside = UILabel(frame: CGRect(x: 5, y: 5, width: 20, height: 20))
regularView.addSubview(labelInside)
rootView.addSubview(regularView)

let result = sut.redactRegionsFor(view: rootView)
// Regular views should still be processed normally - the label should be redacted
XCTAssertEqual(result.count, 1, "Regular view subtrees should be processed normally")
}

func testCameraViewSpecialCaseHandling() {
// -- Arrange --
let sut = getSut()

// -- Act & Assert --
// This test verifies that the camera view handling doesn't break existing functionality
// We test that normal redaction still works for other views
let normalLabel = UILabel(frame: CGRect(x: 10, y: 10, width: 40, height: 40))
let normalImageView = UIImageView(frame: CGRect(x: 60, y: 60, width: 30, height: 30))

// Create a test image for the image view
let testImage = UIGraphicsImageRenderer(size: CGSize(width: 30, height: 30)).image { context in
context.fill(CGRect(x: 0, y: 0, width: 30, height: 30))
}
normalImageView.image = testImage

rootView.addSubview(normalLabel)
rootView.addSubview(normalImageView)

let result = sut.redactRegionsFor(view: rootView)

// Both the label and image should be redacted
XCTAssertEqual(result.count, 2, "Normal views should still be redacted")

// Verify that both redact regions are present
let labelRegion = result.first { $0.transform.tx == 10 && $0.transform.ty == 10 }
let imageRegion = result.first { $0.transform.tx == 60 && $0.transform.ty == 60 }

XCTAssertNotNil(labelRegion, "Label should be redacted")
XCTAssertNotNil(imageRegion, "Image view should be redacted")
}

func testViewSubtreeProcessingWithNestedViews() {
// -- Arrange --
let sut = getSut()
let containerView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
let testView = TestCameraView(frame: CGRect(x: 10, y: 10, width: 60, height: 60))
let labelInsideTest = UILabel(frame: CGRect(x: 5, y: 5, width: 20, height: 20))
let labelOutsideTest = UILabel(frame: CGRect(x: 80, y: 80, width: 15, height: 15))

testView.addSubview(labelInsideTest)
containerView.addSubview(testView)
containerView.addSubview(labelOutsideTest)
rootView.addSubview(containerView)

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

// -- Assert --
// Both labels should be redacted since TestCameraView is not the special camera class
XCTAssertEqual(result.count, 2, "All labels should be processed normally")

// Verify both regions exist
let regions = result.sorted { $0.size.width < $1.size.width }
XCTAssertEqual(regions[0].size, CGSize(width: 15, height: 15), "Outside label should be redacted")
XCTAssertEqual(regions[1].size, CGSize(width: 20, height: 20), "Inside label should be redacted")
}

func testCameraClassDetectionWhenClassDoesNotExist() {
// -- Arrange --
let sut = getSut()

// -- Act & Assert --
// This test verifies that the system handles gracefully when CameraUI.ChromeSwiftUIView doesn't exist
// or when the workaround is not active (e.g., on older iOS versions or different compiler versions)
// In our test environment, this class likely doesn't exist or the workaround is not active,
// so normal processing should continue
let testLabel = UILabel(frame: CGRect(x: 10, y: 10, width: 40, height: 40))
rootView.addSubview(testLabel)

let result = sut.redactRegionsFor(view: rootView)

// Label should be redacted normally since the camera class doesn't exist or workaround is inactive
XCTAssertEqual(result.count, 1, "Normal redaction should work when camera class doesn't exist or workaround is inactive")
XCTAssertEqual(result.first?.size, CGSize(width: 40, height: 40))
}

func testViewSubtreeIgnoreLogicDoesNotAffectOtherIgnoreClasses() {
// -- Arrange --
let sut = getSut()
let testView = TestCameraView(frame: CGRect(x: 10, y: 10, width: 30, height: 30))
let sliderView = UISlider(frame: CGRect(x: 50, y: 50, width: 40, height: 20))
let labelView = UILabel(frame: CGRect(x: 80, y: 80, width: 15, height: 15))

rootView.addSubview(testView)
rootView.addSubview(sliderView)
rootView.addSubview(labelView)

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

// -- Assert --
// Slider should be ignored (default ignore class)
// TestCameraView is not the special camera class, so it should be processed normally
// Label should be redacted
XCTAssertEqual(result.count, 1, "Only label should be redacted, slider should be ignored by default ignore logic")
XCTAssertEqual(result.first?.size, CGSize(width: 15, height: 15))
}

func testCameraWorkaroundOnlyActiveForSpecificConfiguration() {
// -- Arrange --
let sut = getSut()

// -- Act & Assert --
// This test documents that the camera view workaround is only active when:
// 1. Built with Swift 6.0+ compiler (Xcode 16+) - compiler(>=6.0)
// 2. Running on iOS 26+ - #available(iOS 26.0, *)
// 3. CameraUI.ChromeSwiftUIView class exists
//
// In this test environment, the class likely doesn't exist or we're not on iOS 26,
// so normal processing should occur
let testLabel = UILabel(frame: CGRect(x: 10, y: 10, width: 40, height: 40))
rootView.addSubview(testLabel)

let result = sut.redactRegionsFor(view: rootView)

// Label should be redacted normally when workaround conditions are not met
XCTAssertEqual(result.count, 1, "Normal redaction should work when workaround conditions are not met")
XCTAssertEqual(result.first?.size, CGSize(width: 40, height: 40))
}
}

#endif
Loading