From fd0436f5b728eaf4c20ab6076b454431bf00dda8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 5 Nov 2024 11:50:31 +0100 Subject: [PATCH 1/6] recording: fix RN iOS masking --- CHANGELOG.md | 2 + PostHog/Replay/PostHogReplayIntegration.swift | 49 +++++++++++++++---- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 735e49a65..ce9388067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- recording: fix RN iOS masking ([#209](https://github.com/PostHog/posthog-ios/pull/209)) + ## 3.14.0 - 2024-11-05 - add option to pass a custom timestamp when calling capture() ([#228](https://github.com/PostHog/posthog-ios/pull/228)) diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index 356003722..7907321ea 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -30,6 +30,9 @@ private let swiftUIGenericTypes = ["_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView", "_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView"].compactMap { NSClassFromString($0) } + private let reactNativeTextView: AnyClass? = NSClassFromString("RCTTextView") + private let reactNativeImageView: AnyClass? = NSClassFromString("RCTImageView") + static let dispatchQueue = DispatchQueue(label: "com.posthog.PostHogReplayIntegration", target: .global(qos: .utility)) @@ -97,7 +100,7 @@ var data: [String: Any] = ["width": width, "height": height] - if let screenName { + if let screenName = screenName { data["href"] = screenName } @@ -155,7 +158,7 @@ return wireframe } - private func findMaskableWidgets(_ view: UIView, _ parent: UIView, _ maskableWidgets: inout [CGRect]) { + private func findMaskableWidgets(_ view: UIView, _ parent: UIView, _ maskableWidgets: inout [CGRect], _ maskChildren: inout Bool) { if let textView = view as? UITextView { // TextEditor, SwiftUI.TextEditorTextView, SwiftUI.UIKitTextView if isTextViewSensitive(textView) { maskableWidgets.append(view.toAbsoluteRect(parent)) @@ -170,6 +173,13 @@ } } + if let reactNativeTextView = reactNativeTextView { + if view.isKind(of: reactNativeTextView), config.sessionReplayConfig.maskAllTextInputs { + maskableWidgets.append(view.toAbsoluteRect(parent)) + return + } + } + if let image = view as? UIImageView { // Image, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead if isImageViewSensitive(image) { maskableWidgets.append(view.toAbsoluteRect(parent)) @@ -177,6 +187,13 @@ } } + if let reactNativeImageView = reactNativeImageView { + if view.isKind(of: reactNativeImageView), config.sessionReplayConfig.maskAllImages { + maskableWidgets.append(view.toAbsoluteRect(parent)) + return + } + } + if let label = view as? UILabel { // Text, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead if isLabelSensitive(label) { maskableWidgets.append(view.toAbsoluteRect(parent)) @@ -232,9 +249,18 @@ } } - // manually masked views through view modifier `PostHogMaskViewModifier` - if view.phIsManuallyMasked { - maskableWidgets.append(view.toAbsoluteRect(parent)) + // on RN, lots get converted to RCTRootContentView, RCTRootView, RCTView and sometimes its just the whole screen, we dont want to mask + // in such cases + if view.isNoCapture() || maskChildren { + let viewRect = view.toAbsoluteRect(parent) + let parentRect = parent.frame + + // Check if the rectangles do not match + if !viewRect.equalTo(parentRect) { + maskableWidgets.append(view.toAbsoluteRect(parent)) + } else { + maskChildren = true + } } if !view.subviews.isEmpty { @@ -243,9 +269,10 @@ continue } - findMaskableWidgets(child, parent, &maskableWidgets) + findMaskableWidgets(child, parent, &maskableWidgets, &maskChildren) } } + maskChildren = false } private func toScreenshotWireframe(_ view: UIView) -> RRWireframe? { @@ -254,7 +281,8 @@ } var maskableWidgets: [CGRect] = [] - findMaskableWidgets(view, view, &maskableWidgets) + var maskChildren = false + findMaskableWidgets(view, view, &maskableWidgets, &maskChildren) let wireframe = createBasicWireframe(view) @@ -311,11 +339,11 @@ } private func hasText(_ text: String?) -> Bool { - if let text, !text.isEmpty { - true + if let text = text, !text.isEmpty { + return true } else { // if there's no text, there's nothing to mask - false + return false } } @@ -534,6 +562,7 @@ private protocol AnyObjectUIHostingViewController: AnyObject {} extension UIHostingController: AnyObjectUIHostingViewController {} + #endif // swiftlint:enable cyclomatic_complexity From 41f347c76d18f982dd1d4f004c4e0b7890f054cc Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 5 Nov 2024 11:53:53 +0100 Subject: [PATCH 2/6] fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9388067..21e6f1628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## Next -- recording: fix RN iOS masking ([#209](https://github.com/PostHog/posthog-ios/pull/209)) +- recording: fix RN iOS masking ([#230](https://github.com/PostHog/posthog-ios/pull/230)) ## 3.14.0 - 2024-11-05 From 5ebafd966231ebf9d22cc89bc337848d9c8f700c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 5 Nov 2024 12:31:26 +0100 Subject: [PATCH 3/6] fix --- PostHog/Replay/PostHogReplayIntegration.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index 7907321ea..1399f0f54 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -249,6 +249,12 @@ } } + // manually masked views through view modifier `PostHogMaskViewModifier` + if view.phIsManuallyMasked { + maskableWidgets.append(view.toAbsoluteRect(parent)) + return + } + // on RN, lots get converted to RCTRootContentView, RCTRootView, RCTView and sometimes its just the whole screen, we dont want to mask // in such cases if view.isNoCapture() || maskChildren { From 6ddb2a222d5cd724c62dd5c5c2a625cff24a2f18 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 5 Nov 2024 12:43:16 +0100 Subject: [PATCH 4/6] rename --- PostHog/Replay/PostHogReplayIntegration.swift | 106 +++++++++--------- PostHog/Replay/UIView+Util.swift | 4 +- 2 files changed, 55 insertions(+), 55 deletions(-) diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index 1399f0f54..6cd79692e 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -17,7 +17,7 @@ private var timer: Timer? - private let windowViews = NSMapTable.weakToStrongObjects() + private let windowViews = NSMapTable.weakToStrongObjects() private let urlInterceptor: URLSessionInterceptor private var sessionSwizzler: URLSessionSwizzler? @@ -81,20 +81,20 @@ windowViews.removeAllObjects() } - private func generateSnapshot(_ view: UIView, _ screenName: String? = nil) { + private func generateSnapshot(_ window: UIWindow, _ screenName: String? = nil) { var hasChanges = false let timestamp = Date().toMillis() - let snapshotStatus = windowViews.object(forKey: view) ?? ViewTreeSnapshotStatus() + let snapshotStatus = windowViews.object(forKey: window) ?? ViewTreeSnapshotStatus() - guard let wireframe = config.sessionReplayConfig.screenshotMode ? toScreenshotWireframe(view) : toWireframe(view) else { + guard let wireframe = config.sessionReplayConfig.screenshotMode ? toScreenshotWireframe(window) : toWireframe(window) else { return } var snapshotsData: [Any] = [] if !snapshotStatus.sentMetaEvent { - let size = view.bounds.size + let size = window.bounds.size let width = Int(size.width) let height = Int(size.height) @@ -111,7 +111,7 @@ } if hasChanges { - windowViews.setObject(snapshotStatus, forKey: view) + windowViews.setObject(snapshotStatus, forKey: window) } // TODO: IncrementalSnapshot, type=2 @@ -146,57 +146,57 @@ style.paddingLeft = Int(insets.left) } - private func createBasicWireframe(_ view: UIView) -> RRWireframe { + private func createBasicWireframe(_ window: UIView) -> RRWireframe { let wireframe = RRWireframe() - wireframe.id = view.hash - wireframe.posX = Int(view.frame.origin.x) - wireframe.posY = Int(view.frame.origin.y) - wireframe.width = Int(view.frame.size.width) - wireframe.height = Int(view.frame.size.height) + wireframe.id = window.hash + wireframe.posX = Int(window.frame.origin.x) + wireframe.posY = Int(window.frame.origin.y) + wireframe.width = Int(window.frame.size.width) + wireframe.height = Int(window.frame.size.height) return wireframe } - private func findMaskableWidgets(_ view: UIView, _ parent: UIView, _ maskableWidgets: inout [CGRect], _ maskChildren: inout Bool) { + private func findMaskableWidgets(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [CGRect], _ maskChildren: inout Bool) { if let textView = view as? UITextView { // TextEditor, SwiftUI.TextEditorTextView, SwiftUI.UIKitTextView if isTextViewSensitive(textView) { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } } if let textField = view as? UITextField { // TextField if isTextFieldSensitive(textField) { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } } if let reactNativeTextView = reactNativeTextView { if view.isKind(of: reactNativeTextView), config.sessionReplayConfig.maskAllTextInputs { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } } if let image = view as? UIImageView { // Image, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead if isImageViewSensitive(image) { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } } if let reactNativeImageView = reactNativeImageView { if view.isKind(of: reactNativeImageView), config.sessionReplayConfig.maskAllImages { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } } if let label = view as? UILabel { // Text, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead if isLabelSensitive(label) { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } } @@ -205,21 +205,21 @@ // since we cannot mask the webview content, if masking texts or images are enabled // we mask the whole webview as well if isAnyInputSensitive(webView) { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } } if let button = view as? UIButton { // Button, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead if isButtonSensitive(button) { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } } if let theSwitch = view as? UISwitch { // Toggle (no text, items are just rendered to Text (swiftUIImageTypes)) if isSwitchSensitive(theSwitch) { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } } @@ -229,14 +229,14 @@ if let picker = view as? UIPickerView { // Picker (no source, items are just rendered to Text (swiftUIImageTypes)) if isTextInputSensitive(picker), !hasSubViews { - maskableWidgets.append(picker.toAbsoluteRect(parent)) + maskableWidgets.append(picker.toAbsoluteRect(window)) return } } if swiftUIImageTypes.contains(where: { view.isKind(of: $0) }) { if isSwiftUIImageSensitive(view), !hasSubViews { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } } @@ -244,26 +244,26 @@ // this can be anything, so better to be conservative if swiftUIGenericTypes.contains(where: { view.isKind(of: $0) }) { if isTextInputSensitive(view), !hasSubViews { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } } // manually masked views through view modifier `PostHogMaskViewModifier` if view.phIsManuallyMasked { - maskableWidgets.append(view.toAbsoluteRect(parent)) + maskableWidgets.append(view.toAbsoluteRect(window)) return } // on RN, lots get converted to RCTRootContentView, RCTRootView, RCTView and sometimes its just the whole screen, we dont want to mask // in such cases if view.isNoCapture() || maskChildren { - let viewRect = view.toAbsoluteRect(parent) - let parentRect = parent.frame + let viewRect = view.toAbsoluteRect(window) + let windowRect = window.frame // Check if the rectangles do not match - if !viewRect.equalTo(parentRect) { - maskableWidgets.append(view.toAbsoluteRect(parent)) + if !viewRect.equalTo(windowRect) { + maskableWidgets.append(view.toAbsoluteRect(window)) } else { maskChildren = true } @@ -275,24 +275,24 @@ continue } - findMaskableWidgets(child, parent, &maskableWidgets, &maskChildren) + findMaskableWidgets(child, window, &maskableWidgets, &maskChildren) } } maskChildren = false } - private func toScreenshotWireframe(_ view: UIView) -> RRWireframe? { - if !view.isVisible() { + private func toScreenshotWireframe(_ window: UIWindow) -> RRWireframe? { + if !window.isVisible() { return nil } var maskableWidgets: [CGRect] = [] var maskChildren = false - findMaskableWidgets(view, view, &maskableWidgets, &maskChildren) + findMaskableWidgets(window, window, &maskableWidgets, &maskChildren) - let wireframe = createBasicWireframe(view) + let wireframe = createBasicWireframe(window) - if let image = view.toImage() { + if let image = window.toImage() { if !image.size.hasSize() { return nil } @@ -374,16 +374,16 @@ return (config.sessionReplayConfig.maskAllImages && !isAsset) || view.isNoCapture() } - private func toWireframe(_ view: UIView) -> RRWireframe? { - if !view.isVisible() { + private func toWireframe(_ window: UIView) -> RRWireframe? { + if !window.isVisible() { return nil } - let wireframe = createBasicWireframe(view) + let wireframe = createBasicWireframe(window) let style = RRStyle() - if let textView = view as? UITextView { + if let textView = window as? UITextView { wireframe.type = "text" wireframe.text = isTextViewSensitive(textView) ? textView.text.mask() : textView.text wireframe.disabled = !textView.isEditable @@ -396,7 +396,7 @@ setPadding(textView.textContainerInset, style) } - if let textField = view as? UITextField { + if let textField = window as? UITextField { wireframe.type = "input" wireframe.inputType = "text_area" let isSensitive = isTextFieldSensitive(textField) @@ -416,13 +416,13 @@ setAlignment(textField.textAlignment, style) } - if view is UIPickerView { + if window is UIPickerView { wireframe.type = "input" wireframe.inputType = "select" // set wireframe.value from selected row } - if let theSwitch = view as? UISwitch { + if let theSwitch = window as? UISwitch { wireframe.type = "input" wireframe.inputType = "toggle" wireframe.checked = theSwitch.isOn @@ -433,7 +433,7 @@ } } - if let imageView = view as? UIImageView { + if let imageView = window as? UIImageView { wireframe.type = "image" if let image = imageView.image { if !isImageViewSensitive(imageView) { @@ -442,7 +442,7 @@ } } - if let button = view as? UIButton { + if let button = window as? UIButton { wireframe.type = "input" wireframe.inputType = "button" wireframe.disabled = !button.isEnabled @@ -452,7 +452,7 @@ } } - if let label = view as? UILabel { + if let label = window as? UILabel { wireframe.type = "text" if let text = label.text { wireframe.text = isLabelSensitive(label) ? text.mask() : text @@ -466,11 +466,11 @@ setAlignment(label.textAlignment, style) } - if view is WKWebView { + if window is WKWebView { wireframe.type = "web_view" } - if let progressView = view as? UIProgressView { + if let progressView = window as? UIProgressView { wireframe.type = "input" wireframe.inputType = "progress" wireframe.value = progressView.progress @@ -479,7 +479,7 @@ style.bar = "horizontal" } - if view is UIActivityIndicatorView { + if window is UIActivityIndicatorView { wireframe.type = "input" wireframe.inputType = "progress" style.bar = "circular" @@ -488,17 +488,17 @@ // TODO: props: backgroundImage (probably not needed) // TODO: componenets: UITabBar, UINavigationBar, UISlider, UIStepper, UIDatePicker - style.backgroundColor = view.backgroundColor?.toRGBString() - let layer = view.layer + style.backgroundColor = window.backgroundColor?.toRGBString() + let layer = window.layer style.borderWidth = Int(layer.borderWidth) style.borderRadius = Int(layer.cornerRadius) style.borderColor = layer.borderColor?.toRGBString() wireframe.style = style - if !view.subviews.isEmpty { + if !window.subviews.isEmpty { var childWireframes: [RRWireframe] = [] - for subview in view.subviews { + for subview in window.subviews { if let child = toWireframe(subview) { childWireframes.append(child) } diff --git a/PostHog/Replay/UIView+Util.swift b/PostHog/Replay/UIView+Util.swift index 74724b389..283782197 100644 --- a/PostHog/Replay/UIView+Util.swift +++ b/PostHog/Replay/UIView+Util.swift @@ -59,8 +59,8 @@ } // you need this because of SwiftUI otherwise the coordinates always zeroed for some reason - func toAbsoluteRect(_ parent: UIView) -> CGRect { - convert(bounds, to: parent) + func toAbsoluteRect(_ window: UIWindow) -> CGRect { + convert(bounds, to: window) } } #endif From b0f06eb7fca1251d7871752b498e10c4321d6bc4 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 5 Nov 2024 12:50:54 +0100 Subject: [PATCH 5/6] fix --- PostHog/Replay/PostHogReplayIntegration.swift | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index 6cd79692e..920df2856 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -274,6 +274,10 @@ if !child.isVisible() { continue } + // return early if the view is not visible + if !child.clipsToBounds { + continue + } findMaskableWidgets(child, window, &maskableWidgets, &maskChildren) } @@ -374,16 +378,16 @@ return (config.sessionReplayConfig.maskAllImages && !isAsset) || view.isNoCapture() } - private func toWireframe(_ window: UIView) -> RRWireframe? { - if !window.isVisible() { + private func toWireframe(_ view: UIView) -> RRWireframe? { + if !view.isVisible() { return nil } - let wireframe = createBasicWireframe(window) + let wireframe = createBasicWireframe(view) let style = RRStyle() - if let textView = window as? UITextView { + if let textView = view as? UITextView { wireframe.type = "text" wireframe.text = isTextViewSensitive(textView) ? textView.text.mask() : textView.text wireframe.disabled = !textView.isEditable @@ -396,7 +400,7 @@ setPadding(textView.textContainerInset, style) } - if let textField = window as? UITextField { + if let textField = view as? UITextField { wireframe.type = "input" wireframe.inputType = "text_area" let isSensitive = isTextFieldSensitive(textField) @@ -416,13 +420,13 @@ setAlignment(textField.textAlignment, style) } - if window is UIPickerView { + if view is UIPickerView { wireframe.type = "input" wireframe.inputType = "select" // set wireframe.value from selected row } - if let theSwitch = window as? UISwitch { + if let theSwitch = view as? UISwitch { wireframe.type = "input" wireframe.inputType = "toggle" wireframe.checked = theSwitch.isOn @@ -433,7 +437,7 @@ } } - if let imageView = window as? UIImageView { + if let imageView = view as? UIImageView { wireframe.type = "image" if let image = imageView.image { if !isImageViewSensitive(imageView) { @@ -442,7 +446,7 @@ } } - if let button = window as? UIButton { + if let button = view as? UIButton { wireframe.type = "input" wireframe.inputType = "button" wireframe.disabled = !button.isEnabled @@ -452,7 +456,7 @@ } } - if let label = window as? UILabel { + if let label = view as? UILabel { wireframe.type = "text" if let text = label.text { wireframe.text = isLabelSensitive(label) ? text.mask() : text @@ -466,11 +470,11 @@ setAlignment(label.textAlignment, style) } - if window is WKWebView { + if view is WKWebView { wireframe.type = "web_view" } - if let progressView = window as? UIProgressView { + if let progressView = view as? UIProgressView { wireframe.type = "input" wireframe.inputType = "progress" wireframe.value = progressView.progress @@ -479,7 +483,7 @@ style.bar = "horizontal" } - if window is UIActivityIndicatorView { + if view is UIActivityIndicatorView { wireframe.type = "input" wireframe.inputType = "progress" style.bar = "circular" @@ -488,17 +492,17 @@ // TODO: props: backgroundImage (probably not needed) // TODO: componenets: UITabBar, UINavigationBar, UISlider, UIStepper, UIDatePicker - style.backgroundColor = window.backgroundColor?.toRGBString() - let layer = window.layer + style.backgroundColor = view.backgroundColor?.toRGBString() + let layer = view.layer style.borderWidth = Int(layer.borderWidth) style.borderRadius = Int(layer.cornerRadius) style.borderColor = layer.borderColor?.toRGBString() wireframe.style = style - if !window.subviews.isEmpty { + if !view.subviews.isEmpty { var childWireframes: [RRWireframe] = [] - for subview in window.subviews { + for subview in view.subviews { if let child = toWireframe(subview) { childWireframes.append(child) } From 094bc22b1f3e7002f86b08a7084cba69747e128d Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 5 Nov 2024 12:53:08 +0100 Subject: [PATCH 6/6] fix --- PostHog/Replay/PostHogReplayIntegration.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index 920df2856..ecd34da0f 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -274,10 +274,6 @@ if !child.isVisible() { continue } - // return early if the view is not visible - if !child.clipsToBounds { - continue - } findMaskableWidgets(child, window, &maskableWidgets, &maskChildren) }