Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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 @@ -17,6 +17,7 @@
- Fix macOS's frameworks not following the versioned framework structure (#6049)
- Add warning to addBreadcrumb when used before SDK init (#6083)
- Add null-handling for parsed DSN in SentryHTTPTransport (#5800)
- Add exemption for CameraUI traversal for iOS 26.0 (#6045)

### Improvements

Expand Down
32 changes: 32 additions & 0 deletions Samples/iOS-Swift/iOS-Swift-UITests/SessionReplayUITests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import XCTest

Check failure on line 1 in Samples/iOS-Swift/iOS-Swift-UITests/SessionReplayUITests.swift

View workflow job for this annotation

GitHub Actions / JUnit Test Report

SessionReplayUITests.testCameraUI_shouldNotCrashOnIOS26

/Users/runner/work/sentry-cocoa/sentry-cocoa/Samples/iOS-Swift/iOS-Swift-UITests/SessionReplayUITests.swift:18 - Failed to get matching snapshots: Timed out while evaluating UI query.

class SessionReplayUITests: BaseUITest {

func testCameraUI_shouldNotCrashOnIOS26() {
// -- Arrange --
// During the beta phase of iOS 26.0 we noticed crashes when traversing the view hierarchy
// of the camera UI. This test is used to verify that no regression occurs.
// See https://github.com/getsentry/sentry-cocoa/issues/5647
app.buttons["Extra"].tap()
app.buttons["Show Camera UI"].tap()

// We need to verify the camera UI is shown by checking for the existence of a UI element.
// This can be any element that is part of the camera UI and can be found reliably.
// The "PhotoCapture" button is a good candidate as it is always present when the
// camera UI is shown.
let cameraUIElement = app.buttons["PhotoCapture"]
XCTAssertTrue(cameraUIElement.waitForExistence(timeout: 5))

// After the Camera UI is shown, we keep it open for 10 seconds to trigger at least one full
// video segment captured (segments are 5 seconds long).
wait(10)
}

private func wait(_ seconds: TimeInterval) {
let exp = expectation(description: "Waiting for \(seconds) seconds")
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
exp.fulfill()
}
wait(for: [exp], timeout: seconds + 1)
}
}
54 changes: 32 additions & 22 deletions Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,14 @@ class ExtraViewController: UIViewController {
showToast(in: self, type: .warning, message: "Feedback widget only available in iOS 13 or later.")
}
}

@IBAction func showCameraUIAction(_ sender: Any) {
let imagePicker = UIImagePickerController()
imagePicker.sourceType = .camera
imagePicker.allowsEditing = false
imagePicker.cameraCaptureMode = .photo
self.present(imagePicker, animated: true, completion: nil)
}
}

@available(iOS 13.0, *)
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 @@ -228,6 +238,19 @@ final class SentryUIRedactBuilder {
}
let newTransform = concatenateTranform(transform, from: layer, withParent: parentLayer)

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

let ignore = !forceRedact && shouldIgnore(view: view)
let swiftUI = SentryRedactViewHelper.shouldRedactSwiftUI(view)
let redact = forceRedact || shouldRedact(view: view) || swiftUI
Expand All @@ -239,7 +262,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 +279,12 @@ final class SentryUIRedactBuilder {
size: layer.bounds.size,
transform: newTransform,
type: .clipOut,
name: layer.name ?? layer.debugDescription
name: view.debugDescription
))
}
}


// 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
211 changes: 210 additions & 1 deletion Tests/SentryTests/ViewCapture/SentryUIRedactBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,215 @@ class SentryUIRedactBuilderTests: XCTestCase {
}
XCTAssertTrue(sut.containsRedactClass(avPlayerViewClass), "AVPlayerView should be in the redact class list")
}

func testViewSubtreeIgnored_noIgnoredViewsInTree_shouldIncludeEntireTree() {
// -- Arrange --
let sut = getSut()
let view = UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40))
rootView.addSubview(view)

let subview = UILabel(frame: CGRect(x: 10, y: 10, width: 20, height: 20))
view.addSubview(subview)

let subSubview = UIView(frame: CGRect(x: 5, y: 5, width: 10, height: 10))
subview.addSubview(subSubview)

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

// -- Assert --
XCTAssertEqual(result.count, 2)

