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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

- Custom redact modifier for SwiftUI (#4362, #4392)

### Removal of Experimental API

- Remove the deprecated experimental Metrics API (#4406): [Learn more](https://sentry.zendesk.com/hc/en-us/articles/26369339769883-Metrics-Beta-Coming-to-an-End)
Expand Down
20 changes: 11 additions & 9 deletions Samples/iOS-SwiftUI/iOS-SwiftUI/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,16 @@ struct ContentView: View {
return SentryTracedView("Content View Body") {
NavigationView {
VStack(alignment: HorizontalAlignment.center, spacing: 16) {
Text(getCurrentTracer()?.transactionContext.name ?? "NO SPAN")
.accessibilityIdentifier("TRANSACTION_NAME")
Text(getCurrentTracer()?.transactionContext.spanId.sentrySpanIdString ?? "NO ID")
.accessibilityIdentifier("TRANSACTION_ID")

Text(getCurrentTracer()?.transactionContext.origin ?? "NO ORIGIN")
.accessibilityIdentifier("TRACE_ORIGIN")

Group {
Text(getCurrentTracer()?.transactionContext.name ?? "NO SPAN")
.accessibilityIdentifier("TRANSACTION_NAME")
Text(getCurrentTracer()?.transactionContext.spanId.sentrySpanIdString ?? "NO ID")
.accessibilityIdentifier("TRANSACTION_ID")
.sentryReplayMask()

Text(getCurrentTracer()?.transactionContext.origin ?? "NO ORIGIN")
.accessibilityIdentifier("TRACE_ORIGIN")
}.sentryReplayUnmask()
SentryTracedView("Child Span") {
VStack {
Text(getCurrentSpan()?.spanDescription ?? "NO SPAN")
Expand Down Expand Up @@ -199,7 +201,7 @@ struct ContentView: View {
Text("Form Screen")
}
}
.sentryReplayMask()
.background(Color.white)
}
SecondView()
}
Expand Down
2 changes: 0 additions & 2 deletions Samples/iOS-SwiftUI/iOS-SwiftUI/SwiftUIApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ struct SwiftUIApp: App {
options.tracesSampleRate = 1.0
options.profilesSampleRate = 1.0
options.experimental.sessionReplay.sessionSampleRate = 1.0
options.experimental.sessionReplay.maskAllImages = false
options.experimental.sessionReplay.maskAllText = false
options.initialScope = { scope in
scope.injectGitInformation()
return scope
Expand Down
37 changes: 31 additions & 6 deletions Sources/SentrySwiftUI/SentryReplayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,41 @@ import Sentry
import SwiftUI
import UIKit

#if CARTHAGE || SWIFT_PACKAGE
@_implementationOnly import SentryInternal
#endif

enum MaskBehavior {
case mask
case unmask
}

@available(iOS 13, macOS 10.15, tvOS 13, *)
struct SentryReplayView: UIViewRepresentable {
let maskBehavior: MaskBehavior

class SentryRedactView: UIView {
}

func makeUIView(context: Context) -> UIView {
let result = SentryRedactView()
result.sentryReplayMask()
return result
let view = SentryRedactView()
view.isUserInteractionEnabled = false
return view
}

func updateUIView(_ uiView: UIView, context: Context) {
// This is blank on purpose. UIViewRepresentable requires this function.
switch maskBehavior {
case .mask: SentryRedactViewHelper.maskSwiftUI(uiView)
case .unmask: SentryRedactViewHelper.clipOutView(uiView)
}
}
}

@available(iOS 13, macOS 10.15, tvOS 13, *)
struct SentryReplayModifier: ViewModifier {
let behavior: MaskBehavior
func body(content: Content) -> some View {
content.background(SentryReplayView())
content.overlay(SentryReplayView(maskBehavior: behavior))
}
}

Expand All @@ -38,7 +53,17 @@ public extension View {
/// - Returns: A modifier that redacts sensitive information during session replays.
/// - Experiment: This is an experimental feature and may still have bugs.
func sentryReplayMask() -> some View {
modifier(SentryReplayModifier())
modifier(SentryReplayModifier(behavior: .mask))
}

/// Marks the view as safe to not be masked during session replay.
///
/// Anything that is behind this view will also not be masked anymore.
///
/// - Returns: A modifier that prevents a view from being masked in the session replay.
/// - Experiment: This is an experimental feature and may still have bugs.
func sentryReplayUnmask() -> some View {
modifier(SentryReplayModifier(behavior: .unmask))
}
}
#endif
1 change: 1 addition & 0 deletions Sources/Swift/Extensions/UIViewExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public extension UIView {
func sentryReplayUnmask() {
SentryRedactViewHelper.unmaskView(self)
}

}

#endif
Expand Down
2 changes: 1 addition & 1 deletion Sources/Swift/Tools/SentryViewPhotographer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider {
let path = CGPath(rect: rect, transform: &transform)

switch region.type {
case .redact:
case .redact, .redactSwiftUI:
(region.color ?? UIImageHelper.averageColor(of: context.currentImage, at: rect.applying(region.transform))).setFill()
context.cgContext.addPath(path)
context.cgContext.fillPath()
Expand Down
54 changes: 45 additions & 9 deletions Sources/Swift/Tools/UIRedactBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ enum RedactRegionType {
/// Pop the last Pushed region from the drawing context.
/// Used after prossing every child of a view that clip to its bounds.
case clipEnd

/// These regions are redacted first, there is no way to avoid it.
case redactSwiftUI
}

struct RedactRegion {
Expand Down Expand Up @@ -155,7 +158,19 @@ class UIRedactBuilder {
rootFrame: view.frame,
transform: CGAffineTransform.identity)

return redactingRegions.reversed()
var swiftUIRedact = [RedactRegion]()
var otherRegions = [RedactRegion]()

for region in redactingRegions {
if region.type == .redactSwiftUI {
swiftUIRedact.append(region)
} else {
otherRegions.append(region)
}
}

//The swiftUI type needs to appear first in the list so it always get masked
return swiftUIRedact + otherRegions.reversed()
}

private func shouldIgnore(view: UIView) -> Bool {
Expand Down Expand Up @@ -187,11 +202,12 @@ class UIRedactBuilder {
let newTransform = concatenateTranform(transform, with: layer)

let ignore = !forceRedact && shouldIgnore(view: view)
let redact = forceRedact || shouldRedact(view: view)
let swiftUI = SentryRedactViewHelper.shouldRedactSwiftUI(view)
let redact = forceRedact || shouldRedact(view: view) || swiftUI
var enforceRedact = forceRedact

if !ignore && redact {
redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: .redact, color: self.color(for: view)))
redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: swiftUI ? .redactSwiftUI : .redact, color: self.color(for: view)))

guard !view.clipsToBounds else { return }
enforceRedact = true
Expand Down Expand Up @@ -248,14 +264,22 @@ class UIRedactBuilder {
Indicates whether the view is opaque and will block other view behind it
*/
private func isOpaque(_ view: UIView) -> Bool {
return view.alpha == 1 && view.backgroundColor != nil && (view.backgroundColor?.cgColor.alpha ?? 0) == 1
return SentryRedactViewHelper.shouldClipOut(view) || (view.alpha == 1 && view.backgroundColor != nil && (view.backgroundColor?.cgColor.alpha ?? 0) == 1)
}
}

@objcMembers
class SentryRedactViewHelper: NSObject {
public class SentryRedactViewHelper: NSObject {
private static var associatedRedactObjectHandle: UInt8 = 0
private static var associatedIgnoreObjectHandle: UInt8 = 0
private static var associatedClipOutObjectHandle: UInt8 = 0
private static var associatedSwiftUIRedactObjectHandle: UInt8 = 0

override private init() {}

static func maskView(_ view: UIView) {
objc_setAssociatedObject(view, &associatedRedactObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN)
}

static func shouldMaskView(_ view: UIView) -> Bool {
(objc_getAssociatedObject(view, &associatedRedactObjectHandle) as? NSNumber)?.boolValue ?? false
Expand All @@ -265,13 +289,25 @@ class SentryRedactViewHelper: NSObject {
(objc_getAssociatedObject(view, &associatedIgnoreObjectHandle) as? NSNumber)?.boolValue ?? false
}

static func maskView(_ view: UIView) {
objc_setAssociatedObject(view, &associatedRedactObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN)
}

static func unmaskView(_ view: UIView) {
objc_setAssociatedObject(view, &associatedIgnoreObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN)
}

static func shouldClipOut(_ view: UIView) -> Bool {
(objc_getAssociatedObject(view, &associatedClipOutObjectHandle) as? NSNumber)?.boolValue ?? false
}

static public func clipOutView(_ view: UIView) {
objc_setAssociatedObject(view, &associatedClipOutObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN)
}

static func shouldRedactSwiftUI(_ view: UIView) -> Bool {
(objc_getAssociatedObject(view, &associatedSwiftUIRedactObjectHandle) as? NSNumber)?.boolValue ?? false
}

static public func maskSwiftUI(_ view: UIView) {
objc_setAssociatedObject(view, &associatedSwiftUIRedactObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN)
}
}

#endif
Expand Down
9 changes: 8 additions & 1 deletion Tests/SentryTests/SwiftUI/SentryRedactModifierTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ import XCTest

class SentryRedactModifierTests: XCTestCase {

func testViewRedacted() throws {
func testViewMask() throws {
let text = Text("Hello, World!")
let redactedText = text.sentryReplayMask()

XCTAssertTrue(redactedText is ModifiedContent<Text, SentryReplayModifier>)
}

func testViewUnmask() throws {
let text = Text("Hello, World!")
let redactedText = text.sentryReplayUnmask()

XCTAssertTrue(redactedText is ModifiedContent<Text, SentryReplayModifier>)
}

}

Expand Down
Loading