Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: screenshot masking #146

Merged
merged 8 commits into from
Jun 26, 2024
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Next

- recording: screenshot masking ([#146](https://github.com/PostHog/posthog-ios/pull/146))

## 3.5.2 - 2024-06-18

- chore: migrate UUID from v4 to v7 ([#145](https://github.com/PostHog/posthog-ios/pull/145))
Expand Down
4 changes: 3 additions & 1 deletion PostHog/PostHogQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,6 @@ class PostHogQueue {

func flush() {
if !canFlush() {
hedgeLog("Already flushing")
return
}

Expand Down Expand Up @@ -264,16 +263,19 @@ class PostHogQueue {

private func canFlush() -> Bool {
if isFlushing {
hedgeLog("Already flushing")
return false
}

if paused {
// We don't flush data if the queue is paused
hedgeLog("The queue is paused due to the reachability check")
return false
}

if pausedUntil != nil, pausedUntil! > Date() {
// We don't flush data if the queue is temporarily paused
hedgeLog("The queue is paused until `\(pausedUntil!)`")
return false
}

Expand Down
151 changes: 129 additions & 22 deletions PostHog/Replay/PostHogReplayIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
private let urlInterceptor: URLSessionInterceptor
private var sessionSwizzler: URLSessionSwizzler?

// SwiftUI image types
// https://stackoverflow.com/questions/57554590/how-to-get-all-the-subviews-of-a-window-or-view-in-latest-swiftui-app
// https://stackoverflow.com/questions/58336045/how-to-detect-swiftui-usage-programmatically-in-an-ios-application
private let swiftUIImageTypes = ["_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView",
"_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView",
"SwiftUI._UIGraphicsView",
"SwiftUI.ImageLayer"].compactMap { NSClassFromString($0) }

init(_ config: PostHogConfig) {
self.config = config
urlInterceptor = URLSessionInterceptor(self.config)
Expand Down Expand Up @@ -69,7 +77,7 @@
let timestamp = Date().toMillis()
let snapshotStatus = windowViews.object(forKey: view) ?? ViewTreeSnapshotStatus()

guard let wireframe = toWireframe(view) else {
guard let wireframe = config.sessionReplayConfig.screenshotMode ? toScreenshotWireframe(view) : toWireframe(view) else {
return
}

Expand Down Expand Up @@ -129,33 +137,133 @@
style.paddingLeft = Int(insets.left)
}

private func toWireframe(_ view: UIView, parentId: Int? = nil) -> RRWireframe? {
if !view.isVisible() {
return nil
}

private func createBasicWireframe(_ view: 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)
let style = RRStyle()

// no parent id means its the root
if parentId == nil, config.sessionReplayConfig.screenshotMode {
if let image = view.toImage() {
wireframe.base64 = imageToBase64(image)
return wireframe
}

private func findMaskableWidgets(_ view: UIView, _ parent: UIView, _ maskableWidgets: inout [CGRect]) {
if let textView = view as? UITextView {
if isTextViewSensitive(textView) {
maskableWidgets.append(view.toAbsoluteRect(parent))
return
}
}

if let textField = view as? UITextField {
if isTextFieldSensitive(textField) {
maskableWidgets.append(view.toAbsoluteRect(parent))
return
}
}

if let image = view as? UIImageView {
if isImageViewSensitive(image) {
maskableWidgets.append(view.toAbsoluteRect(parent))
return
}
}

if view is UILabel {
if isTextInputSensitive(view) {
maskableWidgets.append(view.toAbsoluteRect(parent))
return
}
}

if swiftUIImageTypes.contains(where: { view.isKind(of: $0) }) {
if isAnyInputSensitive(view) {
maskableWidgets.append(view.toAbsoluteRect(parent))
return
}
}

if !view.subviews.isEmpty {
for child in view.subviews {
if !child.isVisible() {
continue
}

findMaskableWidgets(child, parent, &maskableWidgets)
}
wireframe.type = "screenshot"
return wireframe
}
}

private func toScreenshotWireframe(_ view: UIView) -> RRWireframe? {
if !view.isVisible() {
return nil
}

var maskableWidgets: [CGRect] = []
findMaskableWidgets(view, view, &maskableWidgets)

let wireframe = createBasicWireframe(view)

if let image = view.toImage() {
let redactedImage = UIGraphicsImageRenderer(size: image.size, format: .init(for: .init(displayScale: 1))).image { context in
context.cgContext.interpolationQuality = .none
image.draw(at: .zero)

for rect in maskableWidgets {
let path = UIBezierPath(roundedRect: rect, cornerRadius: 10)
UIColor.black.setFill()
path.fill()
}
}
wireframe.base64 = imageToBase64(redactedImage)
Comment on lines +210 to +220
Copy link
Member Author

Choose a reason for hiding this comment

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

possible optimization for the future, this can be done async but requires changing a few things and adjusting timestamps, if performance is an issue, this is a place to look at.

Copy link
Member Author

Choose a reason for hiding this comment

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

I've started doing this optimization but led me to do other things that also required to be in the main thread so the changes didn't make much effect, most likely we'll need to pay the memory footprint price instead of processing the images right away, so we can batch the process in background threads but pay the price of holding a copy of the images in memory which can lead to OOM quite quickly as well.

}
wireframe.type = "screenshot"
return wireframe
}

private func isAssetsImage(_ image: UIImage) -> Bool {
// https://github.com/daydreamboy/lldb_scripts#9-pimage
image.imageAsset?.value(forKey: "_containingBundle") != nil
}

private func isAnyInputSensitive(_ view: UIView) -> Bool {
config.sessionReplayConfig.maskAllTextInputs || config.sessionReplayConfig.maskAllImages || view.isNoCapture()
}

private func isTextInputSensitive(_ view: UIView) -> Bool {
config.sessionReplayConfig.maskAllTextInputs || view.isNoCapture()
}

private func isTextViewSensitive(_ view: UITextView) -> Bool {
isTextInputSensitive(view) || view.isSensitiveText()
}

private func isTextFieldSensitive(_ view: UITextField) -> Bool {
isTextInputSensitive(view) || view.isSensitiveText()
}

private func isImageViewSensitive(_ view: UIImageView) -> Bool {
var isAsset = false
if let image = view.image {
isAsset = isAssetsImage(image)
}
return (config.sessionReplayConfig.maskAllImages && !isAsset) || view.isNoCapture()
}

private func toWireframe(_ view: UIView) -> RRWireframe? {
if !view.isVisible() {
return nil
}

let wireframe = createBasicWireframe(view)

let style = RRStyle()

if let textView = view as? UITextView {
wireframe.type = "text"
let isSensitive = config.sessionReplayConfig.maskAllTextInputs || textView.isNoCapture() || textView.isSensitiveText()
wireframe.text = isSensitive ? textView.text.mask() : textView.text
wireframe.text = isTextViewSensitive(textView) ? textView.text.mask() : textView.text
wireframe.disabled = !textView.isEditable
style.color = textView.textColor?.toRGBString()
style.fontFamily = textView.font?.familyName
Expand All @@ -169,12 +277,11 @@
if let textField = view as? UITextField {
wireframe.type = "input"
wireframe.inputType = "text_area"
let isSensitive = isTextFieldSensitive(textField)
if let text = textField.text {
let isSensitive = config.sessionReplayConfig.maskAllTextInputs || textField.isNoCapture() || textField.isSensitiveText()
wireframe.value = isSensitive ? text.mask() : text
} else {
if let text = textField.placeholder {
let isSensitive = config.sessionReplayConfig.maskAllTextInputs || textField.isNoCapture() || textField.isSensitiveText()
wireframe.value = isSensitive ? text.mask() : text
}
}
Expand All @@ -198,10 +305,10 @@
wireframe.checked = theSwitch.isOn
}

if let image = view as? UIImageView {
if let imageView = view as? UIImageView {
wireframe.type = "image"
if !image.isNoCapture(), !config.sessionReplayConfig.maskAllImages {
if let image = image.image {
if let image = imageView.image {
if !isImageViewSensitive(imageView) {
wireframe.base64 = imageToBase64(image)
}
}
Expand All @@ -220,7 +327,7 @@
if let label = view as? UILabel {
wireframe.type = "text"
if let text = label.text {
wireframe.text = (config.sessionReplayConfig.maskAllTextInputs || label.isNoCapture()) ? text.mask() : text
wireframe.text = isTextInputSensitive(view) ? text.mask() : text
}
wireframe.disabled = !label.isEnabled
style.color = label.textColor?.toRGBString()
Expand Down Expand Up @@ -264,7 +371,7 @@
if !view.subviews.isEmpty {
var childWireframes: [RRWireframe] = []
for subview in view.subviews {
if let child = toWireframe(subview, parentId: view.hash) {
if let child = toWireframe(subview) {
childWireframes.append(child)
}
}
Expand Down
17 changes: 15 additions & 2 deletions PostHog/Replay/UIView+Util.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,19 @@
}

func isNoCapture() -> Bool {
var isNoCapture = false
if let identifier = accessibilityIdentifier {
return identifier.lowercased().contains("ph-no-capture")
isNoCapture = checkLabel(identifier)
}
if let label = accessibilityLabel, !isNoCapture {
isNoCapture = checkLabel(label)
}

return isNoCapture
}

return false
private func checkLabel(_ label: String) -> Bool {
label.lowercased().contains("ph-no-capture")
}

func toImage() -> UIImage? {
Expand All @@ -44,5 +52,10 @@

return image
}

// you need this because of SwiftUI otherwise the coordinates always zeroed for some reason
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
func toAbsoluteRect(_ parent: UIView) -> CGRect {
convert(bounds, to: parent)
}
}
#endif
10 changes: 7 additions & 3 deletions PostHogExample/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,22 @@ class AppDelegate: NSObject, UIApplicationDelegate {
// the ScreenViews for SwiftUI does not work, the names are not useful
config.captureScreenViews = false
config.captureApplicationLifecycleEvents = false
config.flushAt = 1
config.flushIntervalSeconds = 30
// config.flushAt = 1
// config.flushIntervalSeconds = 30
config.debug = true
config.sendFeatureFlagEvent = false
config.sessionReplay = true
config.sessionReplayConfig.screenshotMode = true
config.sessionReplayConfig.maskAllTextInputs = true
config.sessionReplayConfig.maskAllImages = true

PostHogSDK.shared.setup(config)
PostHogSDK.shared.debug()
// PostHogSDK.shared.debug()
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
// PostHogSDK.shared.capture("App started!")
// PostHogSDK.shared.reset()

PostHogSDK.shared.identify("Manoel")

let defaultCenter = NotificationCenter.default

#if os(iOS) || os(tvOS)
Expand Down
2 changes: 1 addition & 1 deletion PostHogExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ struct ContentView: View {
Text(String(counter))
}.accessibilityIdentifier("ph-no-capture-id").accessibilityLabel("ph-no-capture")

TextField("Enter your name", text: $name)
TextField("Enter your name", text: $name).accessibilityLabel("ph-no-capture")
Text("Hello, \(name)!")
Button(action: triggerAuthentication) {
Text("Trigger fake authentication!")
Expand Down
9 changes: 5 additions & 4 deletions PostHogExampleStoryboard/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// the ScreenViews for SwiftUI does not work, the names are not useful
config.captureScreenViews = false
config.captureApplicationLifecycleEvents = false
config.flushAt = 1
config.flushIntervalSeconds = 30
// config.flushAt = 1
// config.flushIntervalSeconds = 30
config.debug = true
config.sendFeatureFlagEvent = false
config.sessionReplay = true
config.sessionReplayConfig.maskAllTextInputs = false
config.sessionReplayConfig.maskAllImages = false
config.sessionReplayConfig.maskAllTextInputs = true
config.sessionReplayConfig.screenshotMode = true
config.sessionReplayConfig.maskAllImages = true
config.sessionReplayConfig.captureNetworkTelemetry = true

PostHogSDK.shared.setup(config)
Expand Down
21 changes: 11 additions & 10 deletions PostHogExampleStoryboard/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,11 @@ class ViewController: UIViewController {
//
// self.view.addSubview(view)

let view = UIImageView(frame: CGRect(x: 50, y: 50, width: 100, height: 100))
let imageView = UIImageView(frame: CGRect(x: 5, y: 5, width: 100, height: 100))

imageView.accessibilityIdentifier = "ph-no-capture"
let url = URL(string: "https://1.bp.blogspot.com/-hkNkoCjc5UA/T4JTlCjhhfI/AAAAAAAAB98/XxQwZ-QPkI8/s1600/Free+Google+Wallpapers+3.jpg")!
// if let data = try? Data(contentsOf: url) {
// if let image = UIImage(data: data) {
// DispatchQueue.main.async {
// view.image = image
// }
// }
// }

let task = URLSession.shared.dataTask(with: url) { data, _, error in
if let error = error {
print("Error: \(error)")
Expand All @@ -41,12 +37,17 @@ class ViewController: UIViewController {
}

DispatchQueue.main.async {
view.image = image
imageView.image = image
}
}

task.resume()

self.view.addSubview(view)
view.addSubview(imageView)

let textView = UITextView(frame: CGRect(x: 5, y: 105, width: 100, height: 20))
textView.text = "test"

view.addSubview(textView)
}
}
2 changes: 1 addition & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ config.sessionReplayConfig.captureNetworkTelemetry = true
config.sessionReplayConfig.screenshotMode = true
```

If you don't want to mask everything, you can disable the mask config above and mask specific views using the `ph-no-capture` [accessibilityIdentifier](https://developer.apple.com/documentation/uikit/uiaccessibilityidentification/1623132-accessibilityidentifier).
If you don't want to mask everything, you can disable the mask config above and mask specific views using the `ph-no-capture` [accessibilityIdentifier](https://developer.apple.com/documentation/uikit/uiaccessibilityidentification/1623132-accessibilityidentifier) or [accessibilityLabel](https://developer.apple.com/documentation/uikit/uiaccessibilityelement/1619577-accessibilitylabel).

### Limitations

Expand Down
Loading