diff --git a/CHANGELOG.md b/CHANGELOG.md index 43672d77f8..d77b4f4ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ -# Unreleased +# 2.22.1 / 30-01-2025 + +- [FIX] Fix memory leak in Session Replay where privacy overrides retained UIViews. See [#2182][] + +# 2.22.0 / 02-01-2025 - [IMPROVEMENT] Add Datadog Configuration `backgroundTasksEnabled` ObjC API. See [#2148][] - [FIX] Prevent Session Replay to create two full snapshots in a row. See [#2154][] @@ -811,6 +815,7 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO [#2126]: https://github.com/DataDog/dd-sdk-ios/pull/2126 [#2148]: https://github.com/DataDog/dd-sdk-ios/pull/2148 [#2154]: https://github.com/DataDog/dd-sdk-ios/pull/2154 +[#2182]: https://github.com/DataDog/dd-sdk-ios/pull/2182 [@00fa9a]: https://github.com/00FA9A [@britton-earnin]: https://github.com/Britton-Earnin [@hengyu]: https://github.com/Hengyu diff --git a/DatadogAlamofireExtension.podspec b/DatadogAlamofireExtension.podspec index c725e4ff30..e8f516a254 100644 --- a/DatadogAlamofireExtension.podspec +++ b/DatadogAlamofireExtension.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogAlamofireExtension" - s.version = "2.22.0" + s.version = "2.22.1" s.summary = "An Official Extensions of Datadog Swift SDK for Alamofire." s.description = <<-DESC The DatadogAlamofireExtension pod is deprecated and will no longer be maintained. diff --git a/DatadogCore.podspec b/DatadogCore.podspec index 3c66db090d..ac158ce7ae 100644 --- a/DatadogCore.podspec +++ b/DatadogCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogCore" - s.version = "2.22.0" + s.version = "2.22.1" s.summary = "Official Datadog Swift SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogCore/Sources/Versioning.swift b/DatadogCore/Sources/Versioning.swift index c8b72a2e4e..63900ac612 100644 --- a/DatadogCore/Sources/Versioning.swift +++ b/DatadogCore/Sources/Versioning.swift @@ -1,3 +1,3 @@ // GENERATED FILE: Do not edit directly -internal let __sdkVersion = "2.22.0" +internal let __sdkVersion = "2.22.1" diff --git a/DatadogCrashReporting.podspec b/DatadogCrashReporting.podspec index 04c17f9a56..7ac81023b9 100644 --- a/DatadogCrashReporting.podspec +++ b/DatadogCrashReporting.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogCrashReporting" - s.version = "2.22.0" + s.version = "2.22.1" s.summary = "Official Datadog Crash Reporting SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogInternal.podspec b/DatadogInternal.podspec index 96a22a4b81..f274275605 100644 --- a/DatadogInternal.podspec +++ b/DatadogInternal.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogInternal" - s.version = "2.22.0" + s.version = "2.22.1" s.summary = "Datadog Internal Package. This module is not for public use." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogLogs.podspec b/DatadogLogs.podspec index 7eed5cf060..71613dd5d7 100644 --- a/DatadogLogs.podspec +++ b/DatadogLogs.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogLogs" - s.version = "2.22.0" + s.version = "2.22.1" s.summary = "Datadog Logs Module." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogObjc.podspec b/DatadogObjc.podspec index ff9323effe..b492569b6d 100644 --- a/DatadogObjc.podspec +++ b/DatadogObjc.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogObjc" - s.version = "2.22.0" + s.version = "2.22.1" s.summary = "Official Datadog Objective-C SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogRUM.podspec b/DatadogRUM.podspec index 4992c12dec..812e5105a5 100644 --- a/DatadogRUM.podspec +++ b/DatadogRUM.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogRUM" - s.version = "2.22.0" + s.version = "2.22.1" s.summary = "Datadog Real User Monitoring Module." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogSessionReplay.podspec b/DatadogSessionReplay.podspec index bf7f60f4f6..9bebf6d432 100644 --- a/DatadogSessionReplay.podspec +++ b/DatadogSessionReplay.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogSessionReplay" - s.version = "2.22.0" + s.version = "2.22.1" s.summary = "Official Datadog Session Replay SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift index c91c7718ab..70e964aef9 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift @@ -45,8 +45,9 @@ internal class SnapshotTestCase: XCTestCase { return viewController } + // swiftlint:disable function_default_parameter_at_end /// Helper method for most snapshot tests - func takeSnapshotFor( // swiftlint:disable:this function_default_parameter_at_end + func takeSnapshotFor( _ fixture: Fixture, with textAndInputPrivacyLevels: [TextAndInputPrivacyLevel] = [defaultTextAndInputPrivacyLevel], imagePrivacyLevel: ImagePrivacyLevel = defaultImagePrivacyLevel, @@ -73,6 +74,7 @@ internal class SnapshotTestCase: XCTestCase { ) } } + // swiftlint:enable function_default_parameter_at_end /// Helper method for date and time picker snapshot tests func takeSnapshotForPicker( @@ -100,8 +102,9 @@ internal class SnapshotTestCase: XCTestCase { } } + // swiftlint:disable function_default_parameter_at_end /// Helper method for snapshot tests showing PopupsViewController - func takeSnapshotForPopup( // swiftlint:disable:this function_default_parameter_at_end + func takeSnapshotForPopup( fixture: Fixture, showPopup: (PopupsViewController) -> Void, waitTime: TimeInterval, @@ -124,6 +127,7 @@ internal class SnapshotTestCase: XCTestCase { ) } } + // swiftlint:enable function_default_parameter_at_end /// Captures side-by-side snapshot of the app UI and recorded wireframes. func takeSnapshot( diff --git a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift index e377965d4d..8039420152 100644 --- a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift +++ b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift @@ -107,7 +107,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle var view: UIView? = initialView while view != nil { - if let touchPrivacy = view?.dd.sessionReplayPrivacyOverrides.touchPrivacy { + if let touchPrivacy = view?.dd._privacyOverrides?.touchPrivacy { return touchPrivacy } view = view?.superview diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift index bf83ee4814..d086d2b355 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift @@ -40,7 +40,7 @@ internal class UIViewRecorder: NodeRecorder { return semantics } - if attributes.overrides.hide == true { + if attributes.hide == true { let builder = UIViewWireframesBuilder( wireframeID: context.ids.nodeID(view: view, nodeRecorder: self), attributes: attributes @@ -75,7 +75,7 @@ internal struct UIViewWireframesBuilder: NodeWireframesBuilder { } func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { - if attributes.overrides.hide == true { + if attributes.hide == true { return [ builder.createPlaceholderWireframe( id: wireframeID, diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift index 8a6d046cf2..d462a456ac 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift @@ -17,7 +17,7 @@ internal struct ViewTreeRecorder { /// Creates `Nodes` for given view and its subtree hierarchy. func record(_ anyView: UIView, in context: ViewTreeRecordingContext) -> [Node] { var nodes: [Node] = [] - recordRecursively(nodes: &nodes, view: anyView, context: context, overrides: anyView.dd.sessionReplayPrivacyOverrides) + recordRecursively(nodes: &nodes, view: anyView, context: context, overrides: anyView.dd._privacyOverrides) return nodes } @@ -27,7 +27,7 @@ internal struct ViewTreeRecorder { nodes: inout [Node], view: UIView, context: ViewTreeRecordingContext, - overrides: PrivacyOverrides + overrides: PrivacyOverrides? ) { var context = context if let viewController = view.next as? UIViewController { @@ -56,7 +56,7 @@ internal struct ViewTreeRecorder { switch semantics.subtreeStrategy { case .record: for subview in view.subviews { - let subviewOverrides = SessionReplayPrivacyOverrides.merge(subview.dd.sessionReplayPrivacyOverrides, with: overrides) + let subviewOverrides = SessionReplayPrivacyOverrides.merge(subview.dd._privacyOverrides, with: overrides) recordRecursively(nodes: &nodes, view: subview, context: context, overrides: subviewOverrides) } case .ignore: diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift index da987e50c9..1de18fffb4 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift @@ -100,8 +100,12 @@ public struct SessionReplayViewAttributes: Equatable { /// Original view's `.intrinsicContentSize`. var intrinsicContentSize: CGSize - /// If the view has privacy overrides, which take precedence over global masking privacy levels. - var overrides: PrivacyOverrides + /// Values copied from privacy overrides, if the view has privacy overrides, + /// which take precedence over global masking privacy levels. + var textAndInputPrivacy: TextAndInputPrivacyLevel? + var imagePrivacy: ImagePrivacyLevel? + var touchPrivacy: TouchPrivacyLevel? + var hide: Bool? } // This alias enables us to have a more unique name exposed through public-internal access level @@ -114,7 +118,7 @@ extension ViewAttributes { /// - view: The view instance. /// - frame: The view frame in root view's coordinate space. /// - clip: The clipping frame in root view's coordinate space. - init(view: UIView, frame: CGRect, clip: CGRect, overrides: SessionReplayPrivacyOverrides) { + init(view: UIView, frame: CGRect, clip: CGRect, overrides: SessionReplayPrivacyOverrides?) { self.frame = frame self.clip = clip self.backgroundColor = view.backgroundColor?.cgColor.safeCast @@ -124,7 +128,10 @@ extension ViewAttributes { self.alpha = view.alpha self.isHidden = view.isHidden self.intrinsicContentSize = view.intrinsicContentSize - self.overrides = overrides + self.textAndInputPrivacy = overrides?.textAndInputPrivacy + self.imagePrivacy = overrides?.imagePrivacy + self.touchPrivacy = overrides?.touchPrivacy + self.hide = overrides?.hide } /// If the view is technically visible (different than `!isHidden` because it also considers `alpha` and `frame != .zero`). @@ -165,13 +172,13 @@ extension ViewAttributes { /// Resolves the effective privacy level for text and input elements by considering the view's local override. /// Falls back to the global privacy setting in the absence of local overrides. func resolveTextAndInputPrivacyLevel(in context: ViewTreeRecordingContext) -> TextAndInputPrivacyLevel { - return self.overrides.textAndInputPrivacy ?? context.recorder.textAndInputPrivacy + return self.textAndInputPrivacy ?? context.recorder.textAndInputPrivacy } /// Resolves the effective privacy level for image elements by considering the view's local override. /// Falls back to the global privacy setting in the absence of local overrides. func resolveImagePrivacyLevel(in context: ViewTreeRecordingContext) -> ImagePrivacyLevel { - return self.overrides.imagePrivacy ?? context.recorder.imagePrivacy + return self.imagePrivacy ?? context.recorder.imagePrivacy } } diff --git a/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift b/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift index 6e4b949a2a..ca1d14988f 100644 --- a/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift +++ b/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift @@ -8,6 +8,10 @@ import UIKit import DatadogInternal +// MARK: - Associated Keys + +internal var associatedOverridesKey: UInt8 = 3 + // MARK: - DatadogExtension for UIView /// Extension to provide access to `SessionReplayPrivacyOverrides` for any `UIView`. @@ -15,73 +19,45 @@ extension DatadogExtension where ExtendedType: UIView { /// Provides access to Session Replay override settings for the view. /// Usage: `myView.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy = .maskNone`. public var sessionReplayPrivacyOverrides: SessionReplayPrivacyOverrides { - return SessionReplayPrivacyOverrides(self.type) - } -} + if let overrides = _privacyOverrides { + return overrides + } -// MARK: - Associated Keys + let overrides = SessionReplayPrivacyOverrides() + objc_setAssociatedObject(type, &associatedOverridesKey, overrides, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return overrides + } -private var associatedTextAndInputPrivacyKey: UInt8 = 3 -private var associatedImagePrivacyKey: UInt8 = 4 -private var associatedTouchPrivacyKey: UInt8 = 5 -private var associatedHiddenPrivacyKey: UInt8 = 6 + /// Internal accessor + internal var _privacyOverrides: SessionReplayPrivacyOverrides? { + objc_getAssociatedObject(type, &associatedOverridesKey) as? SessionReplayPrivacyOverrides + } +} // MARK: - SessionReplayPrivacyOverrides /// `UIView` extension to manage the Session Replay privacy override settings. public final class SessionReplayPrivacyOverrides { - internal let view: UIView - - public init(_ view: UIView) { - self.view = view - } - /// Text and input privacy override (e.g., mask or unmask specific text fields, labels, etc.). - public var textAndInputPrivacy: TextAndInputPrivacyLevel? { - get { - return objc_getAssociatedObject(view, &associatedTextAndInputPrivacyKey) as? TextAndInputPrivacyLevel - } - set { - objc_setAssociatedObject(view, &associatedTextAndInputPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) - } - } - + public var textAndInputPrivacy: TextAndInputPrivacyLevel? /// Image privacy override (e.g., mask or unmask specific images). - public var imagePrivacy: ImagePrivacyLevel? { - get { - return objc_getAssociatedObject(view, &associatedImagePrivacyKey) as? ImagePrivacyLevel - } - set { - objc_setAssociatedObject(view, &associatedImagePrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) - } - } - + public var imagePrivacy: ImagePrivacyLevel? /// Touch privacy override (e.g., hide or show touch interactions on specific views). - public var touchPrivacy: TouchPrivacyLevel? { - get { - return objc_getAssociatedObject(view, &associatedTouchPrivacyKey) as? TouchPrivacyLevel - } - set { - objc_setAssociatedObject(view, &associatedTouchPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) - } - } - + public var touchPrivacy: TouchPrivacyLevel? /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). - public var hide: Bool? { - get { - return objc_getAssociatedObject(view, &associatedHiddenPrivacyKey) as? Bool - } - set { - objc_setAssociatedObject(view, &associatedHiddenPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) - } + public var hide: Bool? + + /// Creates a new instance of privacy overrides. + internal init() { + // Initialize with all properties as nil } } // MARK: - Equatable + extension PrivacyOverrides: Equatable { public static func == (lhs: SessionReplayPrivacyOverrides, rhs: SessionReplayPrivacyOverrides) -> Bool { - return lhs.view === rhs.view - && lhs.textAndInputPrivacy == rhs.textAndInputPrivacy + return lhs.textAndInputPrivacy == rhs.textAndInputPrivacy && lhs.imagePrivacy == rhs.imagePrivacy && lhs.touchPrivacy == rhs.touchPrivacy && lhs.hide == rhs.hide @@ -89,24 +65,31 @@ extension PrivacyOverrides: Equatable { } // MARK: - Merge + extension PrivacyOverrides { /// Merges child and parent overrides, giving precedence to the child’s overrides, if set. /// If the child has no overrides set, it inherits its parent’s overrides. - internal static func merge(_ child: PrivacyOverrides, with parent: PrivacyOverrides) -> PrivacyOverrides { - let merged = child + internal static func merge(_ child: PrivacyOverrides?, with parent: PrivacyOverrides?) -> PrivacyOverrides? { + guard let child = child else { + return parent + } + guard let parent = parent else { + return child + } + + // Apply parent overrides where child has none set + child.textAndInputPrivacy = child.textAndInputPrivacy ?? parent.textAndInputPrivacy + child.imagePrivacy = child.imagePrivacy ?? parent.imagePrivacy + child.touchPrivacy = child.touchPrivacy ?? parent.touchPrivacy - // Apply child overrides if present - merged.textAndInputPrivacy = merged.textAndInputPrivacy ?? parent.textAndInputPrivacy - merged.imagePrivacy = merged.imagePrivacy ?? parent.imagePrivacy - merged.touchPrivacy = merged.touchPrivacy ?? parent.touchPrivacy /// `hide` is a boolean, so we explicitly check if either the parent or the child has it set to `true`. - /// `false` and `nil` behave the same way, it deactivates the `hide` override. + /// `false` and `nil` behave the same way, it deactivates the `hide` override. /// In practice, this check should not hit, as parent views with `hide = true` should ignore their children. - if merged.hide == true || parent.hide == true { - merged.hide = true + if child.hide == true || parent.hide == true { + child.hide = true } - return merged + return child } } diff --git a/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift b/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift index b91cfe65b2..2db5b1061f 100644 --- a/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift +++ b/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift @@ -7,8 +7,6 @@ #if os(iOS) import UIKit -private var associatedSROverrideKey: UInt8 = 0 - // MARK: UIView extension /// Objective-C accessible extension for UIView @objc @@ -30,7 +28,12 @@ public final class objc_SessionReplayPrivacyOverrides: NSObject { @objc public init(view: UIView) { - _swift = PrivacyOverrides(view) + if let existing = view.dd._privacyOverrides { + _swift = existing + } else { + _swift = SessionReplayPrivacyOverrides() + objc_setAssociatedObject(view, &associatedOverridesKey, _swift, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } super.init() } diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index 9b00a39ef2..e0690dd3c5 100644 --- a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift @@ -68,7 +68,10 @@ extension ViewAttributes: AnyMockable, RandomMockable { alpha: .mockRandom(min: 0, max: 1), isHidden: .mockRandom(), intrinsicContentSize: .mockRandom(), - overrides: .mockAny() + textAndInputPrivacy: .mockRandom(), + imagePrivacy: .mockRandom(), + touchPrivacy: .mockRandom(), + hide: .mockRandom() ) } @@ -95,7 +98,10 @@ extension ViewAttributes: AnyMockable, RandomMockable { alpha: alpha, isHidden: isHidden, intrinsicContentSize: intrinsicContentSize, - overrides: overrides + textAndInputPrivacy: overrides.textAndInputPrivacy, + imagePrivacy: overrides.imagePrivacy, + touchPrivacy: overrides.touchPrivacy, + hide: overrides.hide ) } @@ -181,7 +187,10 @@ extension ViewAttributes: AnyMockable, RandomMockable { alpha: alpha, isHidden: isHidden, intrinsicContentSize: frame.size, - overrides: .mockAny() + textAndInputPrivacy: nil, + imagePrivacy: nil, + touchPrivacy: nil, + hide: nil ) // consistency check: @@ -620,7 +629,7 @@ extension PrivacyOverrides: AnyMockable, RandomMockable { touchPrivacy: TouchPrivacyLevel? = nil, hide: Bool? = nil ) -> PrivacyOverrides { - let override = PrivacyOverrides(UIView.mockRandom()) + let override = PrivacyOverrides() override.textAndInputPrivacy = textAndInputPrivacy override.imagePrivacy = imagePrivacy override.touchPrivacy = touchPrivacy diff --git a/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift b/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift index d437f071da..73d82b4df9 100644 --- a/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift +++ b/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift @@ -437,6 +437,43 @@ class SnapshotProcessorTests: XCTestCase { XCTAssertEqual(core.recordsCountByViewID, ["abc": 4]) } + func testViewRetentionInBackgroundProcessing() { + weak var weakView: UIView? + + autoreleasepool { + let view = UIView() + weakView = view + view.dd.sessionReplayPrivacyOverrides.imagePrivacy = .maskAll + + let time = Date() + let rum: RUMContext = .mockWith(serverTimeOffset: 0) + + // Given + let core = PassthroughCoreMock() + let srContextPublisher = SRContextPublisher(core: core) + let processor = SnapshotProcessor( + queue: NoQueue(), + recordWriter: recordWriter, + resourceProcessor: ResourceProcessorSpy(), + srContextPublisher: srContextPublisher, + telemetry: TelemetryMock() + ) + + // When + let snapshot = generateViewTreeSnapshot(for: view, date: time, rumContext: rum) + processor.process(viewTreeSnapshot: snapshot, touchSnapshot: nil) + + // Then + XCTAssertEqual(recordWriter.records.count, 1) + + // View should still exist here + XCTAssertNotNil(weakView) + } + + // View should be deallocated even though snapshot was processed in background + XCTAssertNil(weakView) + } + // MARK: - `ViewTreeSnapshot` generation private let snapshotBuilder = ViewTreeSnapshotBuilder(additionalNodeRecorders: [], featureFlags: .allEnabled) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift index fdbc812800..29bfe906be 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift @@ -90,7 +90,7 @@ class UILabelRecorderTests: XCTestCase { // Given label.text = .mockRandom() viewAttributes = .mock(fixture: .visible()) - viewAttributes.overrides = .mockWith(textAndInputPrivacy: .maskAll) + viewAttributes.textAndInputPrivacy = .maskAll // When let semantics = try XCTUnwrap(recorder.semantics(of: label, with: viewAttributes, in: .mockAny()) as? SpecificElement) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift index db93eca76c..5a8dad8577 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift @@ -58,7 +58,7 @@ class UISegmentRecorderTests: XCTestCase { func testWhenSegmentHasTextPrivacyOverride() throws { // Given viewAttributes = .mock(fixture: .visible()) - viewAttributes.overrides = .mockWith(textAndInputPrivacy: .maskAll) + viewAttributes.textAndInputPrivacy = .maskAll // When let semantics = try XCTUnwrap(recorder.semantics(of: segment, with: viewAttributes, in: .mockAny()) as? SpecificElement) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift index 8402ec5a61..a2e01837b7 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift @@ -112,7 +112,7 @@ class UITextFieldRecorderTests: XCTestCase { // Given textField.text = .mockRandom() viewAttributes = .mock(fixture: .visible()) - viewAttributes.overrides = .mockWith(textAndInputPrivacy: .maskAll) + viewAttributes.textAndInputPrivacy = .maskAll // When let semantics = try XCTUnwrap(recorder.semantics(of: textField, with: viewAttributes, in: .mockAny()) as? SpecificElement) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift index 1e3e70ecc7..52523e43ea 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift @@ -104,7 +104,7 @@ class UITextViewRecorderTests: XCTestCase { textView.text = .mockRandom() textView.isEditable = false viewAttributes = .mock(fixture: .visible()) - viewAttributes.overrides = .mockWith(textAndInputPrivacy: .maskAll) + viewAttributes.textAndInputPrivacy = .maskAll // When let semantics = try XCTUnwrap(recorder.semantics(of: textView, with: viewAttributes, in: .mockAny()) as? SpecificElement) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift index 814802b420..01c8fe9268 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift @@ -61,7 +61,7 @@ class UIViewRecorderTests: XCTestCase { func testWhenViewHasHiddenOverride() throws { // Given viewAttributes = .mock(fixture: .visible(.someAppearance)) - viewAttributes.overrides = .mockWith(hide: true) + viewAttributes.hide = true // When let semantics = try XCTUnwrap(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift index 28c468c7ad..2b4c6e559b 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift @@ -367,7 +367,7 @@ class ViewTreeRecorderTests: XCTestCase { XCTAssertEqual(nodes.count, 1) let node = nodes.first XCTAssertNotNil(node) - XCTAssertEqual(node?.viewAttributes.overrides.imagePrivacy, viewImagePrivacy) + XCTAssertEqual(node?.viewAttributes.imagePrivacy, viewImagePrivacy) let builder = node?.wireframesBuilder as? UIImageViewWireframesBuilder XCTAssertNotNil(builder) XCTAssertNotNil(builder!.imageResource) @@ -390,8 +390,8 @@ class ViewTreeRecorderTests: XCTestCase { // Then XCTAssertEqual(nodes.count, 2) - XCTAssertEqual(nodes[0].viewAttributes.overrides.imagePrivacy, parentImagePrivacy) - XCTAssertEqual(nodes[1].viewAttributes.overrides.imagePrivacy, childImagePrivacy) + XCTAssertEqual(nodes[0].viewAttributes.imagePrivacy, parentImagePrivacy) + XCTAssertEqual(nodes[1].viewAttributes.imagePrivacy, childImagePrivacy) let builder = nodes[1].wireframesBuilder as? UIImageViewWireframesBuilder XCTAssertNotNil(builder) XCTAssertNotNil(builder!.imageResource) @@ -414,8 +414,8 @@ class ViewTreeRecorderTests: XCTestCase { // Then XCTAssertEqual(nodes.count, 2) - XCTAssertEqual(nodes[0].viewAttributes.overrides.imagePrivacy, parentImagePrivacy) - XCTAssertEqual(nodes[1].viewAttributes.overrides.imagePrivacy, childImagePrivacy) + XCTAssertEqual(nodes[0].viewAttributes.imagePrivacy, parentImagePrivacy) + XCTAssertEqual(nodes[1].viewAttributes.imagePrivacy, childImagePrivacy) let builder = nodes[1].wireframesBuilder as? UIImageViewWireframesBuilder XCTAssertNotNil(builder) XCTAssertNil(builder!.imageResource) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift index 2a76b118e1..b9becb519a 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift @@ -30,10 +30,10 @@ class ViewAttributesTests: XCTestCase { XCTAssertEqual(attributes.alpha, view.alpha) XCTAssertEqual(attributes.isHidden, view.isHidden) XCTAssertEqual(attributes.intrinsicContentSize, view.intrinsicContentSize) - XCTAssertNil(attributes.overrides.textAndInputPrivacy) - XCTAssertNil(attributes.overrides.imagePrivacy) - XCTAssertNil(attributes.overrides.touchPrivacy) - XCTAssertNil(attributes.overrides.hide) + XCTAssertNil(attributes.textAndInputPrivacy) + XCTAssertNil(attributes.imagePrivacy) + XCTAssertNil(attributes.touchPrivacy) + XCTAssertNil(attributes.hide) } func testWhenViewIsVisible() { @@ -169,10 +169,10 @@ class ViewAttributesTests: XCTestCase { let attributes = createViewAttributes(with: view) // Then - XCTAssertNil(attributes.overrides.textAndInputPrivacy) - XCTAssertNil(attributes.overrides.imagePrivacy) - XCTAssertNil(attributes.overrides.touchPrivacy) - XCTAssertNil(attributes.overrides.hide) + XCTAssertNil(attributes.textAndInputPrivacy) + XCTAssertNil(attributes.imagePrivacy) + XCTAssertNil(attributes.touchPrivacy) + XCTAssertNil(attributes.hide) } func testChildViewInheritsParentHideOverride() { diff --git a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift index b33b02a791..eca9ea0d88 100644 --- a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift @@ -11,7 +11,7 @@ import UIKit @testable import DatadogSessionReplay class SessionReplayPrivacyOverridesTests: XCTestCase { - // MARK: Setting overrides + // MARK: Setting Overrides func testWhenNoOverrideIsSet_itDefaultsToNil() { // Given let view = UIView() @@ -21,6 +21,12 @@ class SessionReplayPrivacyOverridesTests: XCTestCase { XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.imagePrivacy) XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.touchPrivacy) XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.hide) + + XCTAssertNotNil(view.dd._privacyOverrides) + XCTAssertNil(view.dd._privacyOverrides?.textAndInputPrivacy) + XCTAssertNil(view.dd._privacyOverrides?.imagePrivacy) + XCTAssertNil(view.dd._privacyOverrides?.touchPrivacy) + XCTAssertNil(view.dd._privacyOverrides?.hide) } func testWithOverrides() { @@ -38,6 +44,12 @@ class SessionReplayPrivacyOverridesTests: XCTestCase { XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.imagePrivacy, .maskAll) XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.touchPrivacy, .hide) XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.hide, true) + + XCTAssertNotNil(view.dd._privacyOverrides) + XCTAssertEqual(view.dd._privacyOverrides?.textAndInputPrivacy, .maskAllInputs) + XCTAssertEqual(view.dd._privacyOverrides?.imagePrivacy, .maskAll) + XCTAssertEqual(view.dd._privacyOverrides?.touchPrivacy, .hide) + XCTAssertEqual(view.dd._privacyOverrides?.hide, true) } func testRemovingOverrides() { @@ -59,9 +71,15 @@ class SessionReplayPrivacyOverridesTests: XCTestCase { XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.imagePrivacy) XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.touchPrivacy) XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.hide) + + XCTAssertNotNil(view.dd._privacyOverrides) + XCTAssertNil(view.dd._privacyOverrides?.textAndInputPrivacy) + XCTAssertNil(view.dd._privacyOverrides?.imagePrivacy) + XCTAssertNil(view.dd._privacyOverrides?.touchPrivacy) + XCTAssertNil(view.dd._privacyOverrides?.hide) } - // MARK: Privacy overrides taking precedence over global settings + // MARK: Privacy Overrides taking precedence over global settings func testTextOverrideTakesPrecedenceOverGlobalTextPrivacy() { // Given let textAndInputOverride: TextAndInputPrivacyLevel = .mockRandom() @@ -116,7 +134,8 @@ class SessionReplayPrivacyOverridesTests: XCTestCase { XCTAssertEqual(resolvedImagePrivacy, globalImagePrivacy) } - func testMergeParentAndChildOverrides() { + // MARK: Privacy Overrides Merge + func testMergeParentAndChildOverrides() throws { // Given let overrides: PrivacyOverrides = .mockRandom() @@ -132,7 +151,7 @@ class SessionReplayPrivacyOverridesTests: XCTestCase { parentOverrides.touchPrivacy = overrides.touchPrivacy // When - let merged = SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides) + let merged = try XCTUnwrap(SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides)) // Then XCTAssertEqual(merged.textAndInputPrivacy, overrides.textAndInputPrivacy) @@ -141,13 +160,13 @@ class SessionReplayPrivacyOverridesTests: XCTestCase { XCTAssertEqual(merged.touchPrivacy, overrides.touchPrivacy) } - func testMergeWithNilParentOverrides() { + func testMergeWithNilParentOverrides() throws { // Given let childOverrides: PrivacyOverrides = .mockRandom() let parentOverrides: PrivacyOverrides = .mockAny() // When - let merged = SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides) + let merged = try XCTUnwrap(SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides)) // Then XCTAssertEqual(merged.textAndInputPrivacy, childOverrides.textAndInputPrivacy) @@ -156,7 +175,7 @@ class SessionReplayPrivacyOverridesTests: XCTestCase { XCTAssertEqual(merged.hide, childOverrides.hide) } - func testMergeWithNilChildOverrides() { + func testMergeWithNilChildOverrides() throws { // Given let childOverrides: PrivacyOverrides = .mockAny() let parentOverrides: PrivacyOverrides = .mockRandom() @@ -165,8 +184,7 @@ class SessionReplayPrivacyOverridesTests: XCTestCase { parentOverrides.hide = true // When - let merged = SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides) - + let merged = try XCTUnwrap(SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides)) // Then XCTAssertEqual(merged.textAndInputPrivacy, parentOverrides.textAndInputPrivacy) XCTAssertEqual(merged.imagePrivacy, parentOverrides.imagePrivacy) @@ -174,7 +192,7 @@ class SessionReplayPrivacyOverridesTests: XCTestCase { XCTAssertEqual(merged.hide, parentOverrides.hide) } - func testMergeWhenChildHideOverrideIsNotNilAndParentHideOverrideIsTrue() { + func testMergeWhenChildHideOverrideIsNotNilAndParentHideOverrideIsTrue() throws { // Given let childOverrides: PrivacyOverrides = .mockRandom() childOverrides.hide = false @@ -182,7 +200,7 @@ class SessionReplayPrivacyOverridesTests: XCTestCase { parentOverrides.hide = true // When - let merged = SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides) + let merged = try XCTUnwrap(SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides)) // Then XCTAssertEqual(merged.textAndInputPrivacy, childOverrides.textAndInputPrivacy) @@ -190,5 +208,57 @@ class SessionReplayPrivacyOverridesTests: XCTestCase { XCTAssertEqual(merged.touchPrivacy, childOverrides.touchPrivacy) XCTAssertEqual(merged.hide, true) } + + func testMergeOptimizationWhenNeitherHasOverrides() throws { + // Given + let childOverrides: PrivacyOverrides = .mockAny() + let parentOverrides: PrivacyOverrides = .mockAny() + + // When + let merged = try XCTUnwrap(SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides)) + + // Then + XCTAssertNil(merged.textAndInputPrivacy) + XCTAssertNil(merged.imagePrivacy) + XCTAssertNil(merged.touchPrivacy) + XCTAssertNil(merged.hide) + } + + func testViewDeallocatesCorrectly() throws { + // Weak reference acting as an observer to the target object view + weak var weakView: UIView? + let randomValues: PrivacyOverrides = .mockRandom() + + try autoreleasepool { + // Strong reference to the view + let view = UIView() + // Weak reference to the view + weakView = view + view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy = randomValues.textAndInputPrivacy + view.dd.sessionReplayPrivacyOverrides.imagePrivacy = randomValues.imagePrivacy + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = randomValues.touchPrivacy + view.dd.sessionReplayPrivacyOverrides.hide = randomValues.hide + + // Captures overrides values without retaining the view + let attributes = ViewAttributes( + view: view, + frame: view.frame, + clip: view.frame, + overrides: view.dd.sessionReplayPrivacyOverrides + ) + + // Check attributes are captured and not optimized away + XCTAssertEqual(attributes.textAndInputPrivacy, randomValues.textAndInputPrivacy) + XCTAssertEqual(attributes.imagePrivacy, randomValues.imagePrivacy) + XCTAssertEqual(attributes.touchPrivacy, randomValues.touchPrivacy) + XCTAssertEqual(attributes.hide, randomValues.hide) + // View still exists + XCTAssertNotNil(weakView) + } + + // View has been deallocxated + XCTAssertNil(weakView, "View should be deallocated") + } } + #endif diff --git a/DatadogTrace.podspec b/DatadogTrace.podspec index 9a305bc092..9d0f73e306 100644 --- a/DatadogTrace.podspec +++ b/DatadogTrace.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogTrace" - s.version = "2.22.0" + s.version = "2.22.1" s.summary = "Datadog Trace Module." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogWebViewTracking.podspec b/DatadogWebViewTracking.podspec index 5b0300d6d3..c1f2c558ef 100644 --- a/DatadogWebViewTracking.podspec +++ b/DatadogWebViewTracking.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogWebViewTracking" - s.version = "2.22.0" + s.version = "2.22.1" s.summary = "Datadog WebView Tracking Module." s.homepage = "https://www.datadoghq.com" diff --git a/TestUtilities.podspec b/TestUtilities.podspec index 3dd077d80a..6bfbff2988 100644 --- a/TestUtilities.podspec +++ b/TestUtilities.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "TestUtilities" - s.version = "2.22.0" + s.version = "2.22.1" s.summary = "Datadog Testing Utilities. This module is for internal testing and should not be published." s.homepage = "https://www.datadoghq.com" diff --git a/TestUtilities/Mocks/CoreMocks/FeatureScopeMock.swift b/TestUtilities/Mocks/CoreMocks/FeatureScopeMock.swift index cc30edde6e..b2c5aea339 100644 --- a/TestUtilities/Mocks/CoreMocks/FeatureScopeMock.swift +++ b/TestUtilities/Mocks/CoreMocks/FeatureScopeMock.swift @@ -58,13 +58,15 @@ public final class FeatureScopeMock: FeatureScope, @unchecked Sendable { return events.compactMap { $0.event as? T } } + // swiftlint:disable function_default_parameter_at_end /// Retrieve typed events written through Even Write Context API with given `bypassConsent` flag. - public func eventsWritten( // swiftlint:disable:this function_default_parameter_at_end + public func eventsWritten( ofType type: T.Type = T.self, withBypassConsent bypassConsent: Bool ) -> [T] where T: Encodable { return events.filter { $0.bypassConsent == bypassConsent }.compactMap { $0.event as? T } } + // swiftlint:enable function_default_parameter_at_end /// Retrieve data written in Data Store. public let dataStoreMock = DataStoreMock()