Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 42 additions & 62 deletions Sources/EZNetworking/Services/Downloader/FileDownloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,7 @@ public actor FileDownloader: FileDownloadable {
private let request: DownloadRequest
private let session: NetworkSession
private let validator: ResponseValidator

enum State: Equatable {
case idle
case downloading
case pausing
case paused(resumeData: Data)
case completed
case failed
case failedButCanResume(resumeData: Data)
case cancelled
}

var state: State = .idle
private var state: DownloadState = .idle
private var downloadTask: (any URLSessionDownloadTaskProtocol)?
private var continuation: AsyncStream<DownloadEvent>.Continuation?

Expand Down Expand Up @@ -63,23 +51,8 @@ public actor FileDownloader: FileDownloadable {
guard !Task.isCancelled else {
return AsyncStream { $0.finish() }
}

switch state {
case .idle:
break
case .downloading, .pausing, .paused:
return earlyExitStream(yielding: .failed(.downloadFailed(reason: .alreadyDownloading)))
case .failedButCanResume:
return earlyExitStream(yielding: .failed(.downloadFailed(reason: .downloadIncompleteButResumable)))
case .completed, .failed, .cancelled:
return earlyExitStream(yielding: .failed(.downloadFailed(reason: .alreadyFinished)))
}

let urlRequest: URLRequest
do {
urlRequest = try request.getURLRequest()
} catch {
return earlyExitStream(yielding: .failed(mapNetworkingError(from: error)))
if let failureReason = validateCanStartDownload() {
return earlyExitStream(yielding: .failed(.downloadFailed(reason: failureReason)))
}

let (stream, continuation) = AsyncStream<DownloadEvent>.makeStream()
Expand All @@ -92,12 +65,16 @@ public actor FileDownloader: FileDownloadable {
}
}

state = .downloading
let task = session.urlSession.downloadTaskInspectable(with: urlRequest)
downloadTask = task
task.resume()

return stream
do {
let urlRequest = try request.getURLRequest()
state = .downloading
let task = session.urlSession.downloadTaskInspectable(with: urlRequest)
downloadTask = task
task.resume()
return stream
} catch {
return earlyExitStream(yielding: .failed(mapNetworkingError(from: error)))
}
}

// MARK: pause
Expand All @@ -115,11 +92,11 @@ public actor FileDownloader: FileDownloadable {

guard case .pausing = state else { return }
guard !Task.isCancelled else {
terminateSilently(state: .cancelled)
terminate(yield: nil, state: .cancelled)
return
}
guard let resumeData else {
terminate(with: .failed(.downloadFailed(reason: .cannotResume)), state: .failed)
terminate(yield: .failed(.downloadFailed(reason: .cannotResume)), state: .failed)
return
}
state = .paused(resumeData: resumeData)
Expand All @@ -129,17 +106,9 @@ public actor FileDownloader: FileDownloadable {

public func resume() async throws {
try Task.checkCancellation()

let resumeData: Data
switch state {
case let .paused(data):
resumeData = data
case let .failedButCanResume(data):
resumeData = data
default:
guard let resumeData = state.resumeData else {
throw NetworkingError.downloadFailed(reason: .notPaused)
}

state = .downloading
let task = session.urlSession.downloadTaskInspectable(withResumeData: resumeData)
downloadTask = task
Expand All @@ -159,10 +128,25 @@ public actor FileDownloader: FileDownloadable {
}

downloadTask?.cancel()
terminateSilently(state: .cancelled)
terminate(yield: nil, state: .cancelled)
}
}

// MARK: - Helpers
// MARK: - Helpers

extension FileDownloader {
private func validateCanStartDownload() -> DownloadFailureReason? {
switch state {
case .idle:
return nil // OK to proceed
case .downloading, .pausing, .paused:
return .alreadyDownloading
case .failedButCanResume:
return .downloadIncompleteButResumable
case .completed, .failed, .cancelled:
return .alreadyFinished
}
}

private nonisolated func earlyExitStream(yielding value: DownloadEvent) -> AsyncStream<DownloadEvent> {
AsyncStream { continuation in
Expand All @@ -171,19 +155,15 @@ public actor FileDownloader: FileDownloadable {
}
}

/// Moves to a terminal state, yields a final event, and closes the stream.
private func terminate(with event: DownloadEvent, state newState: State) {
state = newState
downloadTask = nil
continuation?.yield(event)
continuation?.finish()
continuation = nil
}

/// Moves to a terminal state and closes the stream without yielding any event.
private func terminateSilently(state newState: State) {
/// Moves to a terminal state and closes the stream.
/// - If `event` is non-nil, it is yielded before the stream is finished.
/// - If `event` is nil, the stream is finished without yielding.
private func terminate(yield event: DownloadEvent?, state newState: DownloadState) {
state = newState
downloadTask = nil
if let event {
continuation?.yield(event)
}
continuation?.finish()
continuation = nil
}
Expand Down Expand Up @@ -213,7 +193,7 @@ public actor FileDownloader: FileDownloadable {
case .downloading, .pausing: break
default: return
}
terminate(with: .completed(location), state: .completed)
terminate(yield: .completed(location), state: .completed)

case let .onDownloadFailed(error, resumeData):
guard case .downloading = state else { return }
Expand All @@ -227,7 +207,7 @@ public actor FileDownloader: FileDownloadable {
continuation?.yield(.failed(resumableError))
} else {
let networkError = mapNetworkingError(from: error)
terminate(with: .failed(networkError), state: .failed)
terminate(yield: .failed(networkError), state: .failed)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

enum DownloadState: Equatable {
case idle
case downloading
case pausing
case paused(resumeData: Data)
case completed
case failed
case failedButCanResume(resumeData: Data)
case cancelled

var resumeData: Data? {
switch self {
case let .paused(data):
return data
case let .failedButCanResume(data):
return data
default:
return nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ final class FileDownloaderCoreFunctionalityTests {

_ = await sut.downloadFileStream()
#expect(mockURLSession.mockDownloadTask.didResume)
#expect(await sut.state == .downloading)
}

@Test("test calling FileDownloader.cancel() calls downloadTask.cancel()")
Expand All @@ -26,7 +25,6 @@ final class FileDownloaderCoreFunctionalityTests {
_ = await sut.downloadFileStream()
try await sut.cancel()
#expect(mockURLSession.mockDownloadTask.didCancel)
#expect(await sut.state == .cancelled)
}

@Test("test calling FileDownloader.pause() calls downloadTask.cancelWhileProducingResumeData()")
Expand Down Expand Up @@ -61,7 +59,6 @@ final class FileDownloaderCoreFunctionalityTests {
#expect(events == [
.completed(mockFileLocation)
])
#expect(await sut.state == .completed)
}

@Test("test download success with progress")
Expand All @@ -85,7 +82,6 @@ final class FileDownloaderCoreFunctionalityTests {
.progress(0.5),
.completed(mockFileLocation)
])
#expect(await sut.state == .completed)
}

// MARK: Download failure
Expand All @@ -111,7 +107,6 @@ final class FileDownloaderCoreFunctionalityTests {
.progress(0.5),
.failed(.downloadFailed(reason: .urlError(underlying: URLError(.networkConnectionLost))))
])
#expect(await sut.state == .failed)
}

@Test("test download failure due to non 2xx status code before complete")
Expand All @@ -132,7 +127,6 @@ final class FileDownloaderCoreFunctionalityTests {
#expect(events == [
.failed(.responseValidationFailed(reason: .badHTTPResponse(underlying: HTTPResponse(statusCode: 500))))
])
#expect(await sut.state == .failed)
}

@Test("test download failure due to unknown error before complete")
Expand All @@ -157,7 +151,6 @@ final class FileDownloaderCoreFunctionalityTests {
.progress(0.5),
.failed(.downloadFailed(reason: .unknownError(underlying: UnknownError.error)))
])
#expect(await sut.state == .failed)
}