XCTAssertEqual(result.element(at: 0)?.size, CGSize(width: 10, height: 10))
XCTAssertEqual(result.element(at: 0)?.type, .redact)
XCTAssertEqual(result.element(at: 0)?.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 35, ty: 35))
XCTAssertNil(result.element(at: 0)?.color)

XCTAssertEqual(result.element(at: 1)?.size, CGSize(width: 20, height: 20))
XCTAssertEqual(result.element(at: 1)?.type, .redact)
XCTAssertEqual(result.element(at: 1)?.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 30, ty: 30))
XCTAssertNotNil(result.element(at: 1)?.color)
}

func testViewSubtreeIgnored_ignoredViewsInTree_shouldIncludeEntireTree() throws {
// -- Arrange --
let rootView = UIView(frame: .init(origin: .zero, size: .init(width: 200, height: 200)))

let subview = UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40))
rootView.addSubview(subview)

// We need to ignore the subtree of the `CameraUI.ChromeSwiftUIView` class, which is an internal class of the
// private framework `CameraUI`.
//
// See https://github.com/getsentry/sentry-cocoa/pull/6045 for more context.

// Load the private framework indirectly by creating an instance of `UIImagePickerController`
let _ = UIImagePickerController()

// Allocate object memory without calling subclass init, because the Objective-C initializers are unavailable
// and trapped with fatal error.
let cameraViewClass: AnyClass
if #available(iOS 26.0, *) {
cameraViewClass = try XCTUnwrap(NSClassFromString("CameraUI.ChromeSwiftUIView"), "Test case expects the CameraUI.ChromeSwiftUIView class to exist")
} else {
throw XCTSkip("Type CameraUI.ChromeSwiftUIView is not available on this platform")
}
let cameraView = try XCTUnwrap(class_createInstance(cameraViewClass, 0) as? UIView)

// Reinitialize storage using UIView.initWithFrame(_:) which can be considered instance swizzling
//
// This works, because we don't actually use any of the logic of the `CameraUI.ChromeSwiftUIView` and only need
// an instance with the expected type.
typealias InitWithFrame = @convention(c) (AnyObject, Selector, CGRect) -> AnyObject
let sel = NSSelectorFromString("initWithFrame:")
let m = try XCTUnwrap(class_getInstanceMethod(UIView.self, sel))
let f = unsafeBitCast(method_getImplementation(m), to: InitWithFrame.self)
_ = f(cameraView, sel, .zero)

// Assert that the initialization worked but the type is still the expected one
XCTAssertEqual(type(of: cameraView).description(), "CameraUI.ChromeSwiftUIView")

// Add the view to the hierarchy with additional subviews which should not be traversed even though they need
// redaction (i.e. an UILabel).
cameraView.frame = CGRect(x: 10, y: 10, width: 150, height: 150)
subview.addSubview(cameraView)

let nestedCameraView = UILabel(frame: CGRect(x: 30, y: 30, width: 50, height: 50))
cameraView.addSubview(nestedCameraView)

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

// -- Assert --
XCTAssertEqual(result.count, 1)
XCTAssertEqual(result.element(at: 0)?.size, CGSize(width: 150, height: 150))
XCTAssertEqual(result.element(at: 0)?.type, SentryRedactRegionType.redact)
XCTAssertEqual(result.element(at: 0)?.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 30, ty: 30))
XCTAssertTrue(result.element(at: 0)?.name.contains("CameraUI.ChromeSwiftUIView") == true)
XCTAssertNil(result.element(at: 0)?.color)
}

func testMapRedactRegion_viewHasCustomDebugDescription_shouldUseDebugDescriptionAsName() {
// -- Arrange --
// We use a subclass of UILabel, so that the view is redacted by default
class CustomDebugDescriptionLabel: UILabel {
override var debugDescription: String {
return "CustomDebugDescription"
}
}

let sut = getSut()
let view = CustomDebugDescriptionLabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40))
rootView.addSubview(view)

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

// -- Assert --
XCTAssertEqual(result.count, 1)
XCTAssertEqual(result.first?.name, "CustomDebugDescription")
}

