diff --git a/Sources/MuxUploadSDK/Extensions/NSMutableURLRequest.swift b/Sources/MuxUploadSDK/Extensions/NSMutableURLRequest+Reporting.swift similarity index 100% rename from Sources/MuxUploadSDK/Extensions/NSMutableURLRequest.swift rename to Sources/MuxUploadSDK/Extensions/NSMutableURLRequest+Reporting.swift diff --git a/Sources/MuxUploadSDK/InputInspection/UploadInputFormatInspectionResult.swift b/Sources/MuxUploadSDK/InputInspection/UploadInputFormatInspectionResult.swift index 15a7a689..e8c33395 100644 --- a/Sources/MuxUploadSDK/InputInspection/UploadInputFormatInspectionResult.swift +++ b/Sources/MuxUploadSDK/InputInspection/UploadInputFormatInspectionResult.swift @@ -21,9 +21,12 @@ enum UploadInputFormatInspectionResult { case unsupportedPixelFormat } - case inspectionFailure - case standard - case nonstandard([NonstandardInputReason]) + case inspectionFailure(duration: CMTime) + case standard(duration: CMTime) + case nonstandard( + reasons: [NonstandardInputReason], + duration: CMTime + ) var isStandard: Bool { if case Self.standard = self { @@ -33,8 +36,19 @@ enum UploadInputFormatInspectionResult { } } + var sourceInputDuration: CMTime { + switch self { + case .inspectionFailure(duration: let duration): + return duration + case .standard(duration: let duration): + return duration + case .nonstandard(_, duration: let duration): + return duration + } + } + var nonstandardInputReasons: [NonstandardInputReason]? { - if case Self.nonstandard(let nonstandardInputReasons) = self { + if case Self.nonstandard(let nonstandardInputReasons, _) = self { return nonstandardInputReasons } else { return nil @@ -42,3 +56,32 @@ enum UploadInputFormatInspectionResult { } } + +extension UploadInputFormatInspectionResult.NonstandardInputReason: CustomStringConvertible { + var description: String { + switch self { + case .audioCodec: + return "audio_codec" + case .audioEditList: + return "audio_edit_list" + case .pixelAspectRatio: + return "pixel_aspect_ratio" + case .videoBitrate: + return "video_bitrate" + case .videoCodec: + return "video_codec" + case .videoEditList: + return "video_edit_list" + case .videoFrameRate: + return "video_frame_rate" + case .videoGOPSize: + return "video_gop_size" + case .videoResolution: + return "video_resolution" + case .unexpectedMediaFileParameters: + return "unexpected_media_file_parameters" + case .unsupportedPixelFormat: + return "unsupported_pixel_format" + } + } +} diff --git a/Sources/MuxUploadSDK/InputInspection/UploadInputInspector.swift b/Sources/MuxUploadSDK/InputInspection/UploadInputInspector.swift index c1b712b8..61aa5c1c 100644 --- a/Sources/MuxUploadSDK/InputInspection/UploadInputInspector.swift +++ b/Sources/MuxUploadSDK/InputInspection/UploadInputInspector.swift @@ -20,6 +20,30 @@ class AVFoundationUploadInputInspector: UploadInputInspector { func performInspection( sourceInput: AVAsset, completionHandler: @escaping (UploadInputFormatInspectionResult) -> () + ) { + sourceInput.loadValuesAsynchronously( + forKeys: [ + "duration" + ] + ) { + // FIXME: Trying to avoid the callback pyramid of doom + // here, newer AVAsset APIs use Concurrency + // but Concurrency itself has very primitive + // task sequencing. Replace with async AVAsset + // methods. + let sourceInputDuration = sourceInput.duration + self.performInspection( + sourceInput: sourceInput, + sourceInputDuration: sourceInputDuration, + completionHandler: completionHandler + ) + } + } + + func performInspection( + sourceInput: AVAsset, + sourceInputDuration: CMTime, + completionHandler: @escaping (UploadInputFormatInspectionResult) -> () ) { // TODO: Eventually load audio tracks too if #available(iOS 15, *) { @@ -27,12 +51,15 @@ class AVFoundationUploadInputInspector: UploadInputInspector { withMediaType: .video ) { tracks, error in if error != nil { - completionHandler(.inspectionFailure) + completionHandler( + .inspectionFailure(duration: sourceInputDuration) + ) return } if let tracks { self.inspect( + sourceInputDuration: sourceInputDuration, tracks: tracks, completionHandler: completionHandler ) @@ -40,22 +67,26 @@ class AVFoundationUploadInputInspector: UploadInputInspector { } } else { sourceInput.loadValuesAsynchronously( - forKeys: ["tracks"] + forKeys: [ + "tracks" + ] ) { // Non-blocking if "tracks" is already loaded let tracks = sourceInput.tracks( withMediaType: .video ) + self.inspect( + sourceInputDuration: sourceInputDuration, tracks: tracks, completionHandler: completionHandler ) } } - } func inspect( + sourceInputDuration: CMTime, tracks: [AVAssetTrack], completionHandler: @escaping (UploadInputFormatInspectionResult) -> () ) { @@ -63,7 +94,9 @@ class AVFoundationUploadInputInspector: UploadInputInspector { case 0: // Nothing to inspect, therefore nothing to standardize // declare as already standard - completionHandler(.standard) + completionHandler( + .standard(duration: sourceInputDuration) + ) case 1: if let track = tracks.first { track.loadValuesAsynchronously( @@ -73,12 +106,18 @@ class AVFoundationUploadInputInspector: UploadInputInspector { ] ) { guard let formatDescriptions = track.formatDescriptions as? [CMFormatDescription] else { - completionHandler(.inspectionFailure) + completionHandler( + .inspectionFailure( + duration: sourceInputDuration + ) + ) return } guard let formatDescription = formatDescriptions.first else { - completionHandler(.inspectionFailure) + completionHandler( + .inspectionFailure(duration: sourceInputDuration) + ) return } @@ -106,9 +145,11 @@ class AVFoundationUploadInputInspector: UploadInputInspector { } if nonStandardReasons.isEmpty { - completionHandler(.standard) + completionHandler( + .standard(duration: sourceInputDuration) + ) } else { - completionHandler(.nonstandard(nonStandardReasons)) + completionHandler(.nonstandard(reasons: nonStandardReasons, duration: sourceInputDuration)) } } @@ -116,7 +157,9 @@ class AVFoundationUploadInputInspector: UploadInputInspector { default: // Inspection fails for multi-video track inputs // for the time being - completionHandler(.inspectionFailure) + completionHandler( + .inspectionFailure(duration: sourceInputDuration) + ) } } } diff --git a/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizationWorker.swift b/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizationWorker.swift index d1367bf3..7cd1a328 100644 --- a/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizationWorker.swift +++ b/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizationWorker.swift @@ -19,6 +19,22 @@ enum StandardizationStrategy { case exportSession } +struct StandardizationError: Error { + var localizedDescription: String + + static var missingExportPreset = StandardizationError( + localizedDescription: "Missing export session preset" + ) + + static var exportSessionInitializationFailure = StandardizationError( + localizedDescription: "Export session failed to initialize" + ) + + static var standardizedAssetExportFailure = StandardizationError( + localizedDescription: "Failed to export standardized asset" + ) +} + class UploadInputStandardizationWorker { var sourceInput: AVAsset? @@ -29,7 +45,7 @@ class UploadInputStandardizationWorker { sourceAsset: AVAsset, maximumResolution: UploadOptions.InputStandardization.MaximumResolution, outputURL: URL, - completion: @escaping (AVAsset, AVAsset?, URL?, Bool) -> () + completion: @escaping (AVAsset, AVAsset?, Error?) -> () ) { let availableExportPresets = AVAssetExportSession.allExportPresets() @@ -45,7 +61,7 @@ class UploadInputStandardizationWorker { $0 == exportPreset }) else { // TODO: Use VideoToolbox if export preset unavailable - completion(sourceAsset, nil, nil, false) + completion(sourceAsset, nil, StandardizationError.missingExportPreset) return } @@ -54,7 +70,7 @@ class UploadInputStandardizationWorker { presetName: exportPreset ) else { // TODO: Use VideoToolbox if export session fails to initialize - completion(sourceAsset, nil, nil, false) + completion(sourceAsset, nil, StandardizationError.exportSessionInitializationFailure) return } @@ -64,12 +80,12 @@ class UploadInputStandardizationWorker { // TODO: Use Swift Concurrency exportSession.exportAsynchronously { if let exportError = exportSession.error { - completion(sourceAsset, nil, nil, false) + completion(sourceAsset, nil, exportError) } else if let standardizedAssetURL = exportSession.outputURL { let standardizedAsset = AVAsset(url: standardizedAssetURL) - completion(sourceAsset, standardizedAsset, outputURL, true) + completion(sourceAsset, standardizedAsset, nil) } else { - completion(sourceAsset, nil, nil, false) + completion(sourceAsset, nil, StandardizationError.standardizedAssetExportFailure) } } } diff --git a/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizer.swift b/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizer.swift index f08ad06c..7dcdc9d1 100644 --- a/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizer.swift +++ b/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizer.swift @@ -13,7 +13,7 @@ class UploadInputStandardizer { sourceAsset: AVAsset, maximumResolution: UploadOptions.InputStandardization.MaximumResolution, outputURL: URL, - completion: @escaping (AVAsset, AVAsset?, URL?, Bool) -> () + completion: @escaping (AVAsset, AVAsset?, Error?) -> () ) { let worker = UploadInputStandardizationWorker() diff --git a/Sources/MuxUploadSDK/InternalUtilities/ChunkedFile.swift b/Sources/MuxUploadSDK/InternalUtilities/ChunkedFile.swift index 1cb25ce3..36795b00 100644 --- a/Sources/MuxUploadSDK/InternalUtilities/ChunkedFile.swift +++ b/Sources/MuxUploadSDK/InternalUtilities/ChunkedFile.swift @@ -96,7 +96,7 @@ class ChunkedFile { let fileSize = try fileManager.fileSizeOfItem( atPath: fileURL.path ) - + guard let data = data else { // Called while already at the end of the file. We read zero bytes, "ending" at the end of the file return FileChunk(startByte: fileSize, endByte: fileSize, totalFileSize: fileSize, chunkData: Data(capacity: 0)) diff --git a/Sources/MuxUploadSDK/InternalUtilities/Reporting/InputStandardizationFailedEvent.swift b/Sources/MuxUploadSDK/InternalUtilities/Reporting/InputStandardizationFailedEvent.swift new file mode 100644 index 00000000..7d3c5f37 --- /dev/null +++ b/Sources/MuxUploadSDK/InternalUtilities/Reporting/InputStandardizationFailedEvent.swift @@ -0,0 +1,31 @@ +// +// InputStandardizationFailedEvent.swift +// + +import Foundation + +struct InputStandardizationFailedEvent: Codable { + var type: String = "upload_input_standardization_failed" + var sessionID: String + var version: String = "1" + var data: Data + + struct Data: Codable { + var appName: String? + var appVersion: String? + var deviceModel: String + var errorDescription: String + var inputDuration: Double + var inputSize: UInt64 + var maximumResolution: String + var nonStandardInputReasons: [String] + var platformName: String + var platformVersion: String + var regionCode: String? + var sdkVersion: String + var standardizationStartTime: Date + var standardizationEndTime: Date + var uploadCanceled: Bool + var uploadURL: URL + } +} diff --git a/Sources/MuxUploadSDK/InternalUtilities/Reporting/InputStandardizationSucceededEvent.swift b/Sources/MuxUploadSDK/InternalUtilities/Reporting/InputStandardizationSucceededEvent.swift new file mode 100644 index 00000000..5266a66a --- /dev/null +++ b/Sources/MuxUploadSDK/InternalUtilities/Reporting/InputStandardizationSucceededEvent.swift @@ -0,0 +1,29 @@ +// +// InputStandardizationSucceededEvent.swift +// + +import Foundation + +struct InputStandardizationSucceededEvent: Codable { + var type: String = "upload_input_standardization_succeeded" + var sessionID: String + var version: String = "1" + var data: Data + + struct Data: Codable { + var appName: String? + var appVersion: String? + var deviceModel: String + var inputDuration: Double + var inputSize: UInt64 + var maximumResolution: String + var nonStandardInputReasons: [String] + var platformName: String + var platformVersion: String + var regionCode: String? + var sdkVersion: String + var standardizationStartTime: Date + var standardizationEndTime: Date + var uploadURL: URL + } +} diff --git a/Sources/MuxUploadSDK/InternalUtilities/Reporting/Reporter.swift b/Sources/MuxUploadSDK/InternalUtilities/Reporting/Reporter.swift index 6497e35a..34cdacd8 100644 --- a/Sources/MuxUploadSDK/InternalUtilities/Reporting/Reporter.swift +++ b/Sources/MuxUploadSDK/InternalUtilities/Reporting/Reporter.swift @@ -9,11 +9,18 @@ import Foundation import UIKit class Reporter: NSObject { + + static let shared: Reporter = Reporter() + var session: URLSession? - var pendingUploadEvent: UploadEvent? + + var pendingEvents: [ObjectIdentifier: Codable] = [:] var jsonEncoder: JSONEncoder + var sessionID: String = UUID().uuidString + var url: URL + // TODO: Set these using dependency Injection var locale: Locale { Locale.current @@ -33,68 +40,216 @@ class Reporter: NSObject { let jsonEncoder = JSONEncoder() jsonEncoder.keyEncodingStrategy = JSONEncoder.KeyEncodingStrategy.convertToSnakeCase jsonEncoder.outputFormatting = .sortedKeys + jsonEncoder.dateEncodingStrategy = .iso8601 self.jsonEncoder = jsonEncoder + // TODO: throwable initializer after NSObject super + // is removed + self.url = URL( + string: "https://mobile.muxanalytics.com" + )! + super.init() let sessionConfig: URLSessionConfiguration = URLSessionConfiguration.default session = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil) } - func report( - startTime: TimeInterval, - endTime: TimeInterval, - fileSize: UInt64, - videoDuration: Double, + func send( + event: Event, + url: URL + ) { + guard let httpBody = try? jsonEncoder.encode(event) else { + return + } + + let request = NSMutableURLRequest.makeJSONPost( + url: url, + httpBody: httpBody + ) + + guard let dataTask = session?.dataTask( + with: request as URLRequest + ) else { + return + } + + let taskID = ObjectIdentifier(dataTask) + + pendingEvents[ + taskID + ] = event + + dataTask.resume() + } +} + +extension Reporter { + func reportUploadSuccess( + inputDuration: Double, + inputSize: UInt64, + options: UploadOptions, + uploadEndTime: Date, + uploadStartTime: Date, uploadURL: URL ) -> Void { - self.pendingUploadEvent = UploadEvent( - startTime: startTime, - endTime: endTime, - fileSize: fileSize, - videoDuration: videoDuration, - uploadURL: uploadURL, - sdkVersion: Version.versionString, - osName: device.systemName, - osVersion: device.systemVersion, + + guard !options.eventTracking.optedOut else { + return + } + + let data = UploadSucceededEvent.Data( + appName: Bundle.main.appName, + appVersion: Bundle.main.appVersion, deviceModel: device.model, - appName: Bundle.main.bundleIdentifier, + inputDuration: inputDuration, + inputSize: inputSize, + inputStandardizationEnabled: options.inputStandardization.isEnabled, + platformName: device.systemName, + platformVersion: device.systemVersion, + regionCode: regionCode, + sdkVersion: Version.versionString, + uploadStartTime: uploadStartTime, + uploadEndTime: uploadEndTime, + uploadURL: uploadURL + ) + + let event = UploadSucceededEvent( + sessionID: sessionID, + data: data + ) + + send( + event: event, + url: url + ) + } + + func reportUploadFailure( + errorDescription: String, + inputDuration: Double, + inputSize: UInt64, + options: UploadOptions, + uploadEndTime: Date, + uploadStartTime: Date, + uploadURL: URL + ) { + guard !options.eventTracking.optedOut else { + return + } + + let data = UploadFailedEvent.Data( + appName: Bundle.main.appName, appVersion: Bundle.main.appVersion, - regionCode: regionCode + deviceModel: device.model, + errorDescription: errorDescription, + inputDuration: inputDuration, + inputSize: inputSize, + inputStandardizationEnabled: options.inputStandardization.isEnabled, + platformName: device.systemName, + platformVersion: device.systemVersion, + regionCode: regionCode, + sdkVersion: Version.versionString, + uploadStartTime: uploadStartTime, + uploadEndTime: uploadEndTime, + uploadURL: url ) - // FIXME: If this fails, an event without a payload - // is sent which probably isn't what we want - do { - let httpBody = try serializePendingEvent() - let request = self.generateRequest( - url: URL(string: "https://mobile.muxanalytics.com")!, - httpBody: httpBody - ) - let dataTask = session?.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in - self.pendingUploadEvent = nil - }) - dataTask?.resume() - } catch _ as NSError {} + let event = UploadFailedEvent( + sessionID: sessionID, + data: data + ) + + send( + event: event, + url: url + ) } - func serializePendingEvent() throws -> Data { - return try jsonEncoder.encode(pendingUploadEvent) + func reportUploadInputStandardizationSuccess( + inputDuration: Double, + inputSize: UInt64, + options: UploadOptions, + nonStandardInputReasons: [UploadInputFormatInspectionResult.NonstandardInputReason], + standardizationEndTime: Date, + standardizationStartTime: Date, + uploadURL: URL + ) { + guard !options.eventTracking.optedOut else { + return + } + + let data = InputStandardizationSucceededEvent.Data( + appName: Bundle.main.appName, + appVersion: Bundle.main.appVersion, + deviceModel: device.model, + inputDuration: inputDuration, + inputSize: inputSize, + maximumResolution: options.inputStandardization.maximumResolution.description, + nonStandardInputReasons: nonStandardInputReasons.map(\.description), + platformName: device.systemName, + platformVersion: device.systemVersion, + regionCode: regionCode, + sdkVersion: Version.versionString, + standardizationStartTime: standardizationStartTime, + standardizationEndTime: standardizationEndTime, + uploadURL: uploadURL + ) + + let event = InputStandardizationSucceededEvent( + sessionID: sessionID, + data: data + ) + + send( + event: event, + url: url + ) } - private func generateRequest( - url: URL, - httpBody: Data - ) -> URLRequest { - let request = NSMutableURLRequest(url: url, - cachePolicy: .useProtocolCachePolicy, - timeoutInterval: 10.0) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = httpBody - - return request as URLRequest + func reportUploadInputStandardizationFailure( + errorDescription: String, + inputDuration: Double, + inputSize: UInt64, + nonStandardInputReasons: [UploadInputFormatInspectionResult.NonstandardInputReason], + options: UploadOptions, + standardizationEndTime: Date, + standardizationStartTime: Date, + uploadCanceled: Bool, + uploadURL: URL + ) { + guard !options.eventTracking.optedOut else { + return + } + + let data = InputStandardizationFailedEvent.Data( + appName: Bundle.main.appName, + appVersion: Bundle.main.appVersion, + deviceModel: device.model, + errorDescription: errorDescription, + inputDuration: inputDuration, + inputSize: inputSize, + maximumResolution: options.inputStandardization.maximumResolution.description, + nonStandardInputReasons: nonStandardInputReasons.map(\.description), + platformName: device.systemName, + platformVersion: device.systemVersion, + regionCode: regionCode, + sdkVersion: Version.versionString, + standardizationStartTime: standardizationStartTime, + standardizationEndTime: standardizationEndTime, + uploadCanceled: uploadCanceled, + uploadURL: uploadURL + ) + + let event = InputStandardizationFailedEvent( + sessionID: sessionID, + data: data + ) + + send( + event: event, + url: url + ) } } @@ -102,14 +257,27 @@ class Reporter: NSObject { // can become non-optional, which removes a bunch of edge cases extension Reporter: URLSessionDelegate, URLSessionTaskDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Swift.Void) { - if(self.pendingUploadEvent != nil) { - if let redirectUrl = request.url, let httpBody = try? serializePendingEvent() { - let request = self.generateRequest( - url: redirectUrl, - httpBody: httpBody - ) - completionHandler(request) + if let pendingEvent = pendingEvents[ObjectIdentifier(task)], let redirectURL = request.url { + guard let httpBody = try? jsonEncoder.encode(pendingEvent) else { + completionHandler(nil) + return } + + // TODO: This can be URLRequest instead of NSMutableURLRequest + // test URLRequest-based construction in case + // for any weirdness + let request = NSMutableURLRequest.makeJSONPost( + url: redirectURL, + httpBody: httpBody + ) + + completionHandler(request as URLRequest) } } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + pendingEvents[ + ObjectIdentifier(task) + ] = nil + } } diff --git a/Sources/MuxUploadSDK/InternalUtilities/Reporting/UploadEvent.swift b/Sources/MuxUploadSDK/InternalUtilities/Reporting/UploadEvent.swift deleted file mode 100644 index f0f9a82a..00000000 --- a/Sources/MuxUploadSDK/InternalUtilities/Reporting/UploadEvent.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// UploadEvent.swift -// -// -// Created by Liam Lindner on 3/22/23. -// - -import Foundation - -struct UploadEvent: Codable { - var type = "upload" - var startTime: TimeInterval - var endTime: TimeInterval - var fileSize: UInt64 - var videoDuration: Double - var uploadURL: URL - - var sdkVersion: String - - var osName: String - var osVersion: String - - var deviceModel: String - - var appName: String? - var appVersion: String? - - var regionCode: String? -} diff --git a/Sources/MuxUploadSDK/InternalUtilities/Reporting/UploadFailedEvent.swift b/Sources/MuxUploadSDK/InternalUtilities/Reporting/UploadFailedEvent.swift new file mode 100644 index 00000000..a790595a --- /dev/null +++ b/Sources/MuxUploadSDK/InternalUtilities/Reporting/UploadFailedEvent.swift @@ -0,0 +1,29 @@ +// +// UploadFailedEvent.swift +// + +import Foundation + +struct UploadFailedEvent: Codable { + var type: String = "upload_failed" + var sessionID: String + var version: String = "1" + var data: Data + + struct Data: Codable { + var appName: String? + var appVersion: String? + var deviceModel: String + var errorDescription: String + var inputDuration: Double + var inputSize: UInt64 + var inputStandardizationEnabled: Bool + var platformName: String + var platformVersion: String + var regionCode: String? + var sdkVersion: String + var uploadStartTime: Date + var uploadEndTime: Date + var uploadURL: URL + } +} diff --git a/Sources/MuxUploadSDK/InternalUtilities/Reporting/UploadSucceededEvent.swift b/Sources/MuxUploadSDK/InternalUtilities/Reporting/UploadSucceededEvent.swift new file mode 100644 index 00000000..b18e915b --- /dev/null +++ b/Sources/MuxUploadSDK/InternalUtilities/Reporting/UploadSucceededEvent.swift @@ -0,0 +1,31 @@ +// +// UploadSucceededEvent.swift +// +// +// Created by Liam Lindner on 3/22/23. +// + +import Foundation + +struct UploadSucceededEvent: Codable { + var type: String = "upload_succeeded" + var sessionID: String + var version: String = "1" + var data: Data + + struct Data: Codable { + var appName: String? + var appVersion: String? + var deviceModel: String + var inputDuration: Double + var inputSize: UInt64 + var inputStandardizationEnabled: Bool + var platformName: String + var platformVersion: String + var regionCode: String? + var sdkVersion: String + var uploadStartTime: Date + var uploadEndTime: Date + var uploadURL: URL + } +} diff --git a/Sources/MuxUploadSDK/PublicAPI/MuxUpload.swift b/Sources/MuxUploadSDK/PublicAPI/MuxUpload.swift index 3173d958..1092a4ce 100644 --- a/Sources/MuxUploadSDK/PublicAPI/MuxUpload.swift +++ b/Sources/MuxUploadSDK/PublicAPI/MuxUpload.swift @@ -338,7 +338,13 @@ public final class MuxUpload : Hashable, Equatable { handleStateUpdate(uploader.currentState) uploader.addDelegate( withToken: id, - InternalUploaderDelegate { [weak self] state in self?.handleStateUpdate(state) } + InternalUploaderDelegate { [weak self] state in + guard let self = self else { + return + } + + self.handleStateUpdate(state) + } ) } @@ -419,27 +425,8 @@ public final class MuxUpload : Hashable, Equatable { Begins the upload. You can control what happens when the upload is already started. If `forceRestart` is true, the upload will be restarted. Otherwise, nothing will happen. The default is not to restart */ public func start(forceRestart: Bool = false) { - guard let videoFile else { - let startFailureTransportStatus = TransportStatus( - progress: nil, - updatedTime: Date().timeIntervalSince1970, - startTime: Date().timeIntervalSince1970, - isPaused: true - ) - let error: UploadError = UploadError( - lastStatus: startFailureTransportStatus, - code: .file, - message: "", - reason: nil - ) - let result: UploadResult = .failure(error) - input.status = .uploadFailed( - input.uploadInfo, - error - ) - resultHandler?(result) - return - } + + let videoFile = (input.sourceAsset as! AVURLAsset).url if self.manageBySDK { // See if there's anything in progress already @@ -460,13 +447,13 @@ public final class MuxUpload : Hashable, Equatable { } // Start a new upload - if forceRestart { + + if case UploadInput.Status.ready = input.status { + input.status = .started(input.sourceAsset, uploadInfo) + startInspection(videoFile: videoFile) + } else if forceRestart { cancel() } - - input.status = .started(input.sourceAsset, uploadInfo) - - startInspection(videoFile: videoFile) } func startInspection( @@ -475,6 +462,19 @@ public final class MuxUpload : Hashable, Equatable { if !uploadInfo.options.inputStandardization.isEnabled { startNetworkTransport(videoFile: videoFile) } else { + let inputStandardizationStartTime = Date() + let reporter = Reporter.shared + + // For consistency report non-std + // input size. Should the std size + // be reported too? + // FIXME: if file size is zero, should + // instead throw an error since upload + // will likely fail + let inputSize = (try? FileManager.default.fileSizeOfItem( + atPath: videoFile.path + )) ?? 0 + input.status = .underInspection(input.sourceAsset, uploadInfo) inputInspector.performInspection( sourceInput: input.sourceAsset @@ -489,6 +489,18 @@ public final class MuxUpload : Hashable, Equatable { // input standardization fails let shouldCancelUpload = self.nonStandardInputHandler?() ?? false + reporter.reportUploadInputStandardizationFailure( + errorDescription: "Input inspection failure", + inputDuration: inspectionResult.sourceInputDuration.seconds, + inputSize: inputSize, + nonStandardInputReasons: [], + options: self.uploadInfo.options, + standardizationEndTime: Date(), + standardizationStartTime: inputStandardizationStartTime, + uploadCanceled: shouldCancelUpload, + uploadURL: self.uploadURL + ) + if !shouldCancelUpload { self.startNetworkTransport( videoFile: videoFile @@ -503,7 +515,7 @@ public final class MuxUpload : Hashable, Equatable { case .standard: self.startNetworkTransport(videoFile: videoFile) case .nonstandard( - let reasons + let reasons, _ ): print(""" Detected Nonstandard Reasons @@ -532,19 +544,27 @@ public final class MuxUpload : Hashable, Equatable { sourceAsset: AVAsset(url: videoFile), maximumResolution: maximumResolution, outputURL: outputURL - ) { sourceAsset, standardizedAsset, outputURL, success in + ) { sourceAsset, standardizedAsset, error in - if let outputURL, success { - self.startNetworkTransport( - videoFile: outputURL - ) - } else { + if let error { // Request upload confirmation // before proceeding. If handler unset, // by default do not cancel upload if // input standardization fails let shouldCancelUpload = self.nonStandardInputHandler?() ?? false + reporter.reportUploadInputStandardizationFailure( + errorDescription: error.localizedDescription, + inputDuration: inspectionResult.sourceInputDuration.seconds, + inputSize: inputSize, + nonStandardInputReasons: reasons, + options: self.uploadInfo.options, + standardizationEndTime: Date(), + standardizationStartTime: inputStandardizationStartTime, + uploadCanceled: shouldCancelUpload, + uploadURL: self.uploadURL + ) + if !shouldCancelUpload { self.startNetworkTransport( videoFile: videoFile @@ -554,6 +574,21 @@ public final class MuxUpload : Hashable, Equatable { self.uploadManager.acknowledgeUpload(id: self.id) self.input.processUploadCancellation() } + } else { + reporter.reportUploadInputStandardizationSuccess( + inputDuration: inspectionResult.sourceInputDuration.seconds, + inputSize: inputSize, + options: self.uploadInfo.options, + nonStandardInputReasons: reasons, + standardizationEndTime: Date(), + standardizationStartTime: inputStandardizationStartTime, + uploadURL: self.uploadURL + ) + + self.startNetworkTransport( + videoFile: outputURL, + duration: inspectionResult.sourceInputDuration + ) } self.inputStandardizer.acknowledgeCompletion(id: self.id) @@ -593,6 +628,37 @@ public final class MuxUpload : Hashable, Equatable { ) inputStatusHandler?(inputStatus) } + + func startNetworkTransport( + videoFile: URL, + duration: CMTime + ) { + let completedUnitCount = UInt64(uploadStatus?.progress?.completedUnitCount ?? 0) + + let fileWorker = ChunkedFileUploader( + uploadInfo: input.uploadInfo, + inputFileURL: videoFile, + file: ChunkedFile(chunkSize: input.uploadInfo.options.transport.chunkSizeInBytes), + startingByte: completedUnitCount + ) + fileWorker.addDelegate( + withToken: id, + InternalUploaderDelegate { [self] state in handleStateUpdate(state) } + ) + fileWorker.start(duration: duration) + uploadManager.registerUpload(self) + self.fileWorker = fileWorker + let transportStatus = TransportStatus( + progress: fileWorker.currentState.progress ?? Progress(), + updatedTime: Date().timeIntervalSince1970, + startTime: Date().timeIntervalSince1970, + isPaused: false + ) + self.input.processStartNetworkTransport( + startingTransportStatus: transportStatus + ) + inputStatusHandler?(inputStatus) + } /** Suspends the execution of this upload. Temp files and state will not be changed. The upload will remain paused in this state diff --git a/Sources/MuxUploadSDK/PublicAPI/UploadManager.swift b/Sources/MuxUploadSDK/PublicAPI/UploadManager.swift index 354405da..8fdeed50 100644 --- a/Sources/MuxUploadSDK/PublicAPI/UploadManager.swift +++ b/Sources/MuxUploadSDK/PublicAPI/UploadManager.swift @@ -100,7 +100,7 @@ public final class UploadManager { internal func acknowledgeUpload(id: String) { if let uploader = uploadsByID[id] { uploadsByID.removeValue(forKey: id) - uploader.cancel() + uploader.fileWorker?.cancel() } Task.detached { await self.uploadActor.remove(uploadID: id) @@ -141,10 +141,12 @@ public final class UploadManager { private func notifyDelegates() { Task.detached { await MainActor.run { - self.uploadsUpdateDelegatesByToken - .map { it in it.value } - .forEach { it in it.uploadListUpdated(with: self.allManagedUploads()) } - + let delegates = self.uploadsUpdateDelegatesByToken.values + let allManagedUploads = self.allManagedUploads() + + for delegate in delegates { + delegate.uploadListUpdated(with: allManagedUploads) + } } } } diff --git a/Sources/MuxUploadSDK/Upload/ChunkedFileUploader.swift b/Sources/MuxUploadSDK/Upload/ChunkedFileUploader.swift index 6b247588..2f848ffa 100644 --- a/Sources/MuxUploadSDK/Upload/ChunkedFileUploader.swift +++ b/Sources/MuxUploadSDK/Upload/ChunkedFileUploader.swift @@ -16,14 +16,13 @@ class ChunkedFileUploader { private(set) var currentState: InternalUploadState = .ready let uploadInfo: UploadInfo let inputFileURL: URL - private var delegates: [String : ChunkedFileUploaderDelegate] = [:] private let file: ChunkedFile private var currentWorkTask: Task<(), Never>? = nil private var overallProgress: Progress = Progress() private var lastReadCount: UInt64 = 0 - private let reporter = Reporter() + private let reporter: Reporter func addDelegate(withToken token: String, _ delegate: ChunkedFileUploaderDelegate) { delegates.updateValue(delegate, forKey: token) @@ -68,6 +67,16 @@ class ChunkedFileUploader { MuxUploadSDK.logger?.info("start() ignored in state \(String(describing: self.currentState))") } } + + func start(duration: CMTime) { + switch currentState { + case .ready: fallthrough + case .paused(_): + beginUpload(duration: duration) + default: + MuxUploadSDK.logger?.info("start() ignored in state \(String(describing: self.currentState))") + } + } /// Cancels the upload. It can't be restarted func cancel() { @@ -86,8 +95,24 @@ class ChunkedFileUploader { private func beginUpload() { let task = Task.detached { [self] in + + let asset = AVAsset(url: inputFileURL) + + var duration: CMTime + + do { + if #available(iOS 15, *) { + duration = try await asset.load(.duration) + } else { + await asset.loadValues(forKeys: ["duration"]) + duration = asset.duration + } + } catch { + // Cannot get duration, assume it is zero + duration = CMTime.zero + } + do { - // It's fine if it's already open, that's handled by ignoring the call let fileSize = try FileManager.default.fileSizeOfItem( atPath: inputFileURL.path ) @@ -100,26 +125,108 @@ class ChunkedFileUploader { finishTime: result.updateTime ) - let asset = AVAsset(url: inputFileURL) + reporter.reportUploadSuccess( + inputDuration: duration.seconds, + inputSize: fileSize, + options: uploadInfo.options, + uploadEndTime: Date( + timeIntervalSince1970: success.finishTime + ), + uploadStartTime: Date( + timeIntervalSince1970: success.startTime + ), + uploadURL: uploadInfo.uploadURL + ) + notifyStateFromWorker(.success(success)) + } catch { + handle( + error: error, + duration: duration + ) + } + } + currentWorkTask = task + } - var duration: CMTime - if #available(iOS 15, *) { - duration = try await asset.load(.duration) - } else { - await asset.loadValues(forKeys: ["duration"]) - duration = asset.duration - } + private func handle( + error: Error, + duration: CMTime + ) { + file.close() + if error is CancellationError { + MuxUploadSDK.logger?.debug("Task finished due to cancellation in state \(String(describing: self.currentState))") + if case let .uploading(update) = self.currentState { + self.currentState = .paused(update) + } + } else { + MuxUploadSDK.logger?.debug("Task finished due to error in state \(String(describing: self.currentState))") + let uploadError = InternalUploaderError(reason: error, lastByte: lastReadCount) - if !uploadInfo.options.eventTracking.optedOut { - reporter.report( - startTime: success.startTime, - endTime: success.finishTime, - fileSize: fileSize, - videoDuration: duration.seconds, - uploadURL: uploadInfo.uploadURL - ) - } + let lastUpdate: Update? + if case InternalUploadState.uploading(let update) = currentState { + lastUpdate = update + } else { + lastUpdate = nil + } + + // This modifies currentState, so capture + // the last update first + notifyStateFromWorker(.failure(uploadError)) + + // FIXME: Will only work if currentState + // was uploading before the upload failed + // may miss some edge cases + if let lastUpdate { + let fileSize = (try? FileManager.default.fileSizeOfItem(atPath: inputFileURL.path)) ?? 0 + + let startTime = Date( + timeIntervalSince1970: lastUpdate.startTime + ) + // When failing assume transport ends + // when error is received + let endTime = Date() + + reporter.reportUploadFailure( + errorDescription: uploadError.localizedDescription, + inputDuration: duration.seconds, + inputSize: fileSize, + options: uploadInfo.options, + uploadEndTime: endTime, + uploadStartTime: startTime, + uploadURL: uploadInfo.uploadURL + ) + } + } + } + + private func beginUpload(duration: CMTime) { + let task = Task.detached { [self] in + do { + // It's fine if it's already open, that's handled by ignoring the call + let fileSize = try FileManager.default.fileSizeOfItem( + atPath: inputFileURL.path + ) + let result = try await makeWorker().performUpload() + file.close() + + let success = UploadResult( + finalProgress: result.progress, + startTime: result.startTime, + finishTime: result.updateTime + ) + reporter.reportUploadSuccess( + inputDuration: duration.seconds, + inputSize: fileSize, + options: uploadInfo.options, + uploadEndTime: Date( + timeIntervalSince1970: success.finishTime + ), + uploadStartTime: Date( + timeIntervalSince1970: success.startTime + ), + uploadURL: uploadInfo.uploadURL + ) notifyStateFromWorker(.success(success)) } catch { file.close() @@ -131,7 +238,43 @@ class ChunkedFileUploader { } else { MuxUploadSDK.logger?.debug("Task finished due to error in state \(String(describing: self.currentState))") let uploadError = InternalUploaderError(reason: error, lastByte: lastReadCount) + + let lastUpdate: Update? + if case InternalUploadState.uploading(let update) = currentState { + lastUpdate = update + } else { + lastUpdate = nil + } + + // This modifies currentState, so capture + // the last update first notifyStateFromWorker(.failure(uploadError)) + + // FIXME: Will only work if currentState + // was uploading before the upload failed + // may miss some edge cases + if let lastUpdate { + let fileSize = try? FileManager.default.fileSizeOfItem( + atPath: inputFileURL.path + ) + + let startTime = Date( + timeIntervalSince1970: lastUpdate.startTime + ) + // When failing assume transport ends + // when error is received + let endTime = Date() + + reporter.reportUploadFailure( + errorDescription: uploadError.localizedDescription, + inputDuration: duration.seconds, + inputSize: fileSize ?? 0, + options: uploadInfo.options, + uploadEndTime: endTime, + uploadStartTime: startTime, + uploadURL: uploadInfo.uploadURL + ) + } } } } @@ -207,6 +350,7 @@ class ChunkedFileUploader { self.file = file self.lastReadCount = startingByte self.inputFileURL = inputFileURL + self.reporter = Reporter.shared } enum InternalUploadState { @@ -271,7 +415,6 @@ fileprivate actor Worker { let fileSize = try FileManager.default.fileSizeOfItem( atPath: inputFileURL.path ) - let wideFileSize: Int64 // Prevent overflow if UInt64 exceeds Int64.max diff --git a/Tests/MuxUploadSDKTests/PublicAPITests/MuxUploadTests.swift b/Tests/MuxUploadSDKTests/PublicAPITests/MuxUploadTests.swift index 09242fa4..1fe23abe 100644 --- a/Tests/MuxUploadSDKTests/PublicAPITests/MuxUploadTests.swift +++ b/Tests/MuxUploadSDKTests/PublicAPITests/MuxUploadTests.swift @@ -46,7 +46,7 @@ class MuxUploadTest: XCTestCase { } func testInputInspectionSuccess() throws { - let input = try UploadInput.mockStartedInput() + let input = try UploadInput.mockReadyInput() let upload = MuxUpload( input: input, @@ -82,7 +82,7 @@ class MuxUploadTest: XCTestCase { } func testInputInspectionFailure() throws { - let input = try UploadInput.mockStartedInput() + let input = try UploadInput.mockReadyInput() let upload = MuxUpload( input: input, diff --git a/Tests/MuxUploadSDKTests/Test Doubles/MockUploadInputInspector.swift b/Tests/MuxUploadSDKTests/Test Doubles/MockUploadInputInspector.swift index a88f7a9a..3aaaed9b 100644 --- a/Tests/MuxUploadSDKTests/Test Doubles/MockUploadInputInspector.swift +++ b/Tests/MuxUploadSDKTests/Test Doubles/MockUploadInputInspector.swift @@ -10,17 +10,17 @@ import Foundation class MockUploadInputInspector: UploadInputInspector { static let alwaysStandard: MockUploadInputInspector = MockUploadInputInspector( - mockInspectionResult: .standard + mockInspectionResult: .standard(duration: .zero) ) static let alwaysFailing: MockUploadInputInspector = MockUploadInputInspector( - mockInspectionResult: .inspectionFailure + mockInspectionResult: .inspectionFailure(duration: .zero) ) var mockInspectionResult: UploadInputFormatInspectionResult init() { - self.mockInspectionResult = .standard + self.mockInspectionResult = .standard(duration: .zero) } init( diff --git a/Tests/MuxUploadSDKTests/Test Doubles/UploadInput+Fixtures.swift b/Tests/MuxUploadSDKTests/Test Doubles/UploadInput+Fixtures.swift index 23bbe4fd..0fec1649 100644 --- a/Tests/MuxUploadSDKTests/Test Doubles/UploadInput+Fixtures.swift +++ b/Tests/MuxUploadSDKTests/Test Doubles/UploadInput+Fixtures.swift @@ -10,6 +10,30 @@ import XCTest extension UploadInput { + static func mockReadyInput() throws -> Self { + let uploadURL = try XCTUnwrap( + URL(string: "https://www.example.com/upload") + ) + + let videoInputURL = try XCTUnwrap( + URL(string: "file://path/to/dummy/file/") + ) + + let uploadInputAsset = AVAsset( + url: videoInputURL + ) + + return UploadInput( + status: .ready( + uploadInputAsset, + UploadInfo( + uploadURL: uploadURL, + options: .default + ) + ) + ) + } + static func mockStartedInput() throws -> Self { let uploadURL = try XCTUnwrap( URL(string: "https://www.example.com/upload") diff --git a/Tests/MuxUploadSDKTests/Upload Tests/ReporterTests.swift b/Tests/MuxUploadSDKTests/Upload Tests/ReporterTests.swift index 4d35e05b..cef17768 100644 --- a/Tests/MuxUploadSDKTests/Upload Tests/ReporterTests.swift +++ b/Tests/MuxUploadSDKTests/Upload Tests/ReporterTests.swift @@ -9,37 +9,160 @@ import XCTest class ReporterTests: XCTestCase { - func testUploadEventSerialization() throws { - let reporter = Reporter() - reporter.pendingUploadEvent = UploadEvent( - startTime: 100, - endTime: 103, - fileSize: 1_500_000, - videoDuration: 3.14, - uploadURL: URL(string: "https://www.example.com")!, - sdkVersion: "0.3.1", - osName: "iPadOS", - osVersion: "16.2", + enum ExpectedJSONStrings { + static let inputStandardizationFailed = #"{"data":{"app_name":"AcmeApp","app_version":"3.2.1","device_model":"iPad","error_description":"foo","input_duration":3.1400000000000001,"input_size":1500000,"maximum_resolution":"default","non_standard_input_reasons":[],"platform_name":"iPadOS","platform_version":"15.0.0","sdk_version":"0.4.1","standardization_end_time":"2023-07-07T03:43:58Z","standardization_start_time":"2023-07-07T03:38:58Z","upload_canceled":false,"upload_url":"https:\/\/www.example.com"},"session_id":"xyz789","type":"upload_input_standardization_failed","version":"1"}"# + + static let inputStandardizationSucceeded = #"{"data":{"app_name":"AcmeApp","app_version":"3.2.1","device_model":"iPad","input_duration":3.1400000000000001,"input_size":1500000,"maximum_resolution":"default","non_standard_input_reasons":[],"platform_name":"iPadOS","platform_version":"15.0.0","sdk_version":"0.4.1","standardization_end_time":"2023-07-07T03:43:58Z","standardization_start_time":"2023-07-07T03:38:58Z","upload_url":"https:\/\/www.example.com"},"session_id":"jkl567","type":"upload_input_standardization_succeeded","version":"1"}"# + + static let uploadFailed = #"{"data":{"app_name":"AcmeApp","app_version":"3.2.1","device_model":"iPad","error_description":"foo","input_duration":3.1400000000000001,"input_size":1500000,"input_standardization_enabled":false,"platform_name":"iPadOS","platform_version":"16.2.0","region_code":"US","sdk_version":"0.3.0","upload_end_time":"2023-07-07T04:12:48Z","upload_start_time":"2023-07-07T04:12:18Z","upload_url":"https:\/\/www.example.com"},"session_id":"abc123","type":"upload_failed","version":"1"}"# + + static let uploadSucceeded = #"{"data":{"app_name":"AcmeApp","app_version":"3.2.1","device_model":"iPad","input_duration":3.1400000000000001,"input_size":1500000,"input_standardization_enabled":true,"platform_name":"iPadOS","platform_version":"16.2.0","region_code":"US","sdk_version":"0.3.0","upload_end_time":"2023-07-07T04:12:48Z","upload_start_time":"2023-07-07T04:12:18Z","upload_url":"https:\/\/www.example.com"},"session_id":"abc123","type":"upload_succeeded","version":"1"}"# + } + + var jsonEncoder = Reporter().jsonEncoder + + func testInputStandardizationFailedEventSerialization() throws { + let data = InputStandardizationFailedEvent.Data( + appName: "AcmeApp", + appVersion: "3.2.1", + deviceModel: "iPad", + errorDescription: "foo", + inputDuration: 3.14, + inputSize: 1_500_000, + maximumResolution: "default", + nonStandardInputReasons: [], + platformName: "iPadOS", + platformVersion: "15.0.0", + sdkVersion: "0.4.1", + standardizationStartTime: Date(timeIntervalSince1970: 1688701138.45), + standardizationEndTime: Date(timeIntervalSince1970: 1688701438.45), + uploadCanceled: false, + uploadURL: URL(string: "https://www.example.com")! + ) + + let event = InputStandardizationFailedEvent( + sessionID: "xyz789", + data: data + ) + + let json = try XCTUnwrap( + String( + data: jsonEncoder.encode(event), + encoding: .utf8 + ) + ) + + XCTAssertEqual( + json, + ExpectedJSONStrings.inputStandardizationFailed + ) + } + + func testInputStandardizationSucceededEventSerialization() throws { + let data = InputStandardizationSucceededEvent.Data( + appName: "AcmeApp", + appVersion: "3.2.1", + deviceModel: "iPad", + inputDuration: 3.14, + inputSize: 1_500_000, + maximumResolution: "default", + nonStandardInputReasons: [], + platformName: "iPadOS", + platformVersion: "15.0.0", + sdkVersion: "0.4.1", + standardizationStartTime: Date(timeIntervalSince1970: 1688701138.45), + standardizationEndTime: Date(timeIntervalSince1970: 1688701438.45), + uploadURL: URL(string: "https://www.example.com")! + ) + + let event = InputStandardizationSucceededEvent( + sessionID: "jkl567", + data: data + ) + + let json = try XCTUnwrap( + String( + data: jsonEncoder.encode(event), + encoding: .utf8 + ) + ) + + XCTAssertEqual( + json, + ExpectedJSONStrings.inputStandardizationSucceeded + ) + } + + func testUploadFailedEventSerialization() throws { + let data = UploadFailedEvent.Data( + appName: "AcmeApp", + appVersion: "3.2.1", deviceModel: "iPad", - appName: "foo", - appVersion: "14.3.1", - regionCode: "US" + errorDescription: "foo", + inputDuration: 3.14, + inputSize: 1_500_000, + inputStandardizationEnabled: false, + platformName: "iPadOS", + platformVersion: "16.2.0", + regionCode: "US", + sdkVersion: "0.3.0", + uploadStartTime: Date(timeIntervalSince1970: 1688703138.45), + uploadEndTime: Date(timeIntervalSince1970: 1688703168.45), + uploadURL: URL(string: "https://www.example.com")! ) - let serializedUploadEvent = try reporter.serializePendingEvent() + let event = UploadFailedEvent( + sessionID: "abc123", + data: data + ) let json = try XCTUnwrap( String( - data: serializedUploadEvent, + data: jsonEncoder.encode(event), encoding: .utf8 ) ) XCTAssertEqual( json, - "{\"app_name\":\"foo\",\"app_version\":\"14.3.1\",\"device_model\":\"iPad\",\"end_time\":103,\"file_size\":1500000,\"os_name\":\"iPadOS\",\"os_version\":\"16.2\",\"region_code\":\"US\",\"sdk_version\":\"0.3.1\",\"start_time\":100,\"type\":\"upload\",\"upload_url\":\"https:\\/\\/www.example.com\",\"video_duration\":3.1400000000000001}" + ExpectedJSONStrings.uploadFailed ) + } + func testUploadSucceededEventSerialization() throws { + let data = UploadSucceededEvent.Data( + appName: "AcmeApp", + appVersion: "3.2.1", + deviceModel: "iPad", + inputDuration: 3.14, + inputSize: 1_500_000, + inputStandardizationEnabled: true, + platformName: "iPadOS", + platformVersion: "16.2.0", + regionCode: "US", + sdkVersion: "0.3.0", + uploadStartTime: Date(timeIntervalSince1970: 1688703138.45), + uploadEndTime: Date(timeIntervalSince1970: 1688703168.45), + uploadURL: URL(string: "https://www.example.com")! + ) + + let event = UploadSucceededEvent( + sessionID: "abc123", + version: "1", + data: data + ) + + let json = try XCTUnwrap( + String( + data: jsonEncoder.encode(event), + encoding: .utf8 + ) + ) + + XCTAssertEqual( + json, + ExpectedJSONStrings.uploadSucceeded + ) } }