Skip to content

Commit 4ff3064

Browse files
ioannisjeakurnikov
authored andcommitted
fix: detect and mask SwiftUI Text vs Image (PostHog#257)
* fix: detect and mask SwiftUI Text respecting maskAllTextInputs * chore: update CHANGELOG * fix: changelog
1 parent 979c5ab commit 4ff3064

File tree

5 files changed

+123
-21
lines changed

5 files changed

+123
-21
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
## Next
22

3+
- fix: properly mask SwiftUI Text (and text-based views) ([#257](https://github.com/PostHog/posthog-ios/pull/257))
4+
35
## 3.15.4 - 2024-11-19
46

57
- fix: avoid zero touch locations ([#256](https://github.com/PostHog/posthog-ios/pull/256))

PostHog/Replay/PostHogReplayIntegration.swift

+81-21
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,64 @@
2121
private let urlInterceptor: URLSessionInterceptor
2222
private var sessionSwizzler: URLSessionSwizzler?
2323

24-
// SwiftUI image types
25-
// https://stackoverflow.com/questions/57554590/how-to-get-all-the-subviews-of-a-window-or-view-in-latest-swiftui-app
26-
// https://stackoverflow.com/questions/58336045/how-to-detect-swiftui-usage-programmatically-in-an-ios-application
27-
private let swiftUIImageTypes = ["SwiftUI._UIGraphicsView",
28-
"SwiftUI.ImageLayer"].compactMap { NSClassFromString($0) }
29-
30-
private let swiftUIGenericTypes = ["_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView",
31-
"_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView"].compactMap { NSClassFromString($0) }
24+
/**
25+
### Mapping of SwiftUI Views to UIKit
26+
27+
This section summarizes findings on how SwiftUI views map to UIKit components
28+
29+
#### Image-Based Views
30+
- **`AsyncImage` and `Image`**
31+
- Both views have a `CALayer` of type `SwiftUI.ImageLayer`.
32+
- The associated `UIView` is of type `SwiftUI._UIGraphicsView`.
33+
34+
#### Graphic-based Views
35+
- **`Color`, `Divider`, `Gradient` etc
36+
- These are backed by `SwiftUI._UIGraphicsView` but have a different layer type than images
37+
38+
#### Text-Based Views
39+
- **`Text`, `Button`, and `TextEditor`**
40+
- These views are backed by a `UIView` of type `SwiftUI.CGDrawingView`, which is a subclass of `SwiftUI._UIGraphicsView`.
41+
- CoreGraphics (`CG`) is used for rendering text content directly, making it challenging to access the value programmatically.
42+
43+
#### UIKit-Mapped Views
44+
- **Views Hosted by `UIViewRepresentable`**
45+
- Some SwiftUI views map directly to UIKit classes or to a subclass:
46+
- **Control Images** (e.g., in `Picker` drop-downs) may map to `UIImageView`.
47+
- **Buttons** map to `SwiftUI.UIKitIconPreferringButton` (a subclass of `UIButton`).
48+
- **Toggle** maps to `UISwitch` (the toggle itself, excluding its label).
49+
- **Picker** with wheel style maps to `UIPickerView`. Other styles use combinations of image-based and text-based views.
50+
51+
#### Layout and Structure Views
52+
- **`Spacer`, `VStack`, `HStack`, `ZStack`, and Lazy Stacks**
53+
- These views do not correspond to specific a `UIView`. Instead, they translate directly into layout constraints.
54+
55+
#### List-Based Views
56+
- **`List` and Scrollable Container Views**
57+
- Backed by a subclass of `UICollectionView`
58+
59+
#### Other SwiftUI Views
60+
- Most other SwiftUI views are *compositions* of the views described above
61+
62+
SwiftUI Image Types:
63+
- [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)
64+
- [StackOverflow: Detect SwiftUI Usage Programmatically](https://stackoverflow.com/questions/58336045/how-to-detect-swiftui-usage-programmatically-in-an-ios-application)
65+
*/
66+
67+
/// `AsyncImage` and `Image`
68+
private let swiftUIImageLayerTypes = [
69+
"SwiftUI.ImageLayer",
70+
].compactMap(NSClassFromString)
71+
72+
/// `Text`, `Button`, `TextEditor` views
73+
private let swiftUITextBasedViewTypes = [
74+
"SwiftUI.CGDrawingView", // Text, Button
75+
"SwiftUI.TextEditorTextView", // TextEditor
76+
"SwiftUI.VerticalTextView", // TextField, vertical axis
77+
].compactMap(NSClassFromString)
78+
79+
private let swiftUIGenericTypes = [
80+
"_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView",
81+
].compactMap(NSClassFromString)
3282

3383
private let reactNativeTextView: AnyClass? = NSClassFromString("RCTTextView")
3484
private let reactNativeImageView: AnyClass? = NSClassFromString("RCTImageView")
@@ -171,7 +221,8 @@
171221
}
172222
}
173223

174-
if let textField = view as? UITextField { // TextField
224+
/// SwiftUI: `TextField`, `SecureField` will land here
225+
if let textField = view as? UITextField {
175226
if isTextFieldSensitive(textField) {
176227
maskableWidgets.append(view.toAbsoluteRect(window))
177228
return
@@ -185,7 +236,8 @@
185236
}
186237
}
187238

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

218-
if let button = view as? UIButton { // Button, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead
270+
/// SwiftUI: `SwiftUI.UIKitIconPreferringButton` and other subclasses will land here
271+
if let button = view as? UIButton {
219272
if isButtonSensitive(button) {
220273
maskableWidgets.append(view.toAbsoluteRect(window))
221274
return
222275
}
223276
}
224277

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

235-
if let picker = view as? UIPickerView { // Picker (no source, items are just rendered to Text (swiftUIImageTypes))
289+
/// SwiftUI: `Picker` with .pickerStyle(.wheel) will land here
290+
if let picker = view as? UIPickerView {
236291
if isTextInputSensitive(picker), !hasSubViews {
237292
maskableWidgets.append(picker.toAbsoluteRect(window))
238293
return
239294
}
240295
}
241296

242-
if swiftUIImageTypes.contains(where: { view.isKind(of: $0) }) {
297+
/// SwiftUI: Text based views like `Text`, `Button`, `TextEditor`
298+
if swiftUITextBasedViewTypes.contains(where: view.isKind(of:)) {
299+
if isTextInputSensitive(view), !hasSubViews {
300+
maskableWidgets.append(view.toAbsoluteRect(window))
301+
return
302+
}
303+
}
304+
305+
/// SwiftUI: Image based views like `Image`, `AsyncImage`. (Note: We check the layer type here)
306+
if swiftUIImageLayerTypes.contains(where: view.layer.isKind(of:)) {
243307
if isSwiftUIImageSensitive(view), !hasSubViews {
244308
maskableWidgets.append(view.toAbsoluteRect(window))
245309
return
@@ -359,13 +423,9 @@
359423
}
360424

361425
private func isSwiftUIImageSensitive(_ view: UIView) -> Bool {
362-
// the raw type _UIGraphicsView is always something like Color.white or similar
363-
// never contains PII and should not be masked
364-
// Button will fall in this case case but Button has subviews
365-
let type = type(of: view)
366-
367-
let rawGraphicsView = String(describing: type) == "_UIGraphicsView"
368-
return (config.sessionReplayConfig.maskAllImages || view.isNoCapture()) && !rawGraphicsView
426+
// No way of checking if this is an asset image or not
427+
// No way of checking if there's actual content in the image or not
428+
config.sessionReplayConfig.maskAllImages || view.isNoCapture()
369429
}
370430

371431
private func isImageViewSensitive(_ view: UIImageView) -> Bool {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "max_static.png",
5+
"idiom" : "universal"
6+
}
7+
],
8+
"info" : {
9+
"author" : "xcode",
10+
"version" : 1
11+
}
12+
}
Loading

PostHogExample/ContentView.swift

+28
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,34 @@ struct ContentView: View {
100100
}
101101
.postHogMask()
102102

103+
HStack {
104+
Spacer()
105+
VStack {
106+
Text("Remote Image")
107+
AsyncImage(
108+
url: URL(string: "https://res.cloudinary.com/dmukukwp6/image/upload/v1710055416/posthog.com/contents/images/media/social-media-headers/hogs/professor_hog.png"),
109+
content: { image in
110+
image
111+
.renderingMode(.original)
112+
.resizable()
113+
.aspectRatio(contentMode: .fit)
114+
},
115+
placeholder: {
116+
Color.gray
117+
}
118+
)
119+
.frame(width: 60, height: 60)
120+
}
121+
Spacer()
122+
VStack {
123+
Text("Static Image")
124+
Image(.maxStatic)
125+
.resizable()
126+
.frame(width: 60, height: 60)
127+
}
128+
Spacer()
129+
}
130+
103131
Button("Show Sheet") {
104132
showingSheet.toggle()
105133
}

0 commit comments

Comments
 (0)