Skip to content

Commit 5840d2d

Browse files
authored
fix(session-replay): Add exemption for CameraUI traversal for iOS 26.0 (#6045)
1 parent e4ba9a7 commit 5840d2d

File tree

7 files changed

+352
-29
lines changed

7 files changed

+352
-29
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Fixes
6+
7+
- Fix crash in Session Replay when opening the camera UI on iOS 26+ by skipping redaction of internal views.
8+
This may result in more of the camera screen being redacted. (#6045)
9+
310
## 8.56.0-alpha.1
411

512
- No documented changes.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import XCTest
2+
3+
class SessionReplayUITests: BaseUITest {
4+
5+
func testCameraUI_shouldNotCrashOnIOS26() {
6+
// -- Arrange --
7+
// During the beta phase of iOS 26.0 we noticed crashes when traversing the view hierarchy
8+
// of the camera UI. This test is used to verify that no regression occurs.
9+
// See https://github.com/getsentry/sentry-cocoa/issues/5647
10+
app.buttons["Extra"].tap()
11+
app.buttons["Show Camera UI"].tap()
12+
13+
// We need to verify the camera UI is shown by checking for the existence of a UI element.
14+
// This can be any element that is part of the camera UI and can be found reliably.
15+
// The "PhotoCapture" button is a good candidate as it is always present when the
16+
// camera UI is shown.
17+
let cameraUIElement = app.buttons["PhotoCapture"]
18+
XCTAssertTrue(cameraUIElement.waitForExistence(timeout: 5))
19+
20+
// After the Camera UI is shown, we keep it open for 6 seconds to trigger at least one full
21+
// video segment captured (segments are 5 seconds long).
22+
wait(10)
23+
24+
// We know the test succeeded if we reach this point without the app crashing.
25+
}
26+
27+
private func wait(_ seconds: TimeInterval) {
28+
let exp = expectation(description: "Waiting for \(seconds) seconds")
29+
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
30+
exp.fulfill()
31+
}
32+
wait(for: [exp], timeout: seconds + 1)
33+
}
34+
}

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

Lines changed: 32 additions & 22 deletions
Large diffs are not rendered by default.

Samples/iOS-Swift/iOS-Swift/ExtraViewController.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,14 @@ class ExtraViewController: UIViewController {
412412
showToast(in: self, type: .warning, message: "Feedback widget only available in iOS 13 or later.")
413413
}
414414
}
415+
416+
@IBAction func showCameraUIAction(_ sender: Any) {
417+
let imagePicker = UIImagePickerController()
418+
imagePicker.sourceType = .camera
419+
imagePicker.allowsEditing = false
420+
imagePicker.cameraCaptureMode = .photo
421+
self.present(imagePicker, animated: true, completion: nil)
422+
}
415423
}
416424

417425
@available(iOS 13.0, *)

Samples/iOS-Swift/iOS-Swift/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
<false/>
2929
<key>LSRequiresIPhoneOS</key>
3030
<true/>
31+
<key>NSCameraUsageDescription</key>
32+
<string>Testing camera permissions</string>
3133
<key>NSFaceIDUsageDescription</key>
3234
<string>$(PRODUCT_NAME) Authentication with TouchId or FaceID for testing purposes of the Sentry Cocoa SDK.</string>
3335
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>

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

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,18 @@ import WebKit
99
#endif
1010

