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
5 changes: 5 additions & 0 deletions .changeset/mask-full-window-no-capture-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ios": patch
---

Mask the whole rect of a `ph-no-capture` view in session replay screenshots, including a view that fills the window.
32 changes: 12 additions & 20 deletions PostHog/Replay/PostHogReplayIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1103,19 +1103,11 @@
}
}

// on RN, lots get converted to RCTRootContentView, RCTRootView, RCTView and sometimes its just the whole screen, we dont want to mask
// in such cases
var maskDescendants = maskChildren
if view.isNoCapture() || maskChildren {
let viewRect = view.toAbsoluteRect(window)
let windowRect = window.frame

// Check if the rectangles do not match
if !viewRect.equalTo(windowRect) {
maskableWidgets.append(.init(view, in: window))
} else {
maskDescendants = true
}
// Masked whole, even when it fills the window, and so is every descendant: an
// unclipped descendant can draw outside the view's rect.
let maskDescendants = view.isNoCapture() || maskChildren
Comment thread
turnipdabeets marked this conversation as resolved.
Resolved
if maskDescendants {
maskableWidgets.append(.init(view, in: window))
}

findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskDescendants)
Expand Down Expand Up @@ -1222,6 +1214,10 @@

var maskableWidgets: [MaskedRegion] = []
findMaskableWidgets(under: cover, in: window, &maskableWidgets)
// A reporter inside a `ph-no-capture` view is also reached by the walk. Keep one
// entry per reporter: a repeated owner reads as motion to the settle check.
let reporters = Set(masked.regions.map(\.owner))
maskableWidgets.removeAll { reporters.contains($0.owner) }
maskableWidgets.append(contentsOf: masked.regions)
return maskableWidgets
}
Expand Down Expand Up @@ -2026,13 +2022,9 @@
guard ancestor.isNoCapture() || maskChildren else {
continue
}
if ancestor.toAbsoluteRect(window).equalTo(window.frame) {
maskChildren = true
} else {
// An ancestor that is not the window's size is redacted as one rect, and the
// cover it holds is redacted with it.
maskableWidgets.append(.init(ancestor, in: window))
}
// Redacted as one rect, and the cover it holds is masked with its descendants.
maskableWidgets.append(.init(ancestor, in: window))
maskChildren = true
}

findMaskableWidgets(cover, window, &maskableWidgets, maskChildren)
Expand Down
37 changes: 36 additions & 1 deletion PostHogTests/PostHogMaskFailClosedTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,42 @@
let window = makeWindow(containing: parent)
window.addSubview(CustomDrawnSecretView(frame: CGRect(x: 24, y: 160, width: 200, height: 32)))

#expect(sut.integration.collectMaskableRects(in: window) == [secret.frame])
#expect(sut.integration.collectMaskableRects(in: window) == [parent.frame, secret.frame])
}

@Test("a full-window no-capture view masks its own drawing, not only its subviews")
func fullWindowNoCaptureViewIsMasked() {
let sut = makeSut()
defer { teardown(sut) }

let tagged = CustomDrawnSecretView(frame: CGRect(x: 0, y: 0, width: 320, height: 640))
tagged.accessibilityIdentifier = "ph-no-capture"
let child = UIView(frame: CGRect(x: 24, y: 600, width: 20, height: 20))
tagged.addSubview(child)
let window = makeWindow(containing: tagged)

#expect(sut.integration.collectMaskableRects(in: window) == [window.bounds, child.frame])
}

@Test("an unclipped child drawing outside a shrinking no-capture view stays masked")
func overflowingChildOfShrinkingNoCaptureViewIsMasked() throws {
let sut = makeSut()
defer { teardown(sut) }

// The model frame fills the window while the rendered bounds animate down to 100pt.
let tagged = CollapsingView(frame: CGRect(x: 0, y: 0, width: 320, height: 640))
tagged.accessibilityIdentifier = "ph-no-capture"
let layer = try #require(tagged.layer as? CollapsingLayer)
layer.renderedBounds = CGRect(x: 0, y: 0, width: 100, height: 100)
let secret = CustomDrawnSecretView(frame: CGRect(x: 24, y: 600, width: 200, height: 32))
tagged.addSubview(secret)
let window = makeWindow(containing: tagged)

let rects = try #require(sut.integration.collectMaskableRects(in: window))
let taggedRect = tagged.toPresentationRect(window)
let secretRect = secret.toPresentationRect(window)
#expect(!taggedRect.contains(secretRect))
#expect(rects == [taggedRect, secretRect])
}

@Test("a collapsed clipping parent is walked while its presentation bounds remain visible", arguments: [true, false])
Expand Down
18 changes: 18 additions & 0 deletions PostHogTests/PostHogMaskingCharacterizationTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,24 @@
#expect(rects == [CGRect(x: 10, y: 100, width: 100, height: 40)], "reporter rect flows through the capture collection")
}

@Test("a reporter inside a ph-no-capture view is collected once")
func reporterInsideNoCaptureViewIsCollectedOnce() {
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 640))
let container = UIView(frame: CGRect(x: 20, y: 80, width: 200, height: 200))
container.accessibilityIdentifier = "ph-no-capture"
window.addSubview(container)

let reporter = PostHogMaskReporterUIView(frame: CGRect(x: 10, y: 20, width: 100, height: 40))
container.addSubview(reporter)
defer { reporter.removeFromSuperview() }
window.layoutIfNeeded()

let integration = PostHogReplayIntegration()
let rects = integration.collectMaskableRects(in: window)

#expect(rects == [CGRect(x: 20, y: 80, width: 200, height: 200), CGRect(x: 30, y: 100, width: 100, height: 40)])
}

// MARK: - Pre-first-layout gap (fail closed)

@Test("a reporter completes its first layout even at zero size")
Expand Down
26 changes: 26 additions & 0 deletions PostHogTests/PostHogReactNativeMaskingTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
@objc(RNSVGText) private final class SvgTextViewStub: SvgGroupViewStub {}
@objc(RNSVGImage) private final class SvgImageViewStub: SvgRenderableViewStub {}

/// Derives its label from its subviews the way RCTView does when none is assigned.
private final class DerivedLabelContainerView: UIView {
override var accessibilityLabel: String? {
get { subviews.compactMap(\.accessibilityLabel).joined(separator: " ") }
set { _ = newValue }
}
}

@Suite("Replay masking for React Native (Fabric) component views", .serialized)
@MainActor
struct PostHogReactNativeMaskingTest {
Expand Down Expand Up @@ -175,6 +183,24 @@
#expect(sut.integration.collectMaskableRects(in: window) == [])
}

@Test("A full-window container whose derived label contains a ph-no-capture child is not masked whole")
func fullWindowContainerWithDerivedLabelIsNotMasked() {
let sut = makeSut(maskText: false, maskImages: false)
defer { teardown(sut) }

// RCTView answers `accessibilityLabel` with its descendants' labels joined
// (RCTRecursiveAccessibilityLabel), so the root container of a screen holding a
// PostHogMaskView reports the token too. Only the wrapper is masked.
let root = DerivedLabelContainerView(frame: CGRect(x: 0, y: 0, width: 320, height: 640))
let maskView = UIView(frame: CGRect(x: 24, y: 120, width: 200, height: 32))
maskView.accessibilityLabel = "ph-no-capture"
root.addSubview(maskView)
let window = makeWindow(containing: root)

#expect(root.accessibilityLabel == "ph-no-capture")
#expect(sut.integration.collectMaskableRects(in: window) == [maskView.frame])
}

@Test("nothing is masked with both flags off")
func nothingMaskedWithFlagsOff() {
let sut = makeSut(maskText: false, maskImages: false)
Expand Down
Loading