Skip to content

Commit

Permalink
Create RelativeFilePath to fix background downloads not resuming
Browse files Browse the repository at this point in the history
  • Loading branch information
mohamede1945 committed Jul 11, 2023
1 parent cdc35d0 commit 5ac8b06
Show file tree
Hide file tree
Showing 41 changed files with 296 additions and 175 deletions.
6 changes: 6 additions & 0 deletions Core/AsyncUtilitiesForTesting/AsyncAlgorithms++.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ extension AsyncChannel {
return await iterator.next()
}
}

extension AsyncChannel where Element == Void {
public func send() async {
await send(())
}
}
15 changes: 15 additions & 0 deletions Core/SystemDependencies/FileSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Foundation
import Utilities

public protocol FileSystem: Sendable {
func fileExists(at url: URL) -> Bool
Expand Down Expand Up @@ -43,3 +44,17 @@ public protocol ResourceValues {
}

extension URLResourceValues: ResourceValues { }

public extension FileSystem {
func contentsOfDirectory(at path: RelativeFilePath, includingPropertiesForKeys keys: [URLResourceKey]?) throws -> [URL] {
try contentsOfDirectory(at: path.url, includingPropertiesForKeys: keys)
}

func fileExists(at path: RelativeFilePath) -> Bool {
fileExists(at: path.url)
}

func removeItem(at path: RelativeFilePath) throws {
try removeItem(at: path.url)
}
}
91 changes: 91 additions & 0 deletions Core/Utilities/Sources/Features/RelativeFilePath.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// RelativeFilePath.swift
//
//
// Created by Mohamed Afifi on 2023-07-09.
//

import Foundation

public struct RelativeFilePath: Hashable, Sendable {
// MARK: Lifecycle

public init(_ path: String, isDirectory: Bool) {
self.path = path
self.isDirectory = isDirectory
}

// MARK: Public

public let path: String

public var url: URL { FileManager.documentsURL.appendingPathComponent(path, isDirectory: isDirectory) }

// MARK: Private

private let isDirectory: Bool
}

public extension RelativeFilePath {
var isReachable: Bool { url.isReachable }

func isParent(of child: RelativeFilePath) -> Bool {
child.path.hasPrefix(path)
}

var lastPathComponent: String { url.lastPathComponent }

func appendingPathComponent(_ pathComponent: String, isDirectory: Bool) -> RelativeFilePath {
RelativeFilePath(path.stringByAppendingPath(pathComponent), isDirectory: isDirectory)
}

func appendingPathExtension(_ pathExtension: String) -> RelativeFilePath {
RelativeFilePath(path.appending("." + pathExtension), isDirectory: false)
}

func deletingLastPathComponent() -> RelativeFilePath {
RelativeFilePath(path.stringByDeletingLastPathComponent, isDirectory: true)
}

func deletingPathExtension() -> RelativeFilePath {
RelativeFilePath(path.stringByDeletingPathExtension, isDirectory: true)
}
}

public extension FileManager {
func removeItem(at path: RelativeFilePath) throws {
try removeItem(at: path.url)
}

func createDirectory(at path: RelativeFilePath, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]? = nil) throws {
try createDirectory(at: path.url, withIntermediateDirectories: withIntermediateDirectories, attributes: attributes)
}

func moveItem(at src: URL, to dst: RelativeFilePath) throws {
try moveItem(at: src, to: dst.url)
}

func copyItem(at src: URL, to dst: RelativeFilePath) throws {
try copyItem(at: src, to: dst.url)
}
}

public extension Data {
init(contentsOf path: RelativeFilePath, options: Data.ReadingOptions = []) throws {
try self.init(contentsOf: path.url, options: options)
}

func write(to path: RelativeFilePath, options: Data.WritingOptions = []) throws {
try write(to: path.url, options: options)
}
}

public extension String {
init(contentsOf path: RelativeFilePath) throws {
try self.init(contentsOf: path.url)
}

func write(to path: RelativeFilePath, atomically: Bool, encoding: String.Encoding) throws {
try write(to: path.url, atomically: atomically, encoding: encoding)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import Crashing
import Foundation
import NetworkSupport
import Utilities
import VLogging

actor DownloadSessionDelegate: NetworkSessionDelegate {
Expand Down Expand Up @@ -67,11 +68,11 @@ actor DownloadSessionDelegate: NetworkSessionDelegate {
}
let fileManager = FileManager.default

let resumeURL = response.request.resumeURL
let destinationURL = response.request.destinationURL
let resumePath = response.request.resumePath
let destinationURL = response.request.destination

// remove the resume data
try? fileManager.removeItem(at: resumeURL)
try? fileManager.removeItem(at: resumePath)
// remove the existing file if exist.
try? fileManager.removeItem(at: destinationURL)

Expand All @@ -94,6 +95,7 @@ actor DownloadSessionDelegate: NetworkSessionDelegate {
}

func networkSession(_ session: NetworkSession, task: NetworkSessionTask, didCompleteWithError sessionError: Error?) async {
logger.info("Finished downloading \(describe(task)). Error: \(String(describing: sessionError))")
guard let response = await dataController.downloadRequestResponse(for: task) else {
if let sessionError, !sessionError.isCancelled {
logger.warning("[networkSession:didCompleteWithError] Cannot find onGoingDownloads for task \(describe(task))")
Expand All @@ -110,7 +112,7 @@ actor DownloadSessionDelegate: NetworkSessionDelegate {
return
}

let finalError = wrap(error: error, resumeURL: response.request.resumeURL)
let finalError = wrap(error: error, resumePath: response.request.resumePath)
await dataController.downloadFailed(response, with: finalError)
}

Expand All @@ -126,13 +128,13 @@ actor DownloadSessionDelegate: NetworkSessionDelegate {

private let dataController: DownloadBatchDataController

private func wrap(error theError: Error, resumeURL: URL) -> Error {
private func wrap(error theError: Error, resumePath: RelativeFilePath) -> Error {
var error = theError

// save resume data, if found
if let resumeData = error.resumeData {
do {
try resumeData.write(to: resumeURL, options: [.atomic])
try resumeData.write(to: resumePath, options: [.atomic])
} catch {
crasher.recordError(error, reason: "Error while saving resume data.")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import Foundation
import GRDB
import SQLitePersistence
import Utilities
import VLogging

struct GRDBDownloadsPersistence: DownloadsPersistence {
Expand Down Expand Up @@ -90,8 +91,7 @@ struct GRDBDownloadsPersistence: DownloadsPersistence {
table.column("url", .text)
.notNull()
.indexed()
table.column("resumeURL", .text).notNull()
table.column("destinationURL", .text).notNull()
table.column("destination", .text).notNull()
table.column("status", .integer).notNull()
table.column("taskId", .integer)
}
Expand Down Expand Up @@ -132,13 +132,14 @@ private struct GRDBDownload: Identifiable, Codable, FetchableRecord, MutablePers
static let taskId = Column(CodingKeys.taskId)
}

// MARK: Internal

static let downloadBatch = belongsTo(GRDBDownloadBatch.self)

var id: Int64?
var downloadBatchId: Int64
var url: URL
var resumeURL: URL
var destinationURL: URL
var destination: String
var status: Download.Status
var taskId: Int?

Expand All @@ -152,16 +153,15 @@ extension GRDBDownload {
init(_ download: Download) {
downloadBatchId = download.batchId
url = download.request.url
resumeURL = download.request.resumeURL
destinationURL = download.request.destinationURL
destination = download.request.destination.path
status = download.status
taskId = download.taskId
}

func toDownload() -> Download {
Download(
taskId: taskId,
request: DownloadRequest(url: url, destinationURL: destinationURL),
request: DownloadRequest(url: url, destination: RelativeFilePath(destination, isDirectory: false)),
status: status,
batchId: downloadBatchId
)
Expand Down
19 changes: 11 additions & 8 deletions Data/BatchDownloader/Sources/Entities/DownloadRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,30 @@

import Foundation
import NetworkSupport
import Utilities

public struct DownloadRequest: Hashable, Sendable {
// MARK: Lifecycle

public init(url: URL, destinationURL: URL) {
public init(url: URL, destination: RelativeFilePath) {
self.url = url
resumeURL = destinationURL.appendingPathExtension(Self.downloadResumeDataExtension)
self.destinationURL = destinationURL
self.destination = destination
}

// MARK: Public

public static let downloadResumeDataExtension = "resume"

public let url: URL
public let resumeURL: URL
public let destinationURL: URL
public let destination: RelativeFilePath

public var resumePath: RelativeFilePath { destination.appendingPathExtension(Self.downloadResumeDataExtension) }

public var request: URLRequest {
URLRequest(url: url)
}

// MARK: Private

private static let downloadResumeDataExtension = ".resume"
}

public struct DownloadBatchRequest: Hashable, Sendable {
Expand All @@ -57,7 +60,7 @@ public struct DownloadBatchRequest: Hashable, Sendable {

extension NetworkSession {
func downloadTask(with request: DownloadRequest) -> NetworkSessionDownloadTask {
if let data = try? Data(contentsOf: request.resumeURL) {
if let data = try? Data(contentsOf: request.resumePath) {
return downloadTask(withResumeData: data)
} else {
return downloadTask(with: URLRequest(url: request.url))
Expand Down
10 changes: 5 additions & 5 deletions Data/BatchDownloader/Tests/DownloadManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ final class DownloadManagerTests: XCTestCase {
let task: SessionTask
let text: String
let source: URL
let destination: URL
let destination: RelativeFilePath
let progressLoops: Int
let listener: HistoryProgressListener
let progress: CurrentValueSubject<DownloadProgress, Error>
Expand Down Expand Up @@ -136,8 +136,8 @@ final class DownloadManagerTests: XCTestCase {

// 2nd task
await assertThrows(responseToFail.progress.values(), NetworkError.serverNotReachable)
await AsyncAssertEqual(requestToFail.resumeURL.isReachable, false)
await AsyncAssertEqual(requestToFail.destinationURL.isReachable, false)
await AsyncAssertEqual(requestToFail.resumePath.isReachable, false)
await AsyncAssertEqual(requestToFail.destination.isReachable, false)

// other tasks should be cancelled
for request in batch.requests.filter({ !startedRequests.contains($0) }) {
Expand Down Expand Up @@ -165,7 +165,7 @@ final class DownloadManagerTests: XCTestCase {

// verify task
await assertThrows(details.progress.values(), NetworkError.connectionLost)
try await AsyncAssertEqual(try String(contentsOf: request1.resumeURL), resumeText)
try await AsyncAssertEqual(try String(contentsOf: request1.resumePath), resumeText)
}

func testDownloadBatchAfterEnquingThem() async throws {
Expand Down Expand Up @@ -333,7 +333,7 @@ final class DownloadManagerTests: XCTestCase {
task: task,
text: text,
source: source,
destination: request.destinationURL,
destination: request.destination,
progressLoops: progressLoops,
listener: listener,
progress: details.progress
Expand Down
30 changes: 22 additions & 8 deletions Data/BatchDownloaderFake/BatchDownloaderFake.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,47 @@
// Created by Mohamed Afifi on 2023-06-03.
//

import AsyncAlgorithms
import Foundation
import NetworkSupportFake
import Utilities
import XCTest
@testable import BatchDownloader

public enum BatchDownloaderFake {
// MARK: Public

public static let maxSimultaneousDownloads = 3
public static let downloadsURL = FileManager.documentsURL.appendingPathComponent(downloads)
public static let downloadsURL = RelativeFilePath(downloads, isDirectory: true)

public static func makeDownloader(downloads: [SessionTask] = []) async -> (DownloadManager, NetworkSessionFake) {
try? FileManager.default.createDirectory(at: Self.downloadsURL, withIntermediateDirectories: true)
let downloadsDBURL = Self.downloadsURL.appendingPathComponent("ongoing-downloads.db")
let downloadsDBPath = Self.downloadsURL.appendingPathComponent("ongoing-downloads.db", isDirectory: false)

let persistence = GRDBDownloadsPersistence(fileURL: downloadsDBURL)
var session: NetworkSessionFake!
let persistence = GRDBDownloadsPersistence(fileURL: downloadsDBPath.url)
actor SessionActor {
var session: NetworkSessionFake!
let channel = AsyncChannel<Void>()
func setSession(_ session: NetworkSessionFake) async {
self.session = session
await channel.send()
}
}
let sessionActor = SessionActor()
let downloader = DownloadManager(
maxSimultaneousDownloads: maxSimultaneousDownloads,
sessionFactory: { delegate, queue in
session = NetworkSessionFake(queue: queue, delegate: delegate, downloads: downloads)
let session = NetworkSessionFake(queue: queue, delegate: delegate, downloads: downloads)
Task {
await sessionActor.setSession(session)
}
return session
},
persistence: persistence
)
await downloader.start()
return (downloader, session)
await sessionActor.channel.next()
return (downloader, await sessionActor.session)
}

public static func tearDown() {
Expand All @@ -41,12 +55,12 @@ public enum BatchDownloaderFake {
public static func makeDownloadRequest(_ id: String) -> DownloadRequest {
DownloadRequest(
url: URL(validURL: "http://request/\(id)"),
destinationURL: downloadsURL.appendingPathComponent("/\(id).txt", isDirectory: false)
destination: downloadsURL.appendingPathComponent("/\(id).txt", isDirectory: false)
)
}

public static func createTextFile(at path: String, content: String) throws -> URL {
let directory = Self.downloadsURL.appendingPathComponent("temp")
let directory = Self.downloadsURL.appendingPathComponent("temp", isDirectory: true).url
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent(path)
let data = try XCTUnwrap(content.data(using: .utf8))
Expand Down
Loading

0 comments on commit 5ac8b06

Please sign in to comment.