// MARK: Cancel
Expand All @@ -183,7 +176,6 @@ final class FileDownloaderCoreFunctionalityTests {
#expect(events == [
.progress(0.5)
])
#expect(await sut.state == .cancelled)
}

// MARK: Pause
Expand All @@ -210,7 +202,6 @@ final class FileDownloaderCoreFunctionalityTests {
.progress(0.5),
.failed(.downloadFailed(reason: .cannotResume))
])
#expect(await sut.state == .failed)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ final class FileDownloaderInvalidStateTests {
let sut = FileDownloader(request: mockRequest, session: session)

_ = await sut.downloadFileStream()
#expect(await sut.state == .downloading)

let secondStream = await sut.downloadFileStream()
var events: [DownloadEvent] = []
Expand All @@ -36,7 +35,6 @@ final class FileDownloaderInvalidStateTests {

// Drain the first stream to let state reach .completed
for await _ in firstStream {}
#expect(await sut.state == .completed)

let secondStream = await sut.downloadFileStream()
var events: [DownloadEvent] = []
Expand All @@ -62,7 +60,6 @@ final class FileDownloaderInvalidStateTests {
let mockResumeData = "partial".data(using: .utf8)!
downloadInterceptor.simulateFailure(URLError(.networkConnectionLost), resumeData: mockResumeData)
try await Task.sleep(for: .milliseconds(10))
#expect(await sut.state == .failedButCanResume(resumeData: mockResumeData))

let secondStream = await sut.downloadFileStream()
var events: [DownloadEvent] = []
Expand All @@ -83,7 +80,6 @@ final class FileDownloaderInvalidStateTests {
let session = MockSession(urlSession: MockFileDownloaderURLSession(), delegate: SessionDelegate())
let sut = FileDownloader(request: mockRequest, session: session)

#expect(await sut.state == .idle)
await #expect(throws: NetworkingError.downloadFailed(reason: .notDownloading)) {
try await sut.pause()
}
Expand All @@ -94,7 +90,6 @@ final class FileDownloaderInvalidStateTests {
let session = MockSession(urlSession: MockFileDownloaderURLSession(), delegate: SessionDelegate())
let sut = FileDownloader(request: mockRequest, session: session)

#expect(await sut.state == .idle)
await #expect(throws: NetworkingError.downloadFailed(reason: .notPaused)) {
try await sut.resume()
}
Expand All @@ -105,7 +100,6 @@ final class FileDownloaderInvalidStateTests {
let session = MockSession(urlSession: MockFileDownloaderURLSession(), delegate: SessionDelegate())
let sut = FileDownloader(request: mockRequest, session: session)

#expect(await sut.state == .idle)
await #expect(throws: NetworkingError.downloadFailed(reason: .notDownloading)) {
try await sut.cancel()
}
Expand Down
Loading
Loading