func testViewSubtreeIgnored_noDuplicateRedactionRegions_whenViewMeetsBothConditions() throws {
// -- Arrange --
// This test verifies that views meeting both general redaction criteria AND isViewSubtreeIgnored
// condition don't create duplicate redaction regions. The ordering of checks is important -
// isViewSubtreeIgnored must be checked first to prevent processing duplicates.

let rootView = UIView(frame: .init(origin: .zero, size: .init(width: 200, height: 200)))

// Create a CameraUI view that would trigger isViewSubtreeIgnored
// The key is that this CameraUI view should only generate ONE redaction region, not two
let cameraView = try createCameraUIView(frame: CGRect(x: 10, y: 10, width: 100, height: 100))
rootView.addSubview(cameraView)

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

// -- Assert --
// Verify that exactly ONE redaction region is created for the CameraUI view,
// proving that the deduplication fix works and we don't get duplicate regions
XCTAssertEqual(result.count, 1, "Should have exactly one redaction region, not duplicates")
XCTAssertEqual(result.first?.size, CGSize(width: 100, height: 100))
XCTAssertEqual(result.first?.type, SentryRedactRegionType.redact)
XCTAssertEqual(result.first?.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 10))
XCTAssertTrue(result.first?.name.contains("CameraUI.ChromeSwiftUIView") == true)
}

func testViewSubtreeIgnored_noDuplicatesWithCustomRedactedView() throws {
// -- Arrange --
// This test more explicitly demonstrates the duplicate region scenario that could occur:
// A view hierarchy where a CameraUI view contains a UILabel that would normally be redacted.
// The ordering of checks is important - isViewSubtreeIgnored must be checked first to prevent
// duplicate redaction regions when views meet both conditions.

let rootView = UIView(frame: .init(origin: .zero, size: .init(width: 200, height: 200)))

// Create a CameraUI view that triggers isViewSubtreeIgnored
let cameraView = try createCameraUIView(frame: CGRect(x: 10, y: 10, width: 100, height: 100))
rootView.addSubview(cameraView)

// Create a view hierarchy: root -> cameraView -> label
// The label would normally be redacted, but since it's inside a CameraUI view that triggers
// isViewSubtreeIgnored, the entire subtree should be redacted as one region
let label = UILabel(frame: CGRect(x: 20, y: 20, width: 60, height: 30))
label.text = "Test Label"
cameraView.addSubview(label)

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

// -- Assert --
// With the fix, we should get exactly ONE redaction region for the CameraUI view
// The label inside should NOT create a separate redaction region because the
// CameraUI view is handled with early return in isViewSubtreeIgnored
XCTAssertEqual(result.count, 1, "Should have exactly one redaction region for the CameraUI view, no duplicates or separate regions for nested views")
XCTAssertEqual(result.first?.size, CGSize(width: 100, height: 100), "Should redact the entire CameraUI view")
XCTAssertEqual(result.first?.type, SentryRedactRegionType.redact)
XCTAssertEqual(result.first?.transform, CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 10))
XCTAssertTrue(result.first?.name.contains("CameraUI.ChromeSwiftUIView") == true)
}

// MARK: - Helper Methods

/// Creates a CameraUI.ChromeSwiftUIView instance for testing isViewSubtreeIgnored functionality.
/// - Parameters:
/// - frame: The frame to set for the created view
/// - Returns: The created CameraUI view
/// - Throws: XCTSkip if CameraUI is not available, or other errors if creation fails
private func createCameraUIView(frame: CGRect) throws -> UIView {
// Load the private framework indirectly by creating an instance of UIImagePickerController
let _ = UIImagePickerController()

// Get the CameraUI.ChromeSwiftUIView class
let cameraViewClass: AnyClass
if #available(iOS 26.0, *) {
cameraViewClass = try XCTUnwrap(
NSClassFromString("CameraUI.ChromeSwiftUIView"),
"Test case expects the CameraUI.ChromeSwiftUIView class to exist"
)
} else {
throw XCTSkip("Type CameraUI.ChromeSwiftUIView is not available on this platform")
}

// Create an instance of the CameraUI view
let cameraView = try XCTUnwrap(class_createInstance(cameraViewClass, 0) as? UIView)

// Reinitialize storage using UIView.initWithFrame(_:)
typealias InitWithFrame = @convention(c) (AnyObject, Selector, CGRect) -> AnyObject
let sel = NSSelectorFromString("initWithFrame:")
let m = try XCTUnwrap(class_getInstanceMethod(UIView.self, sel))
let f = unsafeBitCast(method_getImplementation(m), to: InitWithFrame.self)
_ = f(cameraView, sel, .zero)

// Configure the view frame
cameraView.frame = frame

return cameraView
}
}

#endif
#endif // os(iOS)
Loading