1111
final class SentryUIRedactBuilder {
12+
// MARK: - Constants
13+
14+
/// Class identifier for ``CameraUI.ChromeSwiftUIView``, if it exists.
15+
///
16+
/// This object identifier is used to identify views of this class type during the redaction process.
17+
/// This workaround is specifically for Xcode 16 building for iOS 26 where accessing CameraUI.ModeLoupeLayer
18+
/// causes a crash due to unimplemented init(layer:) initializer.
19+
private static let cameraSwiftUIViewClassId = "CameraUI.ChromeSwiftUIView"
20+
1221
///This is a wrapper which marks it's direct children to be ignored
1322
private var ignoreContainerClassIdentifier: ObjectIdentifier?
23+
1424
///This is a wrapper which marks it's direct children to be redacted
1525
private var redactContainerClassIdentifier: ObjectIdentifier?
1626

@@ -186,7 +196,7 @@ final class SentryUIRedactBuilder {
186196
}
187197

188198
private func shouldIgnore(view: UIView) -> Bool {
189-
return SentryRedactViewHelper.shouldUnmask(view) || containsIgnoreClass(type(of: view)) || shouldIgnoreParentContainer(view)
199+
return SentryRedactViewHelper.shouldUnmask(view) || containsIgnoreClass(type(of: view)) || shouldIgnoreParentContainer(view)
190200
}
191201

192202
private func shouldIgnoreParentContainer(_ view: UIView) -> Bool {
@@ -228,6 +238,19 @@ final class SentryUIRedactBuilder {
228238
}
229239
let newTransform = concatenateTranform(transform, from: layer, withParent: parentLayer)
230240

241+
// Check if the subtree should be ignored to avoid crashes with some special views.
242+
// If a subtree is ignored, it will be fully redacted and we return early to prevent duplicates.
243+
if isViewSubtreeIgnored(view) {
244+
redacting.append(SentryRedactRegion(
245+
size: layer.bounds.size,
246+
transform: newTransform,
247+
type: .redact,
248+
color: self.color(for: view),
249+
name: view.debugDescription
250+
))
251+
return
252+
}
253+
231254
let ignore = !forceRedact && shouldIgnore(view: view)
232255
let swiftUI = SentryRedactViewHelper.shouldRedactSwiftUI(view)
233256
let redact = forceRedact || shouldRedact(view: view) || swiftUI
@@ -239,7 +262,7 @@ final class SentryUIRedactBuilder {
239262
transform: newTransform,
240263
type: swiftUI ? .redactSwiftUI : .redact,
241264
color: self.color(for: view),
242-
name: layer.name ?? layer.debugDescription
265+
name: view.debugDescription
243266
))
244267

245268
guard !view.clipsToBounds else {
@@ -256,11 +279,12 @@ final class SentryUIRedactBuilder {
256279
size: layer.bounds.size,
257280
transform: newTransform,
258281
type: .clipOut,
259-
name: layer.name ?? layer.debugDescription
282+
name: view.debugDescription
260283
))
261284
}
262285
}
263-
286+
287+
// Traverse the sublayers to redact them if necessary
264288
guard let subLayers = layer.sublayers, subLayers.count > 0 else {
265289
return
266290
}
@@ -272,7 +296,7 @@ final class SentryUIRedactBuilder {
272296
size: layer.bounds.size,
273297
transform: newTransform,
274298
type: .clipEnd,
275-
name: layer.name ?? layer.debugDescription
299+
name: view.debugDescription
276300
))
277301
}
278302
for subLayer in subLayers.sorted(by: { $0.zPosition < $1.zPosition }) {
@@ -283,11 +307,40 @@ final class SentryUIRedactBuilder {
283307
size: layer.bounds.size,
284308
transform: newTransform,
285309
type: .clipBegin,
286-
name: layer.name ?? layer.debugDescription
310+
name: view.debugDescription
287311
))
288312
}
289313
}
290314

315+
private func isViewSubtreeIgnored(_ view: UIView) -> Bool {
316+
// We intentionally avoid using `NSClassFromString` or directly referencing class objects here,
317+
// because both approaches can trigger the Objective-C `+initialize` method on the class.
318+
// This has side effects and can cause crashes, especially when performed off the main thread
319+
// or with UIKit classes that expect to be initialized on the main thread.
320+
//
321+
// Instead, we use the string description of the type (i.e., `type(of: view).description()`)
322+
// for comparison. This is a safer, more "Swifty" approach that avoids the pitfalls of
323+
// class initialization side effects.
324+
//
325+
// We have previously encountered related issues:
326+
// - In EmergeTools' snapshotting code where using `NSClassFromString` led to crashes [1]
327+
// - In Sentry's own SubClassFinder where storing or accessing class objects on a background thread caused crashes due to `+initialize` being called on UIKit classes [2]
328+
//
329+
// [1] https://github.com/EmergeTools/SnapshotPreviews/blob/main/Sources/SnapshotPreviewsCore/View%2BSnapshot.swift#L248
330+
// [2] https://github.com/getsentry/sentry-cocoa/blob/00d97404946a37e983eabb21cc64bd3d5d2cb474/Sources/Sentry/SentrySubClassFinder.m#L58-L84
331+
let viewTypeId = type(of: view).description()
332+
333+
if #available(iOS 26.0, *), viewTypeId == Self.cameraSwiftUIViewClassId {
334+
// CameraUI.ChromeSwiftUIView is a special case because it contains layers which can not be iterated due to this error:
335+
//
336+
// Fatal error: Use of unimplemented initializer 'init(layer:)' for class 'CameraUI.ModeLoupeLayer'
337+
//
338+
// This crash only occurs when building with Xcode 16 for iOS 26, so we add a runtime check
339+
return true
340+
}
341+
return false
342+
}
343+
291344
/**
292345
Gets a transform that represents the layer global position.
293346
*/

0 commit comments

Comments
 (0)