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

feat: replace photo library detection with human face detection #267

Closed
wants to merge 2 commits into from
Closed
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
## Next

- fix: remove photo library detection since it's not really possible ([#267](https://github.com/PostHog/posthog-ios/pull/267))
- feat: add human face detection in images ([#267](https://github.com/PostHog/posthog-ios/pull/267))

## 3.15.9 - 2024-11-28

- fix: skip capturing a snapshot during view controller transitions ([#265](https://github.com/PostHog/posthog-ios/pull/265))
Expand Down
4 changes: 4 additions & 0 deletions PostHog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
DAC699EC2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC699EB2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift */; };
DACF6D5D2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */; };
DAD5DD0C2CB6DEF30087387B /* PostHogMaskViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */; };
DAF42BC72CFB13DC00BCEB60 /* CGImage+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF42BC12CFB13D900BCEB60 /* CGImage+Util.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -401,6 +402,7 @@
DAC699EB2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForwardingPickerViewDelegate.swift; sourceTree = "<group>"; };
DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureIntegrationSpec.swift; sourceTree = "<group>"; };
DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogMaskViewModifier.swift; sourceTree = "<group>"; };
DAF42BC12CFB13D900BCEB60 /* CGImage+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImage+Util.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -725,6 +727,7 @@
69EE82B82BA9C4DA00EB9542 /* Replay */ = {
isa = PBXGroup;
children = (
DAF42BC12CFB13D900BCEB60 /* CGImage+Util.swift */,
69B7F6062CF7702D00A48BCC /* UIImage+Util.swift */,
69EE82B92BA9C50400EB9542 /* PostHogReplayIntegration.swift */,
69EE82BB2BA9C53000EB9542 /* PostHogSessionReplayConfig.swift */,
Expand Down Expand Up @@ -1195,6 +1198,7 @@
69261D1D2AD967CD00232EC7 /* PostHogFileBackedQueue.swift in Sources */,
3AE3FB432992985A00AFFC18 /* Reachability.swift in Sources */,
69F518122BAC783300F52C14 /* CGColor+Util.swift in Sources */,
DAF42BC72CFB13DC00BCEB60 /* CGImage+Util.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
34 changes: 34 additions & 0 deletions PostHog/Replay/CGImage+Util.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// CGImage+Util.swift
// PostHog
//
// Created by Yiannis Josephides on 30/11/2024.
//

#if os(iOS)
import UIKit

extension CGImage {
// This class can maintain many state variables that can impact performance. So for best performance, reuse CIDetector instances instead of creating new ones.
static var humanFaceDetector: CIDetector = {
let options = [CIDetectorAccuracy: CIDetectorAccuracyLow]
return CIDetector(ofType: CIDetectorTypeFace, context: nil, options: options)!
}()

private static var ph_human_face_detected_key: UInt8 = 0
var ph_human_face_detected: Bool? {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is where we mem-cache the face detection so we avoid detecting multiple times per resource

get {
objc_getAssociatedObject(self, &CGImage.ph_human_face_detected_key) as? Bool
}

set {
objc_setAssociatedObject(
self,
&CGImage.ph_human_face_detected_key,
newValue as Bool?,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
}
}
#endif
85 changes: 65 additions & 20 deletions PostHog/Replay/PostHogReplayIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -406,22 +406,41 @@
image.imageAsset?.value(forKey: "_containingBundle") != nil
}

// Photo library images have a UUID identifier as _assetName (e.g 64EF5A48-2E96-4AB2-A79B-AAB7E9116E3D)
// SF symbol and bundle images have the actual symbol name as _assetName (e.g chevron.backward)
private func isPhotoLibraryImage(_ image: UIImage) -> Bool {
guard config.sessionReplayConfig.maskPhotoLibraryImages else {
return false
private func isAnimatedImageView(_ view: UIImageView) -> Bool {
!(view.animationImages?.isEmpty ?? true)
}

private func containsHumanFaces(caLayer layer: CALayer) -> Bool {
if let content = layer.contents, CFGetTypeID(content as CFTypeRef) == CGImage.typeID {
// force-casting is safe here because of CFTypeID check above
return containsHumanFaces(cgImage: content as! CGImage)
}
return false
}

guard let assetName = image.imageAsset?.value(forKey: "_assetName") as? String else {
return false
private func containsHumanFaces(image: UIImage) -> Bool {
guard let cgImage = image.cgImage else { return false }
return containsHumanFaces(cgImage: cgImage)
}

private func containsHumanFaces(cgImage: CGImage) -> Bool {
let detectAndCache: () -> Bool = {
var faceDetected = false
let ciImage = CIImage(cgImage: cgImage)
faceDetected = !CGImage.humanFaceDetector.features(in: ciImage).isEmpty
cgImage.ph_human_face_detected = faceDetected
return faceDetected
}

if assetName.isEmpty { return false }
if image.isSymbolImage { return false }
if isAssetsImage(image) { return false }
return cgImage.ph_human_face_detected ?? detectAndCache()
}

return true
private func isSystemImageView(_ view: UIImageView) -> Bool {
// Came across some system image views like <_UICutoutShadowView> that were masked
// with maskAllImages options (e.g in this case this was a blurred background view of a UIPickerView)
// Let’s be prudent and add a general check on internal classes starting with _UI.
// We may need to be more explicit in the future
String(describing: view).starts(with: "<_UI")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure if we should be explicit here for "<_UICutoutShadowView>" but a general test for internal types here seemed like a good idea at the time

}

private func isAnyInputSensitive(_ view: UIView) -> Bool {
Expand Down Expand Up @@ -467,9 +486,17 @@
}

private func isSwiftUIImageSensitive(_ view: UIView) -> Bool {
// 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()
// sensitive, regardless
if view.isNoCapture() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Didn't refactor view.isNoCapture() yet to keep the scope low

return true
}

// detect human faces
if config.sessionReplayConfig.maskImagesWithHumanFaces, containsHumanFaces(caLayer: view.layer) {
return true
}

return config.sessionReplayConfig.maskAllImages
}

private func isImageViewSensitive(_ view: UIImageView) -> Bool {
Expand All @@ -481,13 +508,32 @@
return true
}

if config.sessionReplayConfig.maskAllImages {
// asset images are probably not sensitive
return !isAssetsImage(image)
// system images are likely not sensitive
if isSystemImageView(view) {
return false
}

// symbol images are probably not sensitive
if image.isSymbolImage {
return false
}

// an animated image view (e.g spinner view) is most likely not a sensitive asset
if isAnimatedImageView(view) {
Copy link
Contributor Author

@ioannisj ioannisj Nov 30, 2024

Choose a reason for hiding this comment

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

Came across system loaders in SwiftUI. But could also be gif support through UIImage.animatedImageNamed() constructor

return false
}

// try to detect user photo images
return isPhotoLibraryImage(image)
// detect human faces
if config.sessionReplayConfig.maskImagesWithHumanFaces, containsHumanFaces(image: image) {
return true
}

// asset images are probably not sensitive (unless they contain a face)
if isAssetsImage(image) {
return false
}

return config.sessionReplayConfig.maskAllImages
}

private func toWireframe(_ view: UIView) -> RRWireframe? {
Expand Down Expand Up @@ -662,7 +708,6 @@
private protocol AnyObjectUIHostingViewController: AnyObject {}

extension UIHostingController: AnyObjectUIHostingViewController {}

#endif

// swiftlint:enable cyclomatic_complexity
6 changes: 3 additions & 3 deletions PostHog/Replay/PostHogSessionReplayConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@
/// Default: true
@objc public var maskAllSandboxedViews: Bool = true

/// Enable masking of images that likely originated from user's photo library
/// Experimental support (UIKit only)
/// Enable masking of images that may contain human faces
/// Experimental support
/// Default: true
@objc public var maskPhotoLibraryImages: Bool = true
Copy link
Member

Choose a reason for hiding this comment

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

We cannot do this otherwise we will break people's builds on react native and iOS.
If needed we would need a major bump in the sdk version.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can leave and mark as deprecated for next major release? (and also turn default to false - won't have an impact but just to be clear)

@objc public var maskImagesWithHumanFaces: Bool = true

/// Enable capturing network telemetry
/// Experimental support
Expand Down
Loading