diff --git a/.changeset/mask-full-window-no-capture-view.md b/.changeset/mask-full-window-no-capture-view.md new file mode 100644 index 000000000..9e82f3135 --- /dev/null +++ b/.changeset/mask-full-window-no-capture-view.md @@ -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. diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index efe8c25af..376620984 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -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 + if maskDescendants { + maskableWidgets.append(.init(view, in: window)) } findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskDescendants) @@ -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 } @@ -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) diff --git a/PostHogTests/PostHogMaskFailClosedTest.swift b/PostHogTests/PostHogMaskFailClosedTest.swift index b9b90f91c..b4dd8b77c 100644 --- a/PostHogTests/PostHogMaskFailClosedTest.swift +++ b/PostHogTests/PostHogMaskFailClosedTest.swift @@ -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]) diff --git a/PostHogTests/PostHogMaskingCharacterizationTest.swift b/PostHogTests/PostHogMaskingCharacterizationTest.swift index 45ab9ecce..a7f8511cd 100644 --- a/PostHogTests/PostHogMaskingCharacterizationTest.swift +++ b/PostHogTests/PostHogMaskingCharacterizationTest.swift @@ -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") diff --git a/PostHogTests/PostHogReactNativeMaskingTest.swift b/PostHogTests/PostHogReactNativeMaskingTest.swift index 310e6ada2..e1b95083f 100644 --- a/PostHogTests/PostHogReactNativeMaskingTest.swift +++ b/PostHogTests/PostHogReactNativeMaskingTest.swift @@ -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 { @@ -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)