diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index a3ffdba44b8..6f936442798 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -858,6 +858,7 @@ D8ACE3CD2762187D00F5A213 /* SentryNSDataSwizzling.h in Headers */ = {isa = PBXBuildFile; fileRef = D8ACE3CA2762187D00F5A213 /* SentryNSDataSwizzling.h */; }; D8ACE3CE2762187D00F5A213 /* SentryNSDataTracker.h in Headers */ = {isa = PBXBuildFile; fileRef = D8ACE3CB2762187D00F5A213 /* SentryNSDataTracker.h */; }; D8ACE3CF2762187D00F5A213 /* SentryFileIOTrackingIntegration.h in Headers */ = {isa = PBXBuildFile; fileRef = D8ACE3CC2762187D00F5A213 /* SentryFileIOTrackingIntegration.h */; }; + D8AFC0012BD252B900118BE1 /* SentryOnDemandReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AFC0002BD252B900118BE1 /* SentryOnDemandReplayTests.swift */; }; D8B0542E2A7D2C720056BAF6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D8B0542D2A7D2C720056BAF6 /* PrivacyInfo.xcprivacy */; }; D8B088B629C9E3FF00213258 /* SentryTracerConfiguration.h in Headers */ = {isa = PBXBuildFile; fileRef = D8B088B429C9E3FF00213258 /* SentryTracerConfiguration.h */; }; D8B088B729C9E3FF00213258 /* SentryTracerConfiguration.m in Sources */ = {isa = PBXBuildFile; fileRef = D8B088B529C9E3FF00213258 /* SentryTracerConfiguration.m */; }; @@ -1887,6 +1888,7 @@ D8ACE3CA2762187D00F5A213 /* SentryNSDataSwizzling.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryNSDataSwizzling.h; path = include/SentryNSDataSwizzling.h; sourceTree = ""; }; D8ACE3CB2762187D00F5A213 /* SentryNSDataTracker.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryNSDataTracker.h; path = include/SentryNSDataTracker.h; sourceTree = ""; }; D8ACE3CC2762187D00F5A213 /* SentryFileIOTrackingIntegration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryFileIOTrackingIntegration.h; path = include/SentryFileIOTrackingIntegration.h; sourceTree = ""; }; + D8AFC0002BD252B900118BE1 /* SentryOnDemandReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryOnDemandReplayTests.swift; sourceTree = ""; }; D8B0542D2A7D2C720056BAF6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; D8B088B429C9E3FF00213258 /* SentryTracerConfiguration.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryTracerConfiguration.h; path = include/SentryTracerConfiguration.h; sourceTree = ""; }; D8B088B529C9E3FF00213258 /* SentryTracerConfiguration.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryTracerConfiguration.m; sourceTree = ""; }; @@ -3512,6 +3514,7 @@ D80694C52B7CCFA100B820E6 /* SentryReplayRecordingTests.swift */, D86130112BB563FD004C0F5E /* SentrySessionReplayIntegrationTests.swift */, D861301B2BB5A267004C0F5E /* SentrySessionReplayTests.swift */, + D8AFC0002BD252B900118BE1 /* SentryOnDemandReplayTests.swift */, ); path = SessionReplay; sourceTree = ""; @@ -4763,6 +4766,7 @@ 7B4260342630315C00B36EDD /* SampleError.swift in Sources */, D855B3E827D652AF00BCED76 /* SentryCoreDataTrackingIntegrationTest.swift in Sources */, D855AD62286ED6A4002573E1 /* SentryCrashTests.m in Sources */, + D8AFC0012BD252B900118BE1 /* SentryOnDemandReplayTests.swift in Sources */, 0A9415BA28F96CAC006A5DD1 /* TestSentryReachability.swift in Sources */, D880E3A728573E87008A90DB /* SentryBaggageTests.swift in Sources */, 7B16FD022654F86B008177D3 /* SentrySysctlTests.swift in Sources */, @@ -5335,6 +5339,7 @@ ); ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-DCARTHAGE_$(CARTHAGE)"; + OTHER_SWIFT_FLAGS = "-DTEST"; PRODUCT_BUNDLE_IDENTIFIER = io.sentry.Sentry; PRODUCT_NAME = Sentry; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -6205,6 +6210,7 @@ ); ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-DCARTHAGE_$(CARTHAGE)"; + OTHER_SWIFT_FLAGS = "-DTEST"; PRODUCT_BUNDLE_IDENTIFIER = io.sentry.Sentry; PRODUCT_NAME = Sentry; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Sources/Sentry/SentryDispatchQueueWrapper.m b/Sources/Sentry/SentryDispatchQueueWrapper.m index e092e4f1303..a3378728144 100644 --- a/Sources/Sentry/SentryDispatchQueueWrapper.m +++ b/Sources/Sentry/SentryDispatchQueueWrapper.m @@ -16,7 +16,8 @@ - (instancetype)init return self; } -- (instancetype)initWithName:(const char *)name attributes:(dispatch_queue_attr_t)attributes; +- (instancetype)initWithName:(const char *)name + attributes:(nullable dispatch_queue_attr_t)attributes; { if (self = [super init]) { _queue = dispatch_queue_create(name, attributes); @@ -47,6 +48,11 @@ - (void)dispatchOnMainQueue:(void (^)(void))block [SentryThreadWrapper onMainThread:block]; } +- (void)dispatchSync:(void (^)(void))block +{ + dispatch_sync(_queue, block); +} + - (void)dispatchSyncOnMainQueue:(void (^)(void))block { if ([NSThread isMainThread]) { diff --git a/Sources/Sentry/SentrySessionReplay.m b/Sources/Sentry/SentrySessionReplay.m index b13d6a4c201..a31659ebb99 100644 --- a/Sources/Sentry/SentrySessionReplay.m +++ b/Sources/Sentry/SentrySessionReplay.m @@ -310,9 +310,7 @@ - (void)takeScreenshot _processingScreenshot = NO; - dispatch_queue_t backgroundQueue - = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); - dispatch_async(backgroundQueue, ^{ [self->_replayMaker addFrameWithImage:screenshot]; }); + [self->_replayMaker addFrameAsyncWithImage:screenshot]; } @end diff --git a/Sources/Sentry/include/SentryDispatchQueueWrapper.h b/Sources/Sentry/include/SentryDispatchQueueWrapper.h index 39b65cce61e..76052d31ec7 100644 --- a/Sources/Sentry/include/SentryDispatchQueueWrapper.h +++ b/Sources/Sentry/include/SentryDispatchQueueWrapper.h @@ -9,10 +9,13 @@ NS_ASSUME_NONNULL_BEGIN @property (strong, nonatomic) dispatch_queue_t queue; -- (instancetype)initWithName:(const char *)name attributes:(dispatch_queue_attr_t)attributes; +- (instancetype)initWithName:(const char *)name + attributes:(nullable dispatch_queue_attr_t)attributes; - (void)dispatchAsyncWithBlock:(void (^)(void))block; +- (void)dispatchSync:(void (^)(void))block; + - (void)dispatchAsyncOnMainQueue:(void (^)(void))block NS_SWIFT_NAME(dispatchAsyncOnMainQueue(block:)); diff --git a/Sources/Sentry/include/SentrySessionReplay.h b/Sources/Sentry/include/SentrySessionReplay.h index 81282bcf95e..1d8293ccaad 100644 --- a/Sources/Sentry/include/SentrySessionReplay.h +++ b/Sources/Sentry/include/SentrySessionReplay.h @@ -18,7 +18,7 @@ NS_ASSUME_NONNULL_BEGIN @protocol SentryReplayMaker -- (void)addFrameWithImage:(UIImage *)image; +- (void)addFrameAsyncWithImage:(UIImage *)image; - (void)releaseFramesUntil:(NSDate *)date; - (BOOL)createVideoWithDuration:(NSTimeInterval)duration beginning:(NSDate *)beginning diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 8c069b3c540..d56db06b02e 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -21,40 +21,52 @@ enum SentryOnDemandReplayError: Error { case cantReadVideoSize } -@available(iOS 16.0, tvOS 16.0, *) @objcMembers class SentryOnDemandReplay: NSObject { private let _outputPath: String - private let _onDemandDispatchQueue: DispatchQueue - - private var _starttime = Date() - private var _frames = [SentryReplayFrame]() private var _currentPixelBuffer: SentryPixelBuffer? + private var _totalFrames = 0 + private let dateProvider: SentryCurrentDateProvider + private let workingQueue: SentryDispatchQueueWrapper + private var _frames = [SentryReplayFrame]() + + #if TEST + //This is exposed only for tests, no need to make it thread safe. + var frames: [SentryReplayFrame] { + get { _frames } + set { _frames = newValue } + } + #endif var videoWidth = 200 var videoHeight = 434 - var bitRate = 20_000 var frameRate = 1 var cacheMaxSize = UInt.max - init(outputPath: String) { + convenience init(outputPath: String) { + self.init(outputPath: outputPath, + workingQueue: SentryDispatchQueueWrapper(name: "io.sentry.onDemandReplay", attributes: nil), + dateProvider: SentryCurrentDateProvider()) + } + + init(outputPath: String, workingQueue: SentryDispatchQueueWrapper, dateProvider: SentryCurrentDateProvider) { self._outputPath = outputPath - _onDemandDispatchQueue = DispatchQueue(label: "io.sentry.sessionreplay.ondemand") + self.dateProvider = dateProvider + self.workingQueue = workingQueue } - func addFrame(image: UIImage) { - _onDemandDispatchQueue.async { - self.asyncAddFrame(image: image) - } + func addFrameAsync(image: UIImage) { + workingQueue.dispatchAsync({ + self.addFrame(image: image) + }) } - private func asyncAddFrame(image: UIImage) { + private func addFrame(image: UIImage) { guard let data = resizeImage(image, maxWidth: 300)?.pngData() else { return } - let date = Date() - let interval = date.timeIntervalSince(_starttime) - let imagePath = (_outputPath as NSString).appendingPathComponent("\(interval).png") + let date = dateProvider.date() + let imagePath = (_outputPath as NSString).appendingPathComponent("\(_totalFrames).png") do { try data.write(to: URL(fileURLWithPath: imagePath)) } catch { @@ -67,6 +79,7 @@ class SentryOnDemandReplay: NSObject { let first = _frames.removeFirst() try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) } + _totalFrames += 1 } private func resizeImage(_ originalImage: UIImage, maxWidth: CGFloat) -> UIImage? { @@ -87,12 +100,12 @@ class SentryOnDemandReplay: NSObject { } func releaseFramesUntil(_ date: Date) { - _onDemandDispatchQueue.async { + workingQueue.dispatchAsync ({ while let first = self._frames.first, first.time < date { self._frames.removeFirst() try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) } - } + }) } func createVideoWith(duration: TimeInterval, beginning: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws { @@ -112,17 +125,17 @@ class SentryOnDemandReplay: NSObject { videoWriter.startSession(atSourceTime: .zero) var frameCount = 0 - let (frames, start, end) = filterFrames(beginning: beginning, end: beginning.addingTimeInterval(duration)) + let (framesPaths, start, end) = filterFrames(beginning: beginning, end: beginning.addingTimeInterval(duration)) - if frames.isEmpty { return } + if framesPaths.isEmpty { return } _currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: videoWidth, height: videoHeight)) - videoWriterInput.requestMediaDataWhenReady(on: _onDemandDispatchQueue) { [weak self] in + videoWriterInput.requestMediaDataWhenReady(on: workingQueue.queue) { [weak self] in guard let self = self else { return } - if frameCount < frames.count { - let imagePath = frames[frameCount] + if frameCount < framesPaths.count { + let imagePath = framesPaths[frameCount] if let image = UIImage(contentsOfFile: imagePath) { let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(self.frameRate)) @@ -144,7 +157,7 @@ class SentryOnDemandReplay: NSObject { completion(nil, SentryOnDemandReplayError.cantReadVideoSize) return } - videoInfo = SentryVideoInfo(path: outputFileURL, height: self.videoHeight, width: self.videoWidth, duration: TimeInterval(frames.count / self.frameRate), frameCount: frames.count, frameRate: self.frameRate, start: start, end: end, fileSize: fileSize) + videoInfo = SentryVideoInfo(path: outputFileURL, height: self.videoHeight, width: self.videoWidth, duration: TimeInterval(framesPaths.count / self.frameRate), frameCount: framesPaths.count, frameRate: self.frameRate, start: start, end: end, fileSize: fileSize) } catch { completion(nil, error) } @@ -155,20 +168,21 @@ class SentryOnDemandReplay: NSObject { } } - private func filterFrames(beginning: Date, end: Date) -> ([String], firstFrame: Date, lastFrame: Date) { - var frames = [String]() - - var start = Date() - var actualEnd = Date() - - for frame in _frames { - if frame.time < beginning { continue } else if frame.time > end { break } - if frame.time < start { start = frame.time } - - actualEnd = frame.time - frames.append(frame.imagePath) - } - return (frames, start, actualEnd) + private func filterFrames(beginning: Date, end: Date) -> ([String], start: Date, end: Date) { + var framesPaths = [String]() + + var start = dateProvider.date() + var actualEnd = start + workingQueue.dispatchSync({ + for frame in self._frames { + if frame.time < beginning { continue } else if frame.time > end { break } + if frame.time < start { start = frame.time } + + actualEnd = frame.time + framesPaths.append(frame.imagePath) + } + }) + return (framesPaths, start, actualEnd + TimeInterval((1 / Double(frameRate)))) } private func createVideoSettings() -> [String: Any] { diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift new file mode 100644 index 00000000000..e4e8ff5a4b1 --- /dev/null +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift @@ -0,0 +1,125 @@ +import Foundation +import Nimble +@testable import Sentry +import SentryTestUtils +import XCTest + +#if os(iOS) || os(tvOS) +class SentryOnDemandReplayTests: XCTestCase { + + let dateProvider = TestCurrentDateProvider() + let outputPath = FileManager.default.temporaryDirectory + + func getSut() -> SentryOnDemandReplay { + let sut = SentryOnDemandReplay(outputPath: outputPath.path, + workingQueue: TestSentryDispatchQueueWrapper(), + dateProvider: dateProvider) + return sut + } + + func testAddFrame() { + let sut = getSut() + sut.addFrameAsync(image: UIImage.add) + + guard let frame = sut.frames.first else { + fail("Frame was not saved") + return + } + + expect(FileManager.default.fileExists(atPath: frame.imagePath)) == true + expect(frame.imagePath.hasPrefix(self.outputPath.path)) == true + } + + func testReleaseFrames() { + let sut = getSut() + + for _ in 0..<10 { + sut.addFrameAsync(image: UIImage.add) + dateProvider.advance(by: 1) + } + + sut.releaseFramesUntil(dateProvider.date().addingTimeInterval(-5)) + + let frames = sut.frames + + expect(frames.count) == 5 + expect(frames.first?.time) == Date(timeIntervalSinceReferenceDate: 5) + expect(frames.last?.time) == Date(timeIntervalSinceReferenceDate: 9) + } + + func testGenerateVideo() { + let sut = getSut() + dateProvider.driftTimeForEveryRead = true + dateProvider.driftTimeInterval = 1 + + for _ in 0..<10 { + sut.addFrameAsync(image: UIImage.add) + } + + let output = FileManager.default.temporaryDirectory.appendingPathComponent("video.mp4") + let videoExpectation = expectation(description: "Wait for video render") + + try? sut.createVideoWith(duration: 10, beginning: Date(timeIntervalSinceReferenceDate: 0), outputFileURL: output) { info, error in + expect(error) == nil + + expect(info?.duration) == 10 + expect(info?.start) == Date(timeIntervalSinceReferenceDate: 0) + expect(info?.end) == Date(timeIntervalSinceReferenceDate: 10) + + expect(FileManager.default.fileExists(atPath: output.path)) == true + videoExpectation.fulfill() + try? FileManager.default.removeItem(at: output) + } + + wait(for: [videoExpectation], timeout: 1) + } + + func testAddFrameIsThreadSafe() { + let queue = SentryDispatchQueueWrapper() + let sut = SentryOnDemandReplay(outputPath: outputPath.path, + workingQueue: queue, + dateProvider: dateProvider) + + dateProvider.driftTimeForEveryRead = true + dateProvider.driftTimeInterval = 1 + let group = DispatchGroup() + + for _ in 0..<10 { + group.enter() + DispatchQueue.global().async { + sut.addFrameAsync(image: UIImage.add) + group.leave() + } + } + + group.wait() + queue.queue.sync {} //Wait for all enqueued operation to finish + expect(sut.frames.map({ ($0.imagePath as NSString).lastPathComponent })) == (0..<10).map { "\($0).png" } + } + + func testReleaseIsThreadSafe() { + let queue = SentryDispatchQueueWrapper() + let sut = SentryOnDemandReplay(outputPath: outputPath.path, + workingQueue: queue, + dateProvider: dateProvider) + + sut.frames = (0..<100).map { SentryReplayFrame(imagePath: outputPath.path + "/\($0).png", time: Date(timeIntervalSinceReferenceDate: Double($0))) } + + let group = DispatchGroup() + + for i in 1...10 { + group.enter() + DispatchQueue.global().async { + sut.releaseFramesUntil(Date(timeIntervalSinceReferenceDate: Double(i) * 10.0)) + group.leave() + } + } + + group.wait() + + queue.queue.sync {} //Wait for all enqueued operation to finish + expect(sut.frames.count) == 0 + } + +} +#endif diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift index 6249ab60ddd..03d00728f2d 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -34,7 +34,7 @@ class SentrySessionReplayTests: XCTestCase { } var lastFrame: UIImage? - func addFrame(with image: UIImage) { + func addFrameAsync(with image: UIImage) { lastFrame = image }