Skip to content
20 changes: 20 additions & 0 deletions Sources/LaunchDarklySessionReplay/API/ObjcLDMasking.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import UIKit

@objc(LDMasking)
public class ObjcLDMasking: NSObject {
// Use explicit selectors so we control the Obj-C names.
@objc(maskView:)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you are using @objcMembers annotation this is redundant, remove it please

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already but it also not as it assigns the name exported method

public static func mask(view: UIView) {
view.ldMask()
}

@objc(unmaskView:)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you are using @objcMembers annotation this is redundant, remove it please

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use for naming as well

public static func unmask(view: UIView) {
view.ldUnmask()
}

@objc(ignoreView:)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you are using @objcMembers annotation this is redundant, remove it please

public static func ignore(view: UIView) {
view.ldIgnore()
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import UIKit

class SessionReplayAssociatedObjects: NSObject {
private static var swiftUIKey: Int = 0
private static var ignoreUIViewKey: Int = 0
private static var uiViewMaskKey: Int = 0

private override init() {}

static public func maskSwiftUI(_ view: UIView, isEnabled: Bool = true) {
objc_setAssociatedObject(view, &swiftUIKey, isEnabled ? 1 : 0, .OBJC_ASSOCIATION_ASSIGN)
static public func ignoreUIView(_ view: UIView, isEnabled: Bool = true) {
objc_setAssociatedObject(view, &ignoreUIViewKey, isEnabled ? 1 : 0, .OBJC_ASSOCIATION_ASSIGN)
}

static public func shouldMaskSwiftUI(_ view: UIView) -> Bool? {
guard let value = (objc_getAssociatedObject(view, &swiftUIKey) as? Int) else { return nil }
static public func shouldIgnoreUIView(_ view: UIView) -> Bool? {
guard let value = (objc_getAssociatedObject(view, &ignoreUIViewKey) as? Int) else { return nil }
return value == 1
}

Expand All @@ -23,7 +23,6 @@ class SessionReplayAssociatedObjects: NSObject {
guard let value = (objc_getAssociatedObject(view, &uiViewMaskKey) as? Int) else { return nil }
return value == 1
}

}



Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,26 @@ import SwiftUI
import UIKit

struct SessionReplayModifier: ViewModifier {
let isEnabled: Bool
let isEnabled: Bool?
let isIgnored: Bool?

public func body(content: Content) -> some View {
content.overlay(SessionReplayViewRepresentable(isEnabled: isEnabled)).disabled(true)
content.overlay(
SessionReplayViewRepresentable(isEnabled: isEnabled, isIgnored: isIgnored)
.disabled(true)
)
}
}

struct SessionReplayViewRepresentable: UIViewRepresentable {
public typealias Context = UIViewRepresentableContext<Self>

let isEnabled: Bool
let isEnabled: Bool?
let isIgnored: Bool?

public init(isEnabled: Bool) {
public init(isEnabled: Bool?, isIgnored: Bool?) {
self.isEnabled = isEnabled
self.isIgnored = isIgnored
}

class MaskView: UIView { }
Expand All @@ -25,6 +31,11 @@ struct SessionReplayViewRepresentable: UIViewRepresentable {
}

public func updateUIView(_ uiView: MaskView, context: Context) {
SessionReplayAssociatedObjects.maskSwiftUI(uiView, isEnabled: isEnabled)
if let isEnabled {
SessionReplayAssociatedObjects.maskUIView(uiView, isEnabled: isEnabled)
}
if let isIgnored {
SessionReplayAssociatedObjects.ignoreUIView(uiView, isEnabled: isIgnored)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ public struct SessionReplayOptions {
public var maskImages: Bool

public var maskUIViews: [AnyClass]
public var unmaskUIViews: [AnyClass]
public var ignoreUIViews: [AnyClass]

public var maskAccessibilityIdentifiers: [String]
public var unmaskAccessibilityIdentifiers: [String]
public var ignoreAccessibilityIdentifiers: [String]

public var minimumAlpha: CGFloat
Expand All @@ -22,17 +24,21 @@ public struct SessionReplayOptions {
maskLabels: Bool = false,
maskImages: Bool = false,
maskUIViews: [AnyClass] = [],
unmaskUIViews: [AnyClass] = [],
ignoreUIViews: [AnyClass] = [],
maskAccessibilityIdentifiers: [String] = [],
unmaskAccessibilityIdentifiers: [String] = [],
ignoreAccessibilityIdentifiers: [String] = [],
minimumAlpha: CGFloat = 0.02) {
self.maskTextInputs = maskTextInputs
self.maskWebViews = maskWebViews
self.maskLabels = maskLabels
self.maskImages = maskImages
self.maskUIViews = maskUIViews
self.unmaskUIViews = unmaskUIViews
self.ignoreUIViews = ignoreUIViews
self.maskAccessibilityIdentifiers = maskAccessibilityIdentifiers
self.unmaskAccessibilityIdentifiers = unmaskAccessibilityIdentifiers
self.ignoreAccessibilityIdentifiers = ignoreAccessibilityIdentifiers
self.minimumAlpha = minimumAlpha
}
Expand Down
20 changes: 18 additions & 2 deletions Sources/LaunchDarklySessionReplay/API/View+ldMask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ import SwiftUI
import UIKit

public extension View {
func ldMask() -> some View {
modifier(SessionReplayModifier(isEnabled: true, isIgnored: nil))
}

func ldPrivate(isEnabled: Bool = true) -> some View {
modifier(SessionReplayModifier(isEnabled: isEnabled))
modifier(SessionReplayModifier(isEnabled: isEnabled, isIgnored: nil))
}

func ldIgnore() -> some View {
modifier(SessionReplayModifier(isEnabled: nil, isIgnored: true))
}

func ldUnmask() -> some View {
modifier(SessionReplayModifier(isEnabled: false))
modifier(SessionReplayModifier(isEnabled: false, isIgnored: nil))
}
}

Expand All @@ -16,7 +24,15 @@ public extension UIView {
SessionReplayAssociatedObjects.maskUIView(self, isEnabled: isEnabled)
}

func ldMask() {
SessionReplayAssociatedObjects.maskUIView(self, isEnabled: true)
}

func ldUnmask() {
SessionReplayAssociatedObjects.maskUIView(self, isEnabled: false)
}

func ldIgnore() {
SessionReplayAssociatedObjects.ignoreUIView(self)
}
}
107 changes: 58 additions & 49 deletions Sources/LaunchDarklySessionReplay/ScreenCapture/MaskCollector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,20 @@ final class MaskCollector {
enum Constants {
static let maskiOS26ViewTypes = Set(["CameraUI.ChromeSwiftUIView"])
}

struct Settings {
var maskiOS26ViewTypes: Set<String>
var maskTextInputs: Bool
var maskWebViews: Bool
var maskImages: Bool
var minimumAlpha: CGFloat
var maskClasses: Set<ObjectIdentifier>

var maskUIViews: Set<ObjectIdentifier>
var unmaskUIViews: Set<ObjectIdentifier>
var ignoreUIViews: Set<ObjectIdentifier>

var maskAccessibilityIdentifiers: Set<String>
var unmaskAccessibilityIdentifiers: Set<String>
var ignoreAccessibilityIdentifiers: Set<String>

init(privacySettings: PrivacySettings) {
Expand All @@ -30,23 +35,51 @@ final class MaskCollector {
self.maskWebViews = privacySettings.maskWebViews
self.maskImages = privacySettings.maskImages
self.minimumAlpha = privacySettings.minimumAlpha
self.maskClasses = privacySettings.buildMaskClasses()

self.maskUIViews = Set(privacySettings.maskUIViews.map(ObjectIdentifier.init))
self.unmaskUIViews = Set(privacySettings.unmaskUIViews.map(ObjectIdentifier.init))
self.ignoreUIViews = Set(privacySettings.ignoreUIViews.map(ObjectIdentifier.init))

self.maskAccessibilityIdentifiers = Set(privacySettings.maskAccessibilityIdentifiers)
self.unmaskAccessibilityIdentifiers = Set(privacySettings.unmaskAccessibilityIdentifiers)
self.ignoreAccessibilityIdentifiers = Set(privacySettings.ignoreAccessibilityIdentifiers)
}


func shouldIgnore(_ view: UIView) -> Bool {
let viewType = type(of: view)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a little bit expensive cpu-wise, just a notice, since you are now relying on ObjectIdentifier as the main approach to identify the views, it is not a good idea to change the approch this time.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use ObjectIdentifier from the type not from the instance. That's why I need viewType

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand, just a small notice, not asking for a change, we are good

if SessionReplayAssociatedObjects.shouldIgnoreUIView(view) == true {
return true
}

if ignoreUIViews.contains(ObjectIdentifier(viewType)) {
return true
}

if let accessibilityIdentifier = view.accessibilityIdentifier,
ignoreAccessibilityIdentifiers.contains(accessibilityIdentifier) {
return true
}

return false
}

func shouldMask(_ view: UIView) -> Bool {
if let shouldUnmask = SessionReplayAssociatedObjects.shouldMaskUIView(view),
!shouldUnmask {
!shouldUnmask {
return false
}

if let accessibilityIdentifier = view.accessibilityIdentifier,
ignoreAccessibilityIdentifiers.contains(accessibilityIdentifier) {
unmaskAccessibilityIdentifiers.contains(accessibilityIdentifier) {
return false
}

let viewType = type(of: view)
let viewIdentifier = ObjectIdentifier(viewType)
if unmaskUIViews.contains(viewIdentifier) {
return false
}

let stringViewType = String(describing: viewType)

if maskiOS26ViewTypes.contains(stringViewType) {
Expand Down Expand Up @@ -83,23 +116,19 @@ final class MaskCollector {
return true
}

if SessionReplayAssociatedObjects.shouldMaskSwiftUI(view) ?? false {
return true
}

if SessionReplayAssociatedObjects.shouldMaskUIView(view) ?? false {
if maskUIViews.contains(viewIdentifier) {
return true
}

if let accessibilityIdentifier = view.accessibilityIdentifier,
maskAccessibilityIdentifiers.contains(accessibilityIdentifier) {
return true
}
return false

return SessionReplayAssociatedObjects.shouldMaskUIView(view) == true
}
}

var settings: Settings

public init(privacySettings: PrivacySettings) {
Expand All @@ -118,26 +147,26 @@ final class MaskCollector {
view.alpha >= settings.minimumAlpha
else { return }

//let layer = currentView.layer.presentation() ?? currentView.layer
guard !settings.shouldIgnore(view) else { return }

let effectiveFrame = rPresenation.convert(layer.frame, from: layer.superlayer)

let shouldMask = settings.shouldMask(view)
if shouldMask, let mask = createMask(rPresenation, root: root, layer: layer, scale: scale) {
var operation = MaskOperation(mask: mask, kind: .fill, effectiveFrame: effectiveFrame)
#if DEBUG
#if DEBUG
operation.accessibilityIdentifier = view.accessibilityIdentifier
#endif
#endif
result.append(operation)
return
}

if !isSystem(view: view, pLayer: layer) && !isTransparent(view: view, pLayer: layer), result.isNotEmpty {
// if view.accessibilityIdentifier != nil {
result.removeAll {
effectiveFrame.contains($0.effectiveFrame)
}
// }
result.removeAll {
effectiveFrame.contains($0.effectiveFrame)
}
}

if let sublayers = layer.sublayers?.sorted(by: { $0.zPosition < $1.zPosition }) {
sublayers.forEach(visit)
}
Expand All @@ -153,16 +182,14 @@ final class MaskCollector {
}

func isSystem(view: UIView, pLayer: CALayer) -> Bool {
return false
return false
}

func createMask(_ rPresenation: CALayer, root: CALayer, layer: CALayer, scale: CGFloat) -> Mask? {
let scale = 1.0 // scale is already in layers
// let rBounds = rPresenation.bounds
let scale = 1.0
let lBounds = layer.bounds
guard lBounds.width > 0, lBounds.height > 0 else { return nil }

//let lPresenation = layer.presentation() ?? layer

if CATransform3DIsAffine(layer.transform) {
let corner0 = layer.convert(CGPoint.zero, to: root)
let corner1 = layer.convert(CGPoint(x: lBounds.width, y: 0), to: root)
Expand All @@ -176,10 +203,8 @@ final class MaskCollector {
tx: tx,
ty: ty).scaledBy(x: scale, y: scale)
return Mask.affine(rect: lBounds, transform: affineTransform)
} else { // 3D animations
// let corner0 = CGPoint.zero
// let corner1 = CGPoint(x: lBounds.width, y: 0)

} else {
// TODO: finish 3D animations
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove else if you are not implementing/handling this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will

}

return nil
Expand All @@ -193,21 +218,5 @@ final class MaskCollector {
y: min(corner1.y, corner2.y),
width: abs(corner2.x - corner1.x),
height: abs(corner2.y - corner1.y))

}
}

extension PrivacySettings {
func buildMaskClasses() -> Set<ObjectIdentifier> {
let ids = Set(maskUIViews.map(ObjectIdentifier.init))


// if privacySettings.maskTextInputs {
// [UITextField.self, UITextView.self, UIWebView.self, UISearchTextField.self,
// SwiftUI.UITextView.self, SwiftUI.UITextView.self].forEach {
// ids.insert(ObjectIdentifier($0))
// }
// }
return ids
}
}
2 changes: 1 addition & 1 deletion TestApp/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ let config = { () -> LDConfig in
maskTextInputs: true,
maskWebViews: false,
maskImages: false,
maskAccessibilityIdentifiers: ["email-field", "password-field", "card-brand-chip"],
maskAccessibilityIdentifiers: ["email-field", "password-field", "card-brand-chip", "10"],
)
))
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ struct SmoothieList: View {
NavigationLink(tag: smoothie.id, selection: $model.selectedSmoothieID) {
SmoothieView(smoothie: smoothie).environmentObject(model)
} label: {
SmoothieRow(smoothie: smoothie)
SmoothieRow(smoothie: smoothie).ldIgnore()
}
.onChange(of: model.selectedSmoothieID) { newValue in
// Need to make sure the Smoothie exists.
Expand Down
1 change: 1 addition & 0 deletions TestApp/Sources/Fruits/Shared/Smoothie/SmoothieRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ struct SmoothieRow: View {
}
.font(.subheadline)
.accessibilityElement(children: .combine)
.ldIgnore()
}

var listedIngredients: String {
Expand Down
Loading