Caps-iOS/Caps/Data/Download.swift
2022-04-28 15:54:13 +02:00

359 lines
13 KiB
Swift

//
// 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<Int>()
init(server: URL) {
let delegate = Delegate()
self.serverUrl = server
self.session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
self.delegate = delegate
}
// MARK: Paths
var serverNameListUrl: URL {
Download.serverNameListUrl(server: serverUrl)
}
private static func serverNameListUrl(server: URL) -> URL {
server.appendingPathComponent("names.txt")
}
private var serverClassifierVersionUrl: URL {
serverUrl.appendingPathComponent("classifier.version")
}
private var serverRecognitionModelUrl: URL {
serverUrl.appendingPathComponent("classifier.mlmodel")
}
private var serverAllCountsUrl: URL {
serverUrl.appendingPathComponent("counts")
}
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 serverNameUrl(for cap: Int) -> URL {
serverUrl.appendingPathComponent("name/\(cap)")
}
private func serverImageCountUrl(for cap: Int) -> URL {
serverUrl.appendingPathComponent("count/\(cap)")
}
// 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
func image(for cap: Int, to url: URL, timeout: TimeInterval = 30) -> Bool {
let group = DispatchGroup()
group.enter()
var result = true
let success = image(for: cap, version: 0, to: url) { success in
result = success
group.leave()
}
guard success else {
log("Already downloading image for cap \(cap)")
return false
}
guard group.wait(timeout: .now() + timeout) == .success else {
log("Timed out downloading image for cap \(cap)")
return false
}
return result
}
/**
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
- Returns: `true`, of the file download was started, `false`, if the image is already downloading.
*/
@discardableResult
func image(for cap: Int, version: Int = 0, to url: URL, queue: DispatchQueue = .main, completion: @escaping (Bool) -> Void) -> Bool {
// Check if main image, and already being downloaded
if version == 0 {
guard !downloadingMainImages.contains(cap) else {
return false
}
downloadingMainImages.insert(cap)
}
let serverUrl = serverImageUrl(for: cap, version: version)
let query = "Image of cap \(cap) version \(version)"
let task = session.downloadTask(with: serverUrl) { fileUrl, response, error in
if version == 0 {
queue.async {
self.downloadingMainImages.remove(cap)
}
}
guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else {
completion(false)
return
}
do {
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
try FileManager.default.moveItem(at: fileUrl, to: url)
} catch {
self.log("Failed to move downloaded image for cap \(cap): \(error)")
completion(false)
}
completion(true)
}
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)"
session.startTaskExpectingInt(with: url, query: query, completion: completion)
}
func name(for cap: Int, completion: @escaping (_ name: String?) -> Void) {
let url = serverNameUrl(for: cap)
let query = "Name for cap \(cap)"
session.startTaskExpectingString(with: url, query: query, completion: completion)
}
func imageCounts(completion: @escaping ([Int]?) -> Void) {
let query = "Image count of all caps"
session.startTaskExpectingData(with: serverAllCountsUrl, query: query) { data in
guard let data = data else {
completion(nil)
return
}
completion(data.map(Int.init))
}
}
func names(completion: @escaping ([String]?) -> Void) {
let query = "Download of server database"
session.startTaskExpectingString(with: serverNameListUrl, query: query) { string in
completion(string?.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\n"))
}
}
func databaseSize(completion: @escaping (_ size: Int64?) -> Void) {
size(of: "database size", to: serverNameListUrl, completion: completion)
}
func classifierVersion(completion: @escaping (Int?) -> Void) {
let query = "Server classifier version"
session.startTaskExpectingInt(with: serverClassifierVersionUrl, query: query, completion: completion)
}
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<T>(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 { }
extension URLSession {
func startTaskExpectingData(with url: URL, query: String, completion: @escaping (Data?) -> Void) {
let task = dataTask(with: url) { data, response, error in
if let error = error {
log("Request '\(query)' produced an error: \(error)")
completion(nil)
return
}
guard let response = response else {
log("Request '\(query)' received no response")
completion(nil)
return
}
guard let urlResponse = response as? HTTPURLResponse else {
log("Request '\(query)' received an invalid response: \(response)")
completion(nil)
return
}
guard urlResponse.statusCode == 200 else {
log("Request '\(query)' failed with status code \(urlResponse.statusCode)")
completion(nil)
return
}
guard let d = data else {
log("Request '\(query)' received no data")
completion(nil)
return
}
completion(d)
}
task.resume()
}
func startTaskExpectingString(with url: URL, query: String, completion: @escaping (String?) -> Void) {
startTaskExpectingData(with: url, query: query) { data in
guard let data = data else {
completion(nil)
return
}
guard let string = String(data: data, encoding: .utf8) else {
log("Request '\(query)' received invalid data (not a string)")
completion(nil)
return
}
completion(string)
}
}
func startTaskExpectingInt(with url: URL, query: String, completion: @escaping (Int?) -> Void) {
startTaskExpectingString(with: url, query: query) { string in
guard let string = string else {
completion(nil)
return
}
guard let int = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) else {
log("Request '\(query)' received an invalid value '\(string)'")
completion(nil)
return
}
completion(int)
}
}
}