diff --git a/CHANGELOG.md b/CHANGELOG.md index bc0fc46324a..47cc3034962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Support orientation change for session replay (#4194) - Replay for crashes (#4171) - Redact web view from replay (#4203) - Add beforeCaptureViewHierarchy callback (#4210) @@ -23,6 +24,9 @@ ### Fixes - Session replay crash when writing the replay (#4186) + +### Features + - Collect only unique UIWindow references (#4159) ### Deprecated diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index da9abb71622..cfcc449741e 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -4,6 +4,7 @@ # import "SentryClient+Private.h" # import "SentryDependencyContainer.h" +# import "SentryDispatchQueueWrapper.h" # import "SentryDisplayLinkWrapper.h" # import "SentryEvent+Private.h" # import "SentryFileManager.h" @@ -21,7 +22,6 @@ # import "SentrySwizzle.h" # import "SentryUIApplication.h" # import - NS_ASSUME_NONNULL_BEGIN static NSString *SENTRY_REPLAY_FOLDER = @"replay"; @@ -77,6 +77,12 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options return YES; } +/** + * Send the cached frames from a previous session that eventually crashed. + * This function is called when processing an event created by SentryCrashIntegration, + * which runs in the background. That's why we don't need to dispatch the generation of the + * replay to the background in this function. + */ - (void)resumePreviousSessionReplay:(SentryEvent *)event { NSURL *dir = [self replayDirectory]; @@ -114,39 +120,36 @@ - (void)resumePreviousSessionReplay:(SentryEvent *)event } } - _resumeReplayMaker = [[SentryOnDemandReplay alloc] initWithContentFrom:lastReplayURL.path]; - _resumeReplayMaker.bitRate = _replayOptions.replayBitRate; - _resumeReplayMaker.videoScale = _replayOptions.sizeScale; + SentryOnDemandReplay *resumeReplayMaker = + [[SentryOnDemandReplay alloc] initWithContentFrom:lastReplayURL.path]; + resumeReplayMaker.bitRate = _replayOptions.replayBitRate; + resumeReplayMaker.videoScale = _replayOptions.sizeScale; NSDate *beginning = hasCrashInfo ? [NSDate dateWithTimeIntervalSinceReferenceDate:crashInfo.lastSegmentEnd] - : [_resumeReplayMaker oldestFrameDate]; + : [resumeReplayMaker oldestFrameDate]; if (beginning == nil) { return; // no frames to send } + SentryReplayType _type = type; + int _segmentId = segmentId; + NSError *error; - if (![_resumeReplayMaker - createVideoWithBeginning:beginning - end:[beginning dateByAddingTimeInterval:duration] - outputFileURL:[lastReplayURL URLByAppendingPathComponent:@"lastVideo.mp4"] - error:&error - completion:^(SentryVideoInfo *video, NSError *renderError) { - if (renderError != nil) { - SENTRY_LOG_ERROR( - @"Could not create replay video: %@", renderError); - } else { - [self captureVideo:video - replayId:replayId - segmentId:segmentId - type:type]; - } - self->_resumeReplayMaker = nil; - }]) { + NSArray *videos = + [resumeReplayMaker createVideoWithBeginning:beginning + end:[beginning dateByAddingTimeInterval:duration] + error:&error]; + if (videos == nil) { SENTRY_LOG_ERROR(@"Could not create replay video: %@", error); return; } + for (SentryVideoInfo *video in videos) { + [self captureVideo:video replayId:replayId segmentId:_segmentId++ type:_type]; + // type buffer is only for the first segment + _type = SentryReplayTypeSession; + } NSMutableDictionary *eventContext = event.context.mutableCopy; eventContext[@"replay"] = @@ -174,13 +177,15 @@ - (void)captureVideo:(SentryVideoInfo *)video NSError *error = nil; if (![[NSFileManager defaultManager] removeItemAtURL:video.path error:&error]) { - NSLog(@"[SentrySessionReplay:%d] Could not delete replay segment from disk: %@", __LINE__, - error.localizedDescription); + SENTRY_LOG_DEBUG( + @"Could not delete replay segment from disk: %@", error.localizedDescription); } } - (void)startSession { + [self.sessionReplay stop]; + _startedAsFullSession = [self shouldReplayFullSession:_replayOptions.sessionSampleRate]; if (!_startedAsFullSession && _replayOptions.onErrorSampleRate == 0) { @@ -247,6 +252,7 @@ - (void)startWithOptions:(SentryReplayOptions *)replayOptions touchTracker:_touchTracker dateProvider:SentryDependencyContainer.sharedInstance.dateProvider delegate:self + dispatchQueue:[[SentryDispatchQueueWrapper alloc] init] displayLinkWrapper:[[SentryDisplayLinkWrapper alloc] init]]; [self.sessionReplay @@ -320,9 +326,6 @@ - (void)sentrySessionEnded:(SentrySession *)session - (void)sentrySessionStarted:(SentrySession *)session { - if (_sessionReplay) { - return; - } [self startSession]; } diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index ef173e92f55..19243b93716 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -13,23 +13,16 @@ struct SentryReplayFrame { let screenName: String? } -private struct VideoFrames { - let framesPaths: [String] - let screens: [String] - let start: Date - let end: Date -} - enum SentryOnDemandReplayError: Error { case cantReadVideoSize - case assetWriterNotReady + case cantCreatePixelBuffer + case errorRenderingVideo } @objcMembers class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { private let _outputPath: String - private var _currentPixelBuffer: SentryPixelBuffer? private var _totalFrames = 0 private let dateProvider: SentryCurrentDateProvider private let workingQueue: SentryDispatchQueueWrapper @@ -42,16 +35,10 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { set { _frames = newValue } } #endif // TEST || TESTCI || DEBUG - - var videoWidth = 200 - var videoHeight = 434 var videoScale: Float = 1 var bitRate = 20_000 var frameRate = 1 var cacheMaxSize = UInt.max - - private var actualWidth: Int { Int(Float(videoWidth) * videoScale) } - private var actualHeight: Int { Int(Float(videoHeight) * videoScale) } init(outputPath: String, workingQueue: SentryDispatchQueueWrapper, dateProvider: SentryCurrentDateProvider) { self._outputPath = outputPath @@ -85,10 +72,6 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { self.init(withContentFrom: outputPath, workingQueue: SentryDispatchQueueWrapper(name: "io.sentry.onDemandReplay", attributes: nil), dateProvider: SentryCurrentDateProvider()) - - guard let last = _frames.last, let image = UIImage(contentsOfFile: last.imagePath) else { return } - videoWidth = Int(image.size.width) - videoHeight = Int(image.size.height) } func addFrameAsync(image: UIImage, forScreen: String?) { @@ -140,92 +123,128 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { return _frames.first?.time } - func createVideoWith(beginning: Date, end: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws { - var frameCount = 0 + func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { let videoFrames = filterFrames(beginning: beginning, end: end) - if videoFrames.framesPaths.isEmpty { return } + var frameCount = 0 + + var videos = [SentryVideoInfo]() + + while frameCount < videoFrames.count { + let outputFileURL = URL(fileURLWithPath: _outputPath.appending("/\(videoFrames[frameCount].time.timeIntervalSinceReferenceDate).mp4")) + if let videoInfo = try renderVideo(with: videoFrames, from: &frameCount, at: outputFileURL) { + videos.append(videoInfo) + } else { + frameCount++ + } + } + return videos + } + + private func renderVideo(with videoFrames: [SentryReplayFrame], from: inout Int, at outputFileURL: URL) throws -> SentryVideoInfo? { + guard from < videoFrames.count, let image = UIImage(contentsOfFile: videoFrames[from].imagePath) else { return nil } + let videoWidth = image.size.width * CGFloat(videoScale) + let videoHeight = image.size.height * CGFloat(videoScale) let videoWriter = try AVAssetWriter(url: outputFileURL, fileType: .mp4) - let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: createVideoSettings()) + let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: createVideoSettings(width: videoWidth, height: videoHeight)) - _currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: actualWidth, height: actualHeight), videoWriterInput: videoWriterInput) - if _currentPixelBuffer == nil { return } + guard let currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: videoWidth, height: videoHeight), videoWriterInput: videoWriterInput) + else { throw SentryOnDemandReplayError.cantCreatePixelBuffer } videoWriter.add(videoWriterInput) videoWriter.startWriting() videoWriter.startSession(atSourceTime: .zero) - videoWriterInput.requestMediaDataWhenReady(on: workingQueue.queue) { [weak self] in - guard let self = self, videoWriter.status == .writing else { + var lastImageSize: CGSize = image.size + var usedFrames = [SentryReplayFrame]() + let group = DispatchGroup() + + var result: Result? + var frameCount = from + + group.enter() + videoWriterInput.requestMediaDataWhenReady(on: workingQueue.queue) { + guard videoWriter.status == .writing else { videoWriter.cancelWriting() - completion(nil, SentryOnDemandReplayError.assetWriterNotReady) + result = .failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo ) + group.leave() return } - - if frameCount < videoFrames.framesPaths.count { - let imagePath = videoFrames.framesPaths[frameCount] - if let image = UIImage(contentsOfFile: imagePath) { - let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(1 / self.frameRate)) - - guard self._currentPixelBuffer?.append(image: image, presentationTime: presentTime) == true - else { - completion(nil, videoWriter.error) - videoWriterInput.markAsFinished() - return - } + if frameCount >= videoFrames.count { + result = self.finishVideo(outputFileURL: outputFileURL, usedFrames: usedFrames, videoHeight: Int(videoHeight), videoWidth: Int(videoWidth), videoWriter: videoWriter) + group.leave() + return + } + let frame = videoFrames[frameCount] + if let image = UIImage(contentsOfFile: frame.imagePath) { + if lastImageSize != image.size { + result = self.finishVideo(outputFileURL: outputFileURL, usedFrames: usedFrames, videoHeight: Int(videoHeight), videoWidth: Int(videoWidth), videoWriter: videoWriter) + group.leave() + return } - frameCount += 1 - } else { - videoWriterInput.markAsFinished() - videoWriter.finishWriting { - var videoInfo: SentryVideoInfo? - if videoWriter.status == .completed { - do { - let fileAttributes = try FileManager.default.attributesOfItem(atPath: outputFileURL.path) - guard let fileSize = fileAttributes[FileAttributeKey.size] as? Int else { - completion(nil, SentryOnDemandReplayError.cantReadVideoSize) - return - } - videoInfo = SentryVideoInfo(path: outputFileURL, height: self.actualHeight, width: self.actualWidth, duration: TimeInterval(videoFrames.framesPaths.count / self.frameRate), frameCount: videoFrames.framesPaths.count, frameRate: self.frameRate, start: videoFrames.start, end: videoFrames.end, fileSize: fileSize, screens: videoFrames.screens) - } catch { - completion(nil, error) - } - } - completion(videoInfo, videoWriter.error) + lastImageSize = image.size + + let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(1 / self.frameRate)) + if currentPixelBuffer.append(image: image, presentationTime: presentTime) != true { + videoWriter.cancelWriting() + result = .failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo ) + group.leave() + return } + usedFrames.append(frame) } + frameCount += 1 } + guard group.wait(timeout: .now() + 2) == .success else { throw SentryOnDemandReplayError.errorRenderingVideo } + from = frameCount + + return try result?.get() } - - private func filterFrames(beginning: Date, end: Date) -> VideoFrames { - var framesPaths = [String]() - var screens = [String]() + private func finishVideo(outputFileURL: URL, usedFrames: [SentryReplayFrame], videoHeight: Int, videoWidth: Int, videoWriter: AVAssetWriter) -> Result { + let group = DispatchGroup() + var finishError: Error? + var result: SentryVideoInfo? - 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 } - - if let screenName = frame.screenName { - screens.append(screenName) + group.enter() + videoWriter.inputs.forEach { $0.markAsFinished() } + videoWriter.finishWriting { + defer { group.leave() } + if videoWriter.status == .completed { + do { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: outputFileURL.path) + guard let fileSize = fileAttributes[FileAttributeKey.size] as? Int else { + finishError = SentryOnDemandReplayError.cantReadVideoSize + return + } + guard let start = usedFrames.min(by: { $0.time < $1.time })?.time else { return } + let duration = TimeInterval(usedFrames.count / self.frameRate) + result = SentryVideoInfo(path: outputFileURL, height: Int(videoHeight), width: Int(videoWidth), duration: duration, frameCount: usedFrames.count, frameRate: self.frameRate, start: start, end: start.addingTimeInterval(duration), fileSize: fileSize, screens: usedFrames.compactMap({ $0.screenName })) + } catch { + finishError = error } - - actualEnd = frame.time - framesPaths.append(frame.imagePath) } + } + group.wait() + + if let finishError = finishError { return .failure(finishError) } + return .success(result) + } + + private func filterFrames(beginning: Date, end: Date) -> [SentryReplayFrame] { + var frames = [SentryReplayFrame]() + //Using dispatch queue as sync mechanism since we need a queue already to generate the video. + workingQueue.dispatchSync({ + frames = self._frames.filter { $0.time >= beginning && $0.time <= end } }) - return VideoFrames(framesPaths: framesPaths, screens: screens, start: start, end: actualEnd + TimeInterval((1 / Double(frameRate)))) + return frames } - private func createVideoSettings() -> [String: Any] { + private func createVideoSettings(width: CGFloat, height: CGFloat) -> [String: Any] { return [ AVVideoCodecKey: AVVideoCodecType.h264, - AVVideoWidthKey: actualWidth, - AVVideoHeightKey: actualHeight, + AVVideoWidthKey: width, + AVVideoHeightKey: height, AVVideoCompressionPropertiesKey: [ AVVideoAverageBitRateKey: bitRate, AVVideoProfileLevelKey: AVVideoProfileLevelH264BaselineAutoLevel diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift index 2df207a6602..32831f38642 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift @@ -4,12 +4,9 @@ import UIKit @objc protocol SentryReplayVideoMaker: NSObjectProtocol { - var videoWidth: Int { get set } - var videoHeight: Int { get set } - func addFrameAsync(image: UIImage, forScreen: String?) func releaseFramesUntil(_ date: Date) - func createVideoWith(beginning: Date, end: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws + func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] } extension SentryReplayVideoMaker { diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 62891f9ffbd..e56291c1543 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -39,6 +39,7 @@ class SentrySessionReplay: NSObject { private let displayLink: SentryDisplayLinkWrapper private let dateProvider: SentryCurrentDateProvider private let touchTracker: SentryTouchTracker? + private let dispatchQueue: SentryDispatchQueueWrapper private let lock = NSLock() var screenshotProvider: SentryViewScreenshotProvider @@ -52,8 +53,10 @@ class SentrySessionReplay: NSObject { touchTracker: SentryTouchTracker?, dateProvider: SentryCurrentDateProvider, delegate: SentrySessionReplayDelegate, + dispatchQueue: SentryDispatchQueueWrapper, displayLinkWrapper: SentryDisplayLinkWrapper) { + self.dispatchQueue = dispatchQueue self.replayOptions = replayOptions self.dateProvider = dateProvider self.delegate = delegate @@ -82,8 +85,6 @@ class SentrySessionReplay: NSObject { videoSegmentStart = nil currentSegmentId = 0 sessionReplayId = SentryId() - replayMaker.videoWidth = Int(rootView.frame.size.width) - replayMaker.videoHeight = Int(rootView.frame.size.height) imageCollection = [] if fullSession { @@ -148,14 +149,9 @@ class SentrySessionReplay: NSObject { } startFullReplay() - - guard let finalPath = urlToCache?.appendingPathComponent("replay.mp4") else { - SentryLog.debug("Could not create replay video path") - return false - } let replayStart = dateProvider.date().addingTimeInterval(-replayOptions.errorReplayDuration - (Double(replayOptions.frameRate) / 2.0)) - createAndCapture(videoUrl: finalPath, startedAt: replayStart) + createAndCapture(startedAt: replayStart) return true } @@ -215,21 +211,22 @@ class SentrySessionReplay: NSObject { pathToSegment = pathToSegment.appendingPathComponent("\(currentSegmentId).mp4") let segmentStart = videoSegmentStart ?? dateProvider.date().addingTimeInterval(-replayOptions.sessionSegmentDuration) - createAndCapture(videoUrl: pathToSegment, startedAt: segmentStart) + createAndCapture(startedAt: segmentStart) } - private func createAndCapture(videoUrl: URL, startedAt: Date) { - do { - try replayMaker.createVideoWith(beginning: startedAt, end: dateProvider.date(), outputFileURL: videoUrl) { [weak self] videoInfo, error in - guard let _self = self else { return } - if let error = error { - SentryLog.debug("Could not create replay video - \(error.localizedDescription)") - } else if let videoInfo = videoInfo { - _self.newSegmentAvailable(videoInfo: videoInfo) + private func createAndCapture(startedAt: Date) { + //Creating a video is heavy and blocks the thread + //Since this function is always called in the main thread + //we dispatch it to a background thread. + dispatchQueue.dispatchAsync { + do { + let videos = try self.replayMaker.createVideoWith(beginning: startedAt, end: self.dateProvider.date()) + for video in videos { + self.newSegmentAvailable(videoInfo: video) } + } catch { + SentryLog.debug("Could not create replay video - \(error.localizedDescription)") } - } catch { - SentryLog.debug("Could not create replay video - \(error.localizedDescription)") } } diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift index 07b166bf893..e913afef13c 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift @@ -7,11 +7,22 @@ import XCTest class SentryOnDemandReplayTests: XCTestCase { let dateProvider = TestCurrentDateProvider() - let outputPath = FileManager.default.temporaryDirectory + var outputPath: URL = { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent("replayTest") + try? FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + return temp + }() - func getSut() -> SentryOnDemandReplay { - let sut = SentryOnDemandReplay(outputPath: outputPath.path, - workingQueue: TestSentryDispatchQueueWrapper(), + override func tearDownWithError() throws { + let files = try FileManager.default.contentsOfDirectory(atPath: outputPath.path) + for file in files { + try? FileManager.default.removeItem(at: outputPath.appendingPathComponent(file)) + } + } + + func getSut(trueDispatchQueueWrapper: Bool = false) -> SentryOnDemandReplay { + let sut = SentryOnDemandReplay(outputPath: outputPath.path, + workingQueue: trueDispatchQueueWrapper ? SentryDispatchQueueWrapper() : TestSentryDispatchQueueWrapper(), dateProvider: dateProvider) return sut } @@ -63,7 +74,7 @@ class SentryOnDemandReplayTests: XCTestCase { } } - func testGenerateVideo() { + func testGenerateVideo() throws { let sut = getSut() dateProvider.driftTimeForEveryRead = true dateProvider.driftTimeInterval = 1 @@ -72,21 +83,22 @@ class SentryOnDemandReplayTests: XCTestCase { sut.addFrameAsync(image: UIImage.add) } - let output = FileManager.default.temporaryDirectory.appendingPathComponent("video.mp4") let videoExpectation = expectation(description: "Wait for video render") - try? sut.createVideoWith(beginning: Date(timeIntervalSinceReferenceDate: 0), end: Date(timeIntervalSinceReferenceDate: 10), outputFileURL: output) { info, error in - XCTAssertNil(error) - - XCTAssertEqual(info?.duration, 10) - XCTAssertEqual(info?.start, Date(timeIntervalSinceReferenceDate: 0)) - XCTAssertEqual(info?.end, Date(timeIntervalSinceReferenceDate: 10)) - - XCTAssertEqual(FileManager.default.fileExists(atPath: output.path), true) - videoExpectation.fulfill() - try? FileManager.default.removeItem(at: output) - } + let videos = try sut.createVideoWith(beginning: Date(timeIntervalSinceReferenceDate: 0), end: Date(timeIntervalSinceReferenceDate: 10)) + XCTAssertEqual(videos.count, 1) + let info = try XCTUnwrap(videos.first) + + XCTAssertEqual(info.duration, 10) + XCTAssertEqual(info.start, Date(timeIntervalSinceReferenceDate: 0)) + XCTAssertEqual(info.end, Date(timeIntervalSinceReferenceDate: 10)) + let videoPath = info.path + + XCTAssertTrue(FileManager.default.fileExists(atPath: videoPath.path)) + + videoExpectation.fulfill() + try FileManager.default.removeItem(at: videoPath) wait(for: [videoExpectation], timeout: 1) } @@ -137,25 +149,56 @@ class SentryOnDemandReplayTests: XCTestCase { XCTAssertEqual(sut.frames.count, 0) } - func testInvalidWriter() { - let queue = SentryDispatchQueueWrapper() + func testInvalidWriter() throws { + let queue = TestSentryDispatchQueueWrapper() let sut = SentryOnDemandReplay(outputPath: outputPath.path, workingQueue: queue, dateProvider: dateProvider) - let expect = expectation(description: "Video render") let start = dateProvider.date() sut.addFrameAsync(image: UIImage.add) dateProvider.advance(by: 1) let end = dateProvider.date() - try? sut.createVideoWith(beginning: start, end: end, outputFileURL: URL(fileURLWithPath: "/invalidPath/video.mp3")) { _, error in - XCTAssertNotNil(error) - XCTAssertEqual(error as? SentryOnDemandReplayError, SentryOnDemandReplayError.assetWriterNotReady) - expect.fulfill() + //Creating a file where the replay would be written to cause an error in the writer + try "tempFile".data(using: .utf8)?.write(to: outputPath.appendingPathComponent("0.0.mp4")) + + XCTAssertThrowsError(try sut.createVideoWith(beginning: start, end: end)) + } + + func testGenerateVideoForEachSize() throws { + let sut = getSut() + dateProvider.driftTimeForEveryRead = true + dateProvider.driftTimeInterval = 1 + + let image1 = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 19)).image { _ in } + let image2 = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 10)).image { _ in } + + for i in 0..<10 { + sut.addFrameAsync(image: i < 5 ? image1 : image2) } - wait(for: [expect], timeout: 1) + let videos = try sut.createVideoWith(beginning: Date(timeIntervalSinceReferenceDate: 0), end: Date(timeIntervalSinceReferenceDate: 10)) + + XCTAssertEqual(videos.count, 2) + + let firstVideo = try XCTUnwrap(videos.first) + let secondVideo = try XCTUnwrap(videos.last) + + XCTAssertEqual(firstVideo.duration, 5) + XCTAssertEqual(secondVideo.duration, 5) + + XCTAssertEqual(firstVideo.start, Date(timeIntervalSinceReferenceDate: 0)) + XCTAssertEqual(secondVideo.start, Date(timeIntervalSinceReferenceDate: 5)) + + XCTAssertEqual(firstVideo.end, Date(timeIntervalSinceReferenceDate: 5)) + XCTAssertEqual(secondVideo.end, Date(timeIntervalSinceReferenceDate: 10)) + + XCTAssertEqual(firstVideo.width, 20) + XCTAssertEqual(firstVideo.height, 19) + + XCTAssertEqual(secondVideo.width, 20) + XCTAssertEqual(secondVideo.height, 10) } } diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index bcfa6b65c85..a38c7c8ce41 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -164,6 +164,18 @@ class SentrySessionReplayIntegrationTests: XCTestCase { XCTAssertNotNil(sut.sessionReplay) } + func testRestartReplayWithNewSessionClosePreviousReplay() throws { + startSDK(sessionSampleRate: 1, errorSampleRate: 0) + + let sut = try getSut() + SentrySDK.currentHub().startSession() + XCTAssertNotNil(sut.sessionReplay) + let oldSessionReplay = sut.sessionReplay + XCTAssertTrue(oldSessionReplay?.isRunning ?? false) + SentrySDK.currentHub().startSession() + XCTAssertFalse(oldSessionReplay?.isRunning ?? true) + } + func testScreenNameFromSentryUIApplication() throws { startSDK(sessionSampleRate: 1, errorSampleRate: 1) let sut: SentrySessionReplayDelegate = try getSut() diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift index f348974121d..375c3f0156c 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -15,30 +15,22 @@ class SentrySessionReplayTests: XCTestCase { } private class TestReplayMaker: NSObject, SentryReplayVideoMaker { - var videoWidth: Int = 0 - var videoHeight: Int = 0 - var screens = [String]() struct CreateVideoCall { var beginning: Date var end: Date - var outputFileURL: URL - var completion: ((Sentry.SentryVideoInfo?, Error?) -> Void) } var lastCallToCreateVideo: CreateVideoCall? - func createVideoWith(beginning: Date, end: Date, outputFileURL: URL, completion: @escaping (Sentry.SentryVideoInfo?, (Error)?) -> Void) throws { - lastCallToCreateVideo = CreateVideoCall(beginning: beginning, - end: end, - outputFileURL: outputFileURL, - completion: completion) + func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { + lastCallToCreateVideo = CreateVideoCall(beginning: beginning, end: end) + let outputFileURL = FileManager.default.temporaryDirectory.appendingPathComponent("tempvideo.mp4") try? "Video Data".write(to: outputFileURL, atomically: true, encoding: .utf8) - let videoInfo = SentryVideoInfo(path: outputFileURL, height: 1_024, width: 480, duration: end.timeIntervalSince(beginning), frameCount: 5, frameRate: 1, start: beginning, end: end, fileSize: 10, screens: screens) - completion(videoInfo, nil) + return [videoInfo] } var lastFrame: UIImage? @@ -80,6 +72,7 @@ class SentrySessionReplayTests: XCTestCase { touchTracker: SentryTouchTracker(dateProvider: dateProvider, scale: 0), dateProvider: dateProvider, delegate: self, + dispatchQueue: TestSentryDispatchQueueWrapper(), displayLinkWrapper: displayLink) } @@ -128,18 +121,6 @@ class SentrySessionReplayTests: XCTestCase { XCTAssertNil(fixture.lastReplayEvent) } - func testVideoSize() { - let fixture = Fixture() - let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) - let sut = fixture.getSut(options: options) - let view = fixture.rootView - view.frame = CGRect(x: 0, y: 0, width: 320, height: 900) - sut.start(rootView: fixture.rootView, fullSession: true) - - XCTAssertEqual(320, fixture.replayMaker.videoWidth) - XCTAssertEqual(900, fixture.replayMaker.videoHeight) - } - func testSentReplay_FullSession() { let fixture = Fixture() @@ -162,10 +143,8 @@ class SentrySessionReplayTests: XCTestCase { XCTAssertEqual(videoArguments.end, startEvent.addingTimeInterval(5)) XCTAssertEqual(videoArguments.beginning, startEvent) - XCTAssertEqual(videoArguments.outputFileURL, fixture.cacheFolder.appendingPathComponent("segments/0.mp4")) XCTAssertNotNil(fixture.lastReplayRecording) - XCTAssertEqual(fixture.lastVideoUrl, videoArguments.outputFileURL) assertFullSession(sut, expected: true) }