// // Download.swift // CapCollector // // Created by Christoph on 26.04.20. // Copyright © 2020 CH. All rights reserved. // import Foundation import UIKit final class Download { let serverUrl: URL let session: URLSession let delegate: Delegate private var downloadingMainImages = Set() init(server: URL) { let delegate = Delegate() self.serverUrl = server self.session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) self.delegate = delegate } // MARK: Paths private static func serverDatabaseUrl(server: URL) -> URL { server.appendingPathComponent("db.sqlite3") } var serverDatabaseUrl: URL { Download.serverDatabaseUrl(server: serverUrl) } var serverImageUrl: URL { serverUrl.appendingPathComponent("images") } private func serverImageUrl(for cap: Int, version: Int = 0) -> URL { serverImageUrl.appendingPathComponent(String(format: "%04d/%04d-%02d.jpg", cap, cap, version)) } private func serverImageCountUrl(for cap: Int) -> URL { serverUrl.appendingPathComponent("count/\(cap)") } private var serverClassifierVersionUrl: URL { serverUrl.appendingPathComponent("classifier.version") } private var serverAllCountsUrl: URL { serverUrl.appendingPathComponent("count/all") } var serverRecognitionModelUrl: URL { serverUrl.appendingPathComponent("classifier.mlmodel") } // MARK: Delegate final class Delegate: NSObject, URLSessionDownloadDelegate { typealias ProgressHandler = (_ progress: Float, _ bytesWritten: Int64, _ totalBytes: Int64) -> Void typealias CompletionHandler = (_ url: URL?) -> Void private var progress = [URLSessionDownloadTask : Float]() private var callbacks = [URLSessionDownloadTask : ProgressHandler]() private var completions = [URLSessionDownloadTask : CompletionHandler]() func registerForProgress(_ downloadTask: URLSessionDownloadTask, callback: ProgressHandler?, completion: @escaping CompletionHandler) { progress[downloadTask] = 0 callbacks[downloadTask] = callback completions[downloadTask] = completion } func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { completions[downloadTask]?(location) callbacks[downloadTask] = nil progress[downloadTask] = nil completions[downloadTask] = nil } func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { let ratio = totalBytesExpectedToWrite > 0 ? Float(totalBytesWritten) / Float(totalBytesExpectedToWrite) : 0 progress[downloadTask] = ratio callbacks[downloadTask]?(ratio, totalBytesWritten, totalBytesExpectedToWrite) } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { guard let downloadTask = task as? URLSessionDownloadTask else { return } completions[downloadTask]?(nil) callbacks[downloadTask] = nil progress[downloadTask] = nil completions[downloadTask] = nil } } // MARK: Downloading data /** Download an image for a cap. - Parameter cap: The id of the cap. - Parameter version: The image version to download. - Parameter completion: A closure with the resulting image - Note: The closure will be called from the main queue. - Returns: `true`, of the file download was started, `false`, if the image is already downloading. */ @discardableResult func mainImage(for cap: Int, completion: ((_ image: UIImage?) -> Void)?) -> Bool { let url = serverImageUrl(for: cap) let query = "Main image of cap \(cap)" guard !downloadingMainImages.contains(cap) else { return false } downloadingMainImages.insert(cap) let task = session.downloadTask(with: url) { fileUrl, response, error in self.downloadingMainImages.remove(cap) guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else { DispatchQueue.main.async { completion?(nil) } return } guard app.storage.saveImage(at: fileUrl, for: cap) else { self.log("Request '\(query)' could not move downloaded file") DispatchQueue.main.async { completion?(nil) } return } DispatchQueue.main.async { guard let image = app.storage.image(for: cap) else { self.log("Request '\(query)' received an invalid image") completion?(nil) return } completion?(image) } } task.resume() return true } /** Download an image for a cap. - Parameter cap: The id of the cap. - Parameter version: The image version to download. - Parameter completion: A closure with the resulting image - Note: The closure will be called from the main queue. - Returns: `true`, of the file download was started, `false`, if the image is already downloading. */ @discardableResult func image(for cap: Int, version: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool { let url = serverImageUrl(for: cap, version: version) let query = "Image of cap \(cap) version \(version)" let task = session.downloadTask(with: url) { fileUrl, response, error in guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else { DispatchQueue.main.async { completion(nil) } return } guard app.storage.saveImage(at: fileUrl, for: cap, version: version) else { self.log("Request '\(query)' could not move downloaded file") DispatchQueue.main.async { completion(nil) } return } DispatchQueue.main.async { guard let image = app.storage.image(for: cap, version: version) else { self.log("Request '\(query)' received an invalid image") completion(nil) return } completion(image) } } task.resume() return true } func imageCount(for cap: Int, completion: @escaping (_ count: Int?) -> Void) { let url = serverImageCountUrl(for: cap) let query = "Image count for cap \(cap)" let task = session.dataTask(with: url) { data, response, error in let int = self.convertIntResponse(to: query, data, response, error) completion(int) } task.resume() } func imageCounts(completion: @escaping ([(cap: Int, count: Int)]?) -> Void) { let url = serverAllCountsUrl let query = "Image count of all caps" let task = session.dataTask(with: url) { data, response, error in guard let string = self.convertStringResponse(to: query, data, response, error) else { completion(nil) return } // Convert the encoded string into (id, count) pairs let parts = string.components(separatedBy: ";") let array: [(cap: Int, count: Int)] = parts.compactMap { s in let p = s.components(separatedBy: "#") guard p.count == 2, let cap = Int(p[0]), let count = Int(p[1]) else { return nil } return (cap, count) } completion(array) } task.resume() } func databaseSize(completion: @escaping (_ size: Int64?) -> Void) { size(of: "database size", to: serverDatabaseUrl, completion: completion) } func database(progress: Delegate.ProgressHandler? = nil, completion: @escaping (URL?) -> Void) { //let query = "Download of server database" let task = session.downloadTask(with: serverDatabaseUrl) delegate.registerForProgress(task, callback: progress) {url in self.log("Database download complete") completion(url) } task.resume() } func classifierVersion(completion: @escaping (Int?) -> Void) { let query = "Server classifier version" let task = session.dataTask(with: serverClassifierVersionUrl) { data, response, error in let int = self.convertIntResponse(to: query, data, response, error) completion(int) } task.resume() } func classifierSize(completion: @escaping (Int64?) -> Void) { size(of: "classifier size", to: serverRecognitionModelUrl, completion: completion) } func classifier(progress: Delegate.ProgressHandler? = nil, completion: @escaping (URL?) -> Void) { let task = session.downloadTask(with: serverRecognitionModelUrl) delegate.registerForProgress(task, callback: progress) { url in self.log("Classifier download complete") completion(url) } task.resume() } // MARK: Requests private func size(of query: String, to url: URL, completion: @escaping (_ size: Int64?) -> Void) { var request = URLRequest(url: url) request.httpMethod = "HEAD" let task = session.dataTask(with: request) { _, response, _ in guard let r = response else { self.log("Request '\(query)' received no response") completion(nil) return } completion(r.expectedContentLength) } task.resume() } private func convertIntResponse(to query: String, _ data: Data?, _ response: URLResponse?, _ error: Error?) -> Int? { guard let string = self.convertStringResponse(to: query, data, response, error) else { return nil } guard let int = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) else { self.log("Request '\(query)' received an invalid value '\(string)'") return nil } return int } private func convertStringResponse(to query: String, _ data: Data?, _ response: URLResponse?, _ error: Error?) -> String? { guard let data = self.convertResponse(to: query, data, response, error) else { return nil } guard let string = String(data: data, encoding: .utf8) else { self.log("Request '\(query)' received invalid data (not a string)") return nil } return string } private func convertResponse(to query: String, _ result: T?, _ response: URLResponse?, _ error: Error?) -> T? { if let error = error { log("Request '\(query)' produced an error: \(error)") return nil } guard let response = response else { log("Request '\(query)' received no response") return nil } guard let urlResponse = response as? HTTPURLResponse else { log("Request '\(query)' received an invalid response: \(response)") return nil } guard urlResponse.statusCode == 200 else { log("Request '\(query)' failed with status code \(urlResponse.statusCode)") return nil } guard let r = result else { log("Request '\(query)' received no item") return nil } return r } } extension Download: Logger { }