Skip to content

Commit

Permalink
fix: detect and mask SwiftUI Text vs Image (#257)
Browse files Browse the repository at this point in the history
* fix: detect and mask SwiftUI Text respecting maskAllTextInputs

* chore: update CHANGELOG

* fix: changelog
  • Loading branch information
ioannisj authored Nov 19, 2024
1 parent dbaf857 commit 769f762
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 21 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Next

- fix: properly mask SwiftUI Text (and text-based views) ([#257](https://github.com/PostHog/posthog-ios/pull/257))

## 3.15.4 - 2024-11-19

- fix: avoid zero touch locations ([#256](https://github.com/PostHog/posthog-ios/pull/256))
Expand Down
102 changes: 81 additions & 21 deletions PostHog/Replay/PostHogReplayIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,64 @@
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 = ["SwiftUI._UIGraphicsView",
"SwiftUI.ImageLayer"].compactMap { NSClassFromString($0) }

private let swiftUIGenericTypes = ["_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView",
"_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView"].compactMap { NSClassFromString($0) }
/**
### Mapping of SwiftUI Views to UIKit

This section summarizes findings on how SwiftUI views map to UIKit components

#### Image-Based Views
- **`AsyncImage` and `Image`**
- Both views have a `CALayer` of type `SwiftUI.ImageLayer`.
- The associated `UIView` is of type `SwiftUI._UIGraphicsView`.

#### Graphic-based Views
- **`Color`, `Divider`, `Gradient` etc
- These are backed by `SwiftUI._UIGraphicsView` but have a different layer type than images

#### Text-Based Views
- **`Text`, `Button`, and `TextEditor`**
- These views are backed by a `UIView` of type `SwiftUI.CGDrawingView`, which is a subclass of `SwiftUI._UIGraphicsView`.
- CoreGraphics (`CG`) is used for rendering text content directly, making it challenging to access the value programmatically.

#### UIKit-Mapped Views
- **Views Hosted by `UIViewRepresentable`**
- Some SwiftUI views map directly to UIKit classes or to a subclass:
- **Control Images** (e.g., in `Picker` drop-downs) may map to `UIImageView`.
- **Buttons** map to `SwiftUI.UIKitIconPreferringButton` (a subclass of `UIButton`).
- **Toggle** maps to `UISwitch` (the toggle itself, excluding its label).
- **Picker** with wheel style maps to `UIPickerView`. Other styles use combinations of image-based and text-based views.

#### Layout and Structure Views
- **`Spacer`, `VStack`, `HStack`, `ZStack`, and Lazy Stacks**
- These views do not correspond to specific a `UIView`. Instead, they translate directly into layout constraints.

#### List-Based Views
- **`List` and Scrollable Container Views**
- Backed by a subclass of `UICollectionView`

#### Other SwiftUI Views
- Most other SwiftUI views are *compositions* of the views described above

SwiftUI Image Types:
- [StackOverflow: Subviews of a Window or View in SwiftUI](https://stackoverflow.com/questions/57554590/how-to-get-all-the-subviews-of-a-window-or-view-in-latest-swiftui-app)
- [StackOverflow: Detect SwiftUI Usage Programmatically](https://stackoverflow.com/questions/58336045/how-to-detect-swiftui-usage-programmatically-in-an-ios-application)
*/

/// `AsyncImage` and `Image`
private let swiftUIImageLayerTypes = [
"SwiftUI.ImageLayer",
].compactMap(NSClassFromString)

/// `Text`, `Button`, `TextEditor` views
private let swiftUITextBasedViewTypes = [
"SwiftUI.CGDrawingView", // Text, Button
"SwiftUI.TextEditorTextView", // TextEditor
"SwiftUI.VerticalTextView", // TextField, vertical axis
].compactMap(NSClassFromString)

private let swiftUIGenericTypes = [
"_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView",
].compactMap(NSClassFromString)

private let reactNativeTextView: AnyClass? = NSClassFromString("RCTTextView")
private let reactNativeImageView: AnyClass? = NSClassFromString("RCTImageView")
Expand Down Expand Up @@ -171,7 +221,8 @@
}
}

if let textField = view as? UITextField { // TextField
/// SwiftUI: `TextField`, `SecureField` will land here
if let textField = view as? UITextField {
if isTextFieldSensitive(textField) {
maskableWidgets.append(view.toAbsoluteRect(window))
return
Expand All @@ -185,7 +236,8 @@
}
}

if let image = view as? UIImageView { // Image, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead
/// SwiftUI: Some control images like the ones in `Picker` view may land here
if let image = view as? UIImageView {
if isImageViewSensitive(image) {
maskableWidgets.append(view.toAbsoluteRect(window))
return
Expand Down Expand Up @@ -215,14 +267,16 @@
}
}

if let button = view as? UIButton { // Button, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead
/// SwiftUI: `SwiftUI.UIKitIconPreferringButton` and other subclasses will land here
if let button = view as? UIButton {
if isButtonSensitive(button) {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}

if let theSwitch = view as? UISwitch { // Toggle (no text, items are just rendered to Text (swiftUIImageTypes))
/// SwiftUI: `Toggle` (no text, labels are just rendered to Text (swiftUIImageTypes))
if let theSwitch = view as? UISwitch {
if isSwitchSensitive(theSwitch) {
maskableWidgets.append(view.toAbsoluteRect(window))
return
Expand All @@ -232,14 +286,24 @@
// if its a generic type and has subviews, subviews have to be checked first
let hasSubViews = !view.subviews.isEmpty

if let picker = view as? UIPickerView { // Picker (no source, items are just rendered to Text (swiftUIImageTypes))
/// SwiftUI: `Picker` with .pickerStyle(.wheel) will land here
if let picker = view as? UIPickerView {
if isTextInputSensitive(picker), !hasSubViews {
maskableWidgets.append(picker.toAbsoluteRect(window))
return
}
}

if swiftUIImageTypes.contains(where: { view.isKind(of: $0) }) {
/// SwiftUI: Text based views like `Text`, `Button`, `TextEditor`
if swiftUITextBasedViewTypes.contains(where: view.isKind(of:)) {
if isTextInputSensitive(view), !hasSubViews {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}

/// SwiftUI: Image based views like `Image`, `AsyncImage`. (Note: We check the layer type here)
if swiftUIImageLayerTypes.contains(where: view.layer.isKind(of:)) {
if isSwiftUIImageSensitive(view), !hasSubViews {
maskableWidgets.append(view.toAbsoluteRect(window))
return
Expand Down Expand Up @@ -359,13 +423,9 @@
}

private func isSwiftUIImageSensitive(_ view: UIView) -> Bool {
// the raw type _UIGraphicsView is always something like Color.white or similar
// never contains PII and should not be masked
// Button will fall in this case case but Button has subviews
let type = type(of: view)

let rawGraphicsView = String(describing: type) == "_UIGraphicsView"
return (config.sessionReplayConfig.maskAllImages || view.isNoCapture()) && !rawGraphicsView
// No way of checking if this is an asset image or not
// No way of checking if there's actual content in the image or not
config.sessionReplayConfig.maskAllImages || view.isNoCapture()
}

private func isImageViewSensitive(_ view: UIImageView) -> Bool {
Expand Down
12 changes: 12 additions & 0 deletions PostHogExample/Assets.xcassets/max_static.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "max_static.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions PostHogExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,34 @@ struct ContentView: View {
}
.postHogMask()

HStack {
Spacer()
VStack {
Text("Remote Image")
AsyncImage(
url: URL(string: "https://res.cloudinary.com/dmukukwp6/image/upload/v1710055416/posthog.com/contents/images/media/social-media-headers/hogs/professor_hog.png"),
content: { image in
image
.renderingMode(.original)
.resizable()
.aspectRatio(contentMode: .fit)
},
placeholder: {
Color.gray
}
)
.frame(width: 60, height: 60)
}
Spacer()
VStack {
Text("Static Image")
Image(.maxStatic)
.resizable()
.frame(width: 60, height: 60)
}
Spacer()
}

Button("Show Sheet") {
showingSheet.toggle()
}
Expand Down

0 comments on commit 769f762

Please sign in to comment.