diff --git a/.changeset/eager-ships-hug.md b/.changeset/eager-ships-hug.md new file mode 100644 index 000000000..6abcf30c4 --- /dev/null +++ b/.changeset/eager-ships-hug.md @@ -0,0 +1,5 @@ +--- +"posthog-ios": minor +--- + +Add an experimental `sessionReplayConfig.screenshotScale` option that sets session replay screenshot resolution as a fraction of the screen's native resolution. Left unset, screenshots stay at one pixel per point. diff --git a/.changeset/tame-rules-roll.md b/.changeset/tame-rules-roll.md new file mode 100644 index 000000000..59dab4d08 --- /dev/null +++ b/.changeset/tame-rules-roll.md @@ -0,0 +1,5 @@ +--- +"posthog-ios": patch +--- + +Keep small text readable in downsized session replay screenshots by resampling them with smoothing instead of nearest-neighbour. diff --git a/PostHog/Replay/PostHogGraphicsImageRenderer.swift b/PostHog/Replay/PostHogGraphicsImageRenderer.swift index 1d0be85c1..32fad59a1 100644 --- a/PostHog/Replay/PostHogGraphicsImageRenderer.swift +++ b/PostHog/Replay/PostHogGraphicsImageRenderer.swift @@ -17,15 +17,24 @@ final class PostHogGraphicsImageRenderer { private let size: CGSize private let scale: CGFloat + private let roundingUp: Bool - init(size: CGSize, scale: CGFloat) { + /// `roundingUp` rounds the pixel size up instead of down, and stretches `size` over every pixel so none + /// is left undrawn. + init(size: CGSize, scale: CGFloat, roundingUp: Bool = false) { self.size = size self.scale = scale + self.roundingUp = roundingUp + } + + private func pixels(_ points: CGFloat) -> Int { + // The tolerance keeps float error, such as 100 × 0.30000000000000004, from adding a pixel. + roundingUp ? Int((points * scale - 0.001).rounded(.up)) : Int(points * scale) } func image(actions: (CGContext) -> Void) -> UIImage? { - let pixelsPerRow = Int(size.width * scale) - let pixelsPerColumn = Int(size.height * scale) + let pixelsPerRow = pixels(size.width) + let pixelsPerColumn = pixels(size.height) let bytesPerPixel = 4 let bytesPerRow = bytesPerPixel * pixelsPerRow let bitsPerComponent = 8 @@ -53,8 +62,10 @@ } // UIKit's coordinate system is flipped relative to CoreGraphics. - context.translateBy(x: 0, y: size.height * scale) - context.scaleBy(x: scale, y: -scale) + let scaleX = roundingUp ? CGFloat(pixelsPerRow) / size.width : scale + let scaleY = roundingUp ? CGFloat(pixelsPerColumn) / size.height : scale + context.translateBy(x: 0, y: size.height * scaleY) + context.scaleBy(x: scaleX, y: -scaleY) UIGraphicsPushContext(context) actions(context) diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index 376620984..1e845ad9d 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -1185,6 +1185,7 @@ let wireframe = createBasicWireframe(window) wireframe.maskableWidgets = maskableWidgets + wireframe.screenshotPixelScale = config?.sessionReplayConfig.screenshotPixelScale(nativeScale: window.screen.scale) ?? 1 wireframe.type = "screenshot" return wireframe } diff --git a/PostHog/Replay/PostHogSessionReplayConfig.swift b/PostHog/Replay/PostHogSessionReplayConfig.swift index 0da3a649c..0444d3177 100644 --- a/PostHog/Replay/PostHogSessionReplayConfig.swift +++ b/PostHog/Replay/PostHogSessionReplayConfig.swift @@ -60,6 +60,29 @@ /// Default: false @objc public var screenshotModeBackgroundCapture: Bool = false + /// Resolution of `screenshotMode` screenshots, as a multiplier of the screen's native resolution. + /// + /// - `nil`: one pixel per point on every device, so a 402×874 pt screen uploads 402×874 pixels. + /// - A value: that fraction of native resolution. On a 3x device, `1.0` uploads 1206×2622 pixels + /// (native) and `0.5` uploads 603×1311 (1.5 pixels per point). + /// + /// Values are clamped to 0.1...1.0, and NaN or infinite values reset it to `nil`. The replay's viewport in + /// points is unchanged. Does not affect wireframe mode or compression quality. Exposed as `NSNumber` so + /// Objective-C can leave it unset. + /// + /// Experimental. + /// Default: nil (one pixel per point) + @objc public var screenshotScale: NSNumber? { + didSet { + guard let value = screenshotScale?.doubleValue else { return } + screenshotScale = value.isFinite ? NSNumber(value: min(max(value, 0.1), 1)) : nil + } + } + + func screenshotPixelScale(nativeScale: CGFloat) -> CGFloat { + screenshotScale.map { nativeScale * CGFloat($0.doubleValue) } ?? 1 + } + /// Debouncer delay used to reduce the number of snapshots captured and reduce performance impact /// This is used for capturing the view as a wireframe or screenshot /// The lower the number more snapshots will be captured but higher the performance impact diff --git a/PostHog/Replay/RRWireframe.swift b/PostHog/Replay/RRWireframe.swift index 2feaac19c..ae6be4f81 100644 --- a/PostHog/Replay/RRWireframe.swift +++ b/PostHog/Replay/RRWireframe.swift @@ -25,6 +25,7 @@ class RRWireframe { #if os(iOS) var image: UIImage? var maskableWidgets: [CGRect]? + var screenshotPixelScale: CGFloat = 1 /// Set by `toDict()` when mask rects were collected but the redacted image could not /// be rendered. The caller must drop the frame: the wireframe carries no image, and /// the raw screenshot would show masked content. @@ -52,28 +53,42 @@ class RRWireframe { guard hasMaskableWidgets(), let image else { return nil } - return RRWireframe.maskImage(image, maskableWidgets: maskableWidgets ?? []) + return RRWireframe.maskImage(image, maskableWidgets: maskableWidgets ?? [], pixelScale: screenshotPixelScale) } // Shared so tests can redact through the exact production path instead of reimplementing it. - static func maskImage(_ image: UIImage, maskableWidgets: [CGRect]) -> UIImage? { + static func maskImage(_ image: UIImage, maskableWidgets: [CGRect], pixelScale: CGFloat = 1) -> UIImage? { guard !maskableWidgets.isEmpty else { return nil } - return redrawAtOnePixelPerPoint(image, maskableWidgets: maskableWidgets) + return autoreleasepool { + // Mask at native resolution so resampling can't blend what a mask covers into the output. + paintMasks(maskableWidgets, on: image).flatMap { resample($0, pixelScale: pixelScale) } + } } - /// Screenshots render at native scale on main; this downscale runs at encode time, off main, - /// so every screenshot uploads at the same size whether or not it is masked. - private static func redrawAtOnePixelPerPoint(_ image: UIImage, maskableWidgets: [CGRect] = []) -> UIImage? { - autoreleasepool { - let renderer = PostHogGraphicsImageRenderer(size: image.size, scale: 1) - return renderer.image { context in - context.interpolationQuality = .none - image.draw(at: .zero) - - for rect in maskableWidgets { - UIColor.black.setFill() - UIRectFill(rect) - } + /// Nothing to hide, so unlike masking a failed resample can fall back to the native image. + static func unmaskedScreenshot(_ image: UIImage, pixelScale: CGFloat) -> UIImage { + autoreleasepool { resample(image, pixelScale: pixelScale) ?? image } + } + + private static func resample(_ image: UIImage, pixelScale: CGFloat) -> UIImage? { + // Already at the upload resolution, so resampling would only cost time. + guard pixelScale < image.scale else { return image } + return PostHogGraphicsImageRenderer(size: image.size, scale: pixelScale, roundingUp: true).image { context in + // Nearest-neighbour drops thin glyph stems when downsampling. + context.interpolationQuality = .medium + image.draw(at: .zero) + } + } + + private static func paintMasks(_ masks: [CGRect], on image: UIImage) -> UIImage? { + PostHogGraphicsImageRenderer(size: image.size, scale: image.scale, roundingUp: true).image { context in + image.draw(at: .zero) + // Whole pixels, so a mask edge that falls mid-pixel can't leave part of it showing. + let toPixels = context.userSpaceToDeviceSpaceTransform + let toPoints = toPixels.inverted() + UIColor.black.setFill() + for rect in masks { + UIRectFill(rect.applying(toPixels).integral.applying(toPoints)) } } } @@ -115,8 +130,7 @@ class RRWireframe { maskRenderFailed = true } } else if type == "screenshot" { - // Nothing to hide, so unlike masking a failed redraw can fall back to the native image. - base64 = (RRWireframe.redrawAtOnePixelPerPoint(image) ?? image).toBase64() + base64 = RRWireframe.unmaskedScreenshot(image, pixelScale: screenshotPixelScale).toBase64() } else { base64 = image.toBase64() } diff --git a/PostHogTests/PostHogMaskFailClosedTest.swift b/PostHogTests/PostHogMaskFailClosedTest.swift index b4dd8b77c..113b95bbe 100644 --- a/PostHogTests/PostHogMaskFailClosedTest.swift +++ b/PostHogTests/PostHogMaskFailClosedTest.swift @@ -92,15 +92,15 @@ // MARK: - Mask render failure - /// An image whose pixel size rounds to zero, so `PostHogGraphicsImageRenderer` - /// returns nil exactly as it does when the full-frame malloc or the CGContext fails. + /// An image whose pixel size rounds to zero even when rounding up (1/10,000 pt), so + /// `PostHogGraphicsImageRenderer` returns nil exactly as it does when the full-frame malloc or the CGContext fails. private func makeUnrenderableImage() -> UIImage { let pixel = CGContext( data: nil, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue )! - return UIImage(cgImage: pixel.makeImage()!, scale: 8, orientation: .up) + return UIImage(cgImage: pixel.makeImage()!, scale: 10000, orientation: .up) } private func makeRenderableImage() -> UIImage { diff --git a/PostHogTests/PostHogReplayScreenshotSizeTest.swift b/PostHogTests/PostHogReplayScreenshotSizeTest.swift index e2afc71f5..4787baab5 100644 --- a/PostHogTests/PostHogReplayScreenshotSizeTest.swift +++ b/PostHogTests/PostHogReplayScreenshotSizeTest.swift @@ -23,14 +23,30 @@ return window } - private func uploaded(_ image: UIImage, masks: [CGRect]) throws -> CGImage { + private func uploaded(_ image: UIImage, masks: [CGRect], pixelScale: CGFloat = 1) throws -> CGImage { let wireframe = RRWireframe() wireframe.type = "screenshot" wireframe.image = image wireframe.maskableWidgets = masks + wireframe.screenshotPixelScale = pixelScale return try decode(wireframe) } + private func pixelScale(screenshotScale: NSNumber?, on window: UIWindow) -> CGFloat { + let config = PostHogSessionReplayConfig() + config.screenshotScale = screenshotScale + return config.screenshotPixelScale(nativeScale: window.screen.scale) + } + + private func solidImage(_ color: UIColor, size: CGSize, scale: CGFloat) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = scale + return UIGraphicsImageRenderer(size: size, format: format).image { context in + color.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } + } + private func decode(_ wireframe: RRWireframe) throws -> CGImage { let base64 = try #require(wireframe.toDict()["base64"] as? String) let encoded = try #require(base64.split(separator: ",").last.map(String.init)) @@ -118,5 +134,140 @@ #expect(try isWhite(rgb(masked, at: point)), "masked frame at \(point)") } } + + @Test("an unset screenshotScale uploads one pixel per point") + func nilScaleUploadsPointSize() throws { + let window = try window() + defer { window.isHidden = true } + let image = try #require(window.toImage(preferFidelityRenderer: false)) + let scale = pixelScale(screenshotScale: nil, on: window) + #expect(scale == 1) + #expect(try pixelSize(uploaded(image, masks: [], pixelScale: scale)) == window.bounds.size) + #expect(try pixelSize(uploaded(image, masks: [Self.secretRect], pixelScale: scale)) == window.bounds.size) + } + + @Test("screenshotScale uploads that fraction of native resolution, masked or not", arguments: [0.5, 1.0]) + func explicitScaleUploadsFractionOfNative(screenshotScale: Double) throws { + let window = try window() + defer { window.isHidden = true } + let image = try #require(window.toImage(preferFidelityRenderer: false)) + let scale = pixelScale(screenshotScale: NSNumber(value: screenshotScale), on: window) + #expect(scale == window.screen.scale * CGFloat(screenshotScale)) + + let expected = CGSize(width: (window.bounds.width * scale).rounded(.up), + height: (window.bounds.height * scale).rounded(.up)) + #expect(try pixelSize(uploaded(image, masks: [], pixelScale: scale)) == expected) + #expect(try pixelSize(uploaded(image, masks: [Self.secretRect], pixelScale: scale)) == expected) + } + + @Test("screenshotScale is clamped to 0.1...1, and NaN or infinity unset it", + arguments: [(0.7, 0.7), (0.01, 0.1), (-1, 0.1), (5, 1), (.nan, nil), (.infinity, nil), (-.infinity, nil)] as [(Double, Double?)]) + func screenshotScaleIsClamped(value: Double, expected: Double?) { + let config = PostHogSessionReplayConfig() + config.screenshotScale = NSNumber(value: value) + #expect(config.screenshotScale?.doubleValue == expected) + } + + @Test("a fractional output size rounds up and leaves no pixel undrawn") + func fractionalSizeRoundsUp() throws { + let image = solidImage(.white, size: CGSize(width: 201, height: 301), scale: 3) + let uploaded = try uploaded(image, masks: [], pixelScale: 1.5) + #expect(pixelSize(uploaded) == CGSize(width: 302, height: 452)) + #expect(try isWhite(rgb(uploaded, at: CGPoint(x: 301, y: 451)))) + } + + @Test("a mask lands on the secret's pixels at a non-1x scale") + func maskLandsAtNonUnitScale() throws { + let window = try window() + defer { window.isHidden = true } + let image = try #require(window.toImage(preferFidelityRenderer: false)) + let scale = pixelScale(screenshotScale: 0.5, on: window) + let unmasked = try uploaded(image, masks: [], pixelScale: scale) + let masked = try uploaded(image, masks: [Self.secretRect], pixelScale: scale) + + let rect = Self.secretRect + let inside = [CGPoint(x: rect.midX, y: rect.midY), CGPoint(x: rect.minX + 12, y: rect.minY + 12), + CGPoint(x: rect.maxX - 12, y: rect.maxY - 12)] + for point in inside { + let pixel = CGPoint(x: point.x * scale, y: point.y * scale) + #expect(try isRed(rgb(unmasked, at: pixel)), "unmasked frame at \(point)") + #expect(try isBlack(rgb(masked, at: pixel)), "masked frame at \(point)") + } + + let outside = [CGPoint(x: rect.minX - 8, y: rect.midY), CGPoint(x: rect.maxX + 8, y: rect.midY), + CGPoint(x: rect.midX, y: rect.maxY + 8)] + for point in outside { + let pixel = CGPoint(x: point.x * scale, y: point.y * scale) + #expect(try isWhite(rgb(masked, at: pixel)), "masked frame at \(point)") + } + } + + @Test("a mask edge that falls mid-pixel covers that whole native pixel") + func maskEdgeRoundsOutward() throws { + let image = solidImage(.red, size: CGSize(width: 200, height: 300), scale: 3) + // At 3 pixels per point these edges land on pixels 120.3 and 419.7. + let mask = CGRect(x: 40.1, y: 60, width: 99.8, height: 80) + let masked = try #require(RRWireframe.maskImage(image, maskableWidgets: [mask], pixelScale: 3)?.cgImage) + let row = CGFloat(300) + #expect(try isBlack(rgb(masked, at: CGPoint(x: 120, y: row)))) + #expect(try isBlack(rgb(masked, at: CGPoint(x: 419, y: row)))) + #expect(try isRed(rgb(masked, at: CGPoint(x: 119, y: row)))) + #expect(try isRed(rgb(masked, at: CGPoint(x: 420, y: row)))) + } + + @Test("an unmasked frame already at native resolution is not redrawn") + func nativeScaleSkipsRedraw() throws { + let window = try window() + defer { window.isHidden = true } + let image = try #require(window.toImage(preferFidelityRenderer: false)) + let native = pixelScale(screenshotScale: 1, on: window) + #expect(RRWireframe.unmaskedScreenshot(image, pixelScale: native) === image) + #expect(RRWireframe.unmaskedScreenshot(image, pixelScale: 1) !== image) + } + + @Test("downsampling keeps a one-pixel line whichever source pixel it falls on") + func downsamplingKeepsThinLines() throws { + let size = CGSize(width: 30, height: 30) + let format = UIGraphicsImageRendererFormat() + format.scale = 3 + // One-pixel lines at 3x, one in each phase of the 3:1 downsample: nearest-neighbour keeps only one. + let image = UIGraphicsImageRenderer(size: size, format: format).image { context in + UIColor.white.setFill() + context.fill(CGRect(origin: .zero, size: size)) + UIColor.black.setFill() + for (column, phase) in [(10, 0), (11, 1), (12, 2)] { + context.fill(CGRect(x: CGFloat(column * 3 + phase) / 3, y: 0, width: 1.0 / 3, height: size.height)) + } + } + let downsampled = try #require(RRWireframe.unmaskedScreenshot(image, pixelScale: 1).cgImage) + for column in 10 ... 12 { + let pixel = try rgb(downsampled, at: CGPoint(x: column, y: 15)) + #expect(!isWhite(pixel), "column \(column) lost its line: \(pixel)") + } + } + + @Test("no pixel of a resized masked frame shows any of the secret", arguments: [1.0, 1.5], [0.0, 1.0 / 3, 2.0 / 3]) + func maskedSecretDoesNotBleed(pixelScale: CGFloat, offset: CGFloat) throws { + let size = CGSize(width: 200, height: 300) + let secret = Self.secretRect.offsetBy(dx: offset, dy: offset) + let format = UIGraphicsImageRendererFormat() + format.scale = 3 + let image = UIGraphicsImageRenderer(size: size, format: format).image { context in + UIColor.white.setFill() + context.fill(CGRect(origin: .zero, size: size)) + UIColor.red.setFill() + context.fill(secret) + } + let masked = try #require(RRWireframe.maskImage(image, maskableWidgets: [secret], pixelScale: pixelScale)?.cgImage) + var rgba = [UInt8](repeating: 0, count: masked.width * masked.height * 4) + let context = try #require(CGContext( + data: &rgba, width: masked.width, height: masked.height, bitsPerComponent: 8, bytesPerRow: masked.width * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.draw(masked, in: CGRect(x: 0, y: 0, width: masked.width, height: masked.height)) + // Black and white keep every channel equal; only the secret adds red. + let tinted = stride(from: 0, to: rgba.count, by: 4).filter { Int(rgba[$0]) - Int(max(rgba[$0 + 1], rgba[$0 + 2])) > 2 } + #expect(tinted.isEmpty, "\(tinted.count) pixels show the secret") + } } #endif diff --git a/PostHogTests/__MaskSnapshots__/case_1.png b/PostHogTests/__MaskSnapshots__/case_1.png index f653590d6..7d5ade314 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_1.png and b/PostHogTests/__MaskSnapshots__/case_1.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_10.png b/PostHogTests/__MaskSnapshots__/case_10.png index 1f9c035d5..1462d6883 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_10.png and b/PostHogTests/__MaskSnapshots__/case_10.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_11.png b/PostHogTests/__MaskSnapshots__/case_11.png index 96aadc4c8..2b7c5a3ec 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_11.png and b/PostHogTests/__MaskSnapshots__/case_11.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_12.png b/PostHogTests/__MaskSnapshots__/case_12.png index 5e8308ae4..8c26a3b0a 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_12.png and b/PostHogTests/__MaskSnapshots__/case_12.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_13.png b/PostHogTests/__MaskSnapshots__/case_13.png index 2125888f3..b8c2982ae 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_13.png and b/PostHogTests/__MaskSnapshots__/case_13.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_14.png b/PostHogTests/__MaskSnapshots__/case_14.png index ace11fe3a..c038a682c 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_14.png and b/PostHogTests/__MaskSnapshots__/case_14.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_17.png b/PostHogTests/__MaskSnapshots__/case_17.png index d6ee48040..7c1a58f20 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_17.png and b/PostHogTests/__MaskSnapshots__/case_17.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_2.png b/PostHogTests/__MaskSnapshots__/case_2.png index b500bd2b0..3c771bcd4 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_2.png and b/PostHogTests/__MaskSnapshots__/case_2.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_20.png b/PostHogTests/__MaskSnapshots__/case_20.png index 1e39b80bd..e666f77da 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_20.png and b/PostHogTests/__MaskSnapshots__/case_20.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_21.png b/PostHogTests/__MaskSnapshots__/case_21.png index 73663518c..d5b6db601 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_21.png and b/PostHogTests/__MaskSnapshots__/case_21.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_22.png b/PostHogTests/__MaskSnapshots__/case_22.png index b86925ead..6a9f6a008 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_22.png and b/PostHogTests/__MaskSnapshots__/case_22.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_23.png b/PostHogTests/__MaskSnapshots__/case_23.png index 96c9e752c..737c0bb34 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_23.png and b/PostHogTests/__MaskSnapshots__/case_23.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_24.png b/PostHogTests/__MaskSnapshots__/case_24.png index d8bb38b15..c72bd0ec4 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_24.png and b/PostHogTests/__MaskSnapshots__/case_24.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_25.png b/PostHogTests/__MaskSnapshots__/case_25.png index bb9efe63f..4fcd9f333 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_25.png and b/PostHogTests/__MaskSnapshots__/case_25.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_26.png b/PostHogTests/__MaskSnapshots__/case_26.png index fb03101d5..6d28cbb3d 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_26.png and b/PostHogTests/__MaskSnapshots__/case_26.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_27.png b/PostHogTests/__MaskSnapshots__/case_27.png index 545d0f1e8..51761d1c1 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_27.png and b/PostHogTests/__MaskSnapshots__/case_27.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_28.png b/PostHogTests/__MaskSnapshots__/case_28.png index 44b874ab5..0a52a2bdd 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_28.png and b/PostHogTests/__MaskSnapshots__/case_28.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_3.png b/PostHogTests/__MaskSnapshots__/case_3.png index d2713073e..328b1f355 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_3.png and b/PostHogTests/__MaskSnapshots__/case_3.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_4.png b/PostHogTests/__MaskSnapshots__/case_4.png index b59c97b35..dad4e83f0 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_4.png and b/PostHogTests/__MaskSnapshots__/case_4.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_5.png b/PostHogTests/__MaskSnapshots__/case_5.png index 4507cad8f..75a1eb28d 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_5.png and b/PostHogTests/__MaskSnapshots__/case_5.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_6.png b/PostHogTests/__MaskSnapshots__/case_6.png index 1528582a9..8e28e09c3 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_6.png and b/PostHogTests/__MaskSnapshots__/case_6.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_7.png b/PostHogTests/__MaskSnapshots__/case_7.png index db0e74cd6..479649621 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_7.png and b/PostHogTests/__MaskSnapshots__/case_7.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_8.png b/PostHogTests/__MaskSnapshots__/case_8.png index 9bc731909..5b6fe862e 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_8.png and b/PostHogTests/__MaskSnapshots__/case_8.png differ diff --git a/PostHogTests/__MaskSnapshots__/case_9.png b/PostHogTests/__MaskSnapshots__/case_9.png index c80f09035..0a83ca99e 100644 Binary files a/PostHogTests/__MaskSnapshots__/case_9.png and b/PostHogTests/__MaskSnapshots__/case_9.png differ diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index e68f2522b..a56b8a97a 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -384,6 +384,7 @@ PostHog | PostHogSessionReplayConfig.maskPhotoLibraryImages | property | @objc v PostHog | PostHogSessionReplayConfig.sampleRate | property | @objc var sampleRate: NSNumber? { get set } | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)sampleRate PostHog | PostHogSessionReplayConfig.screenshotMode | property | @objc var screenshotMode: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)screenshotMode PostHog | PostHogSessionReplayConfig.screenshotModeBackgroundCapture | property | @objc var screenshotModeBackgroundCapture: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)screenshotModeBackgroundCapture +PostHog | PostHogSessionReplayConfig.screenshotScale | property | @objc var screenshotScale: NSNumber? { get set } | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)screenshotScale PostHog | PostHogSessionReplayConfig.throttleDelay | property | @objc var throttleDelay: TimeInterval | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)throttleDelay PostHog | PostHogSessionReplayConsoleLogConfig | class | @objc class PostHogSessionReplayConsoleLogConfig | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig PostHog | PostHogSessionReplayConsoleLogConfig.logSanitizer | property | @objc var logSanitizer: (String) -> PostHogLogEntry? | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig(py)logSanitizer