Skip to content

Commit

Permalink
ref: SwiftUI custom redact (#4392)
Browse files Browse the repository at this point in the history
Added sentryReplayUnmask as SwiftUI modifier
  • Loading branch information
brustolin authored Oct 9, 2024
1 parent 0418702 commit 0a23401
Show file tree
Hide file tree
Showing 8 changed files with 101 additions and 28 deletions.
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

0 comments on commit 0a23401

Please sign in to comment.