diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c569b..49eb90f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### 2025-08-01 + +- Fix progress notification for `uploadFile`. +- Fix error (only visible in logs) related to `chunkedMode` and `httpBodyStream`. + ## 1.0.0 ### 2025-04-14 diff --git a/IONFileTransferLib/Delegates/IONFLTRUploadDelegate.swift b/IONFileTransferLib/Delegates/IONFLTRUploadDelegate.swift index dfe5bd8..9e86e38 100644 --- a/IONFileTransferLib/Delegates/IONFLTRUploadDelegate.swift +++ b/IONFileTransferLib/Delegates/IONFLTRUploadDelegate.swift @@ -8,6 +8,9 @@ import Foundation /// - Note: This class conforms to `URLSessionTaskDelegate` and `URLSessionDataDelegate` to handle task-specific events. class IONFLTRUploadDelegate: IONFLTRBaseDelegate { + /// the URL pointing to the file to upload + let fileURL: URL + /// The total number of bytes sent during the upload. private var totalBytesSent: Int = 0 @@ -20,7 +23,9 @@ class IONFLTRUploadDelegate: IONFLTRBaseDelegate { /// - Parameters: /// - publisher: The publisher used to send progress and success updates. /// - disableRedirects: A flag indicating whether HTTP redirects should be disabled. - override init(publisher: IONFLTRPublisher, disableRedirects: Bool) { + /// - fileURL: the URL pointing to the file to upload + init(publisher: IONFLTRPublisher, disableRedirects: Bool, fileURL: URL) { + self.fileURL = fileURL super.init(publisher: publisher, disableRedirects: disableRedirects) } } @@ -100,4 +105,15 @@ extension IONFLTRUploadDelegate: URLSessionDataDelegate { func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { receivedData.append(data) } + + /// Handles sending a body stream of the file to upload. Relevant for chunkedMode=true + /// + /// - Parameters: + /// - session: The `URLSession` containing the data task. + /// - task: The `URLSessionTask` that wiill get the input stream + func urlSession(_ session: URLSession, task: URLSessionTask, needNewBodyStream completionHandler: @escaping (InputStream?) -> Void) { + print("needNewBodyStream") + let stream = InputStream(fileAtPath: fileURL.path) + completionHandler(stream) + } } diff --git a/IONFileTransferLib/Helpers/IONFLTRURLRequestHelper.swift b/IONFileTransferLib/Helpers/IONFLTRURLRequestHelper.swift index e99fe25..38e24b8 100644 --- a/IONFileTransferLib/Helpers/IONFLTRURLRequestHelper.swift +++ b/IONFileTransferLib/Helpers/IONFLTRURLRequestHelper.swift @@ -73,18 +73,21 @@ class IONFLTRURLRequestHelper { request.setValue(mimeType, forHTTPHeaderField: "Content-Type") } } + + let fileLength = getFileSize(from: fileURL) if uploadOptions.chunkedMode { - let inputStream = InputStream(fileAtPath: fileURL.path) - request.httpBodyStream = inputStream + request.setContentLengthHeader(httpOptions: httpOptions, contentLength: fileLength) return (request, fileURL) } else if isMultipartUpload { let httpBody = try createMultipartBody(uploadOptions: uploadOptions, fileURL: fileURL, fileHelper: fileHelper, boundary: boundary) let tempFileURL = FileManager.default.temporaryDirectory.appendingPathComponent("multipart_\(UUID().uuidString).tmp") + request.setContentLengthHeader(httpOptions: httpOptions, contentLength: Int64(httpBody.count)) try httpBody.write(to: tempFileURL) return (request, tempFileURL) } + request.setContentLengthHeader(httpOptions: httpOptions, contentLength: fileLength) return (request, fileURL) } @@ -161,6 +164,25 @@ class IONFLTRURLRequestHelper { body.append("--\(boundary)--\(lineEnd)".data(using: .utf8)!) return body } + + private func getFileSize(from url: URL) -> Int64 { + do { + return try FileManager.default.attributesOfItem(atPath: url.path)[.size] as! Int64 + } catch { + return 0 + } + } +} + +private extension URLRequest { + mutating func setContentLengthHeader( + httpOptions: IONFLTRHttpOptions, + contentLength: Int64 + ) { + if (!httpOptions.headers.keys.contains("Content-Length") && contentLength > 0) { + setValue(String(contentLength), forHTTPHeaderField: "Content-Length") + } + } } private extension Data { diff --git a/IONFileTransferLib/IONFLTRManager.swift b/IONFileTransferLib/IONFLTRManager.swift index 8eb565c..0a2c163 100644 --- a/IONFileTransferLib/IONFLTRManager.swift +++ b/IONFileTransferLib/IONFLTRManager.swift @@ -87,7 +87,8 @@ public class IONFLTRManager: NSObject { let publisher = IONFLTRPublisher() let delegate = IONFLTRUploadDelegate( publisher: publisher, - disableRedirects: httpOptions.disableRedirects + disableRedirects: httpOptions.disableRedirects, + fileURL: fileURL ) let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) if uploadOptions.chunkedMode { diff --git a/IONFileTransferLibTests/Delegates/IONFLTRUploadDelegateTests.swift b/IONFileTransferLibTests/Delegates/IONFLTRUploadDelegateTests.swift index c54760d..8722f77 100644 --- a/IONFileTransferLibTests/Delegates/IONFLTRUploadDelegateTests.swift +++ b/IONFileTransferLibTests/Delegates/IONFLTRUploadDelegateTests.swift @@ -29,7 +29,8 @@ final class IONFLTRUploadDelegateTests: XCTestCase { mockPublisher = MockPublisher() delegate = IONFLTRUploadDelegate( publisher: mockPublisher, - disableRedirects: false + disableRedirects: false, + fileURL: URL(string: "somefile_path/not_relevat_for_test.txt")! ) } @@ -76,7 +77,8 @@ final class IONFLTRUploadDelegateTests: XCTestCase { func testRedirection_shouldBeCancelledWhenDisabled() { delegate = IONFLTRUploadDelegate( publisher: mockPublisher, - disableRedirects: true + disableRedirects: true, + fileURL: URL(string: "somefile_path/not_relevat_for_test.txt")! ) let task = URLSession.shared.dataTask(with: URL(string: "https://example.com")!) @@ -181,4 +183,33 @@ final class IONFLTRUploadDelegateTests: XCTestCase { XCTAssertEqual(mockPublisher.successCalled?.2, responseBody) XCTAssertEqual(mockPublisher.successCalled?.3["X-Custom"], "value") } + + func testBodyStream_retrievedFromTestFile() { + let testFileURL = FileManager.default.temporaryDirectory.appendingPathComponent("testFile.txt") + try? "Test content".write(to: testFileURL, atomically: true, encoding: .utf8) + delegate = IONFLTRUploadDelegate( + publisher: mockPublisher, + disableRedirects: true, + fileURL: testFileURL + ) + let expectation = self.expectation(description: "stream is correct") + + let task = URLSession.shared.dataTask(with: URL(string: "https://example.com")!) + delegate.urlSession( + URLSession.shared, + task: task, + needNewBodyStream: { stream in + XCTAssertNotNil(stream) + stream?.open() + var buffer = [UInt8](repeating: 0, count: 100) + let bytesRead = stream!.read(&buffer, maxLength: buffer.count) + XCTAssertGreaterThan(bytesRead, 0) + let outputString = String(bytes: buffer[0.. 0 { - data.append(buffer, count: bytesRead) - } - stream?.close() - let fileData = try Data(contentsOf: testFileURL) - - XCTAssertEqual(data, fileData) XCTAssertEqual(fileURL, testFileURL) + XCTAssertNil(configuredRequest.httpBodyStream) + XCTAssertEqual(configuredRequest.value(forHTTPHeaderField: "Content-Length"), String(fileData.count)) } func testConfigureRequestForUpload_withMultipartUpload() throws { @@ -169,7 +159,9 @@ class IONFLTRURLRequestHelperTests: XCTestCase { fileHelper: fileHelper ) + let fileData = try Data(contentsOf: testFileURL) XCTAssertEqual(configuredRequest.value(forHTTPHeaderField: "Content-Type")?.contains("multipart/form-data"), true) + XCTAssertTrue(Int(configuredRequest.value(forHTTPHeaderField: "Content-Length")!)! > fileData.count) XCTAssertTrue(FileManager.default.fileExists(atPath: tempFileURL.path)) try? FileManager.default.removeItem(at: tempFileURL) } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 5e842d6..1dd4cd7 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -18,8 +18,7 @@ default_platform(:ios) platform :ios do desc "Lane to run the unit tests" lane :unit_tests do - run_tests(device: "iPhone 8", scheme: "IONFileTransferLib", - slack_url: ENV['SLACK_WEBHOOK']) + run_tests(scheme: "IONFileTransferLib") end desc "Code coverage"