diff --git a/Caps/Data/Database.swift b/Caps/Data/Database.swift index 45e4bbc..af8e5f0 100644 --- a/Caps/Data/Database.swift +++ b/Caps/Data/Database.swift @@ -49,8 +49,29 @@ final class Database: ObservableObject { changedCapStorage = newValue.map { "\($0)" }.joined(separator: ",") } } - - private lazy var imageUploads: [Int: Int] = loadImageUploadCounts() + + @AppStorage("uploads") + private var pendingImageUploadStorage: String = "" + + private(set) var imageUploads: [Int: [Int]] { + get { + pendingImageUploadStorage.components(separatedBy: ";").reduce(into: [:]) { dict, string in + let parts = string.components(separatedBy: "-") + guard parts.count == 2 else { + return + } + guard let cap = Int(parts[0]) else { + return + } + dict[cap] = parts[1].components(separatedBy: ":").compactMap(Int.init) + } + } + set { + pendingImageUploadStorage = newValue.map { cap, images in + "\(cap)-\(images.map { "\($0)" }.joined(separator: ":"))" + }.joined(separator: ";") + } + } private var uploadTimer: Timer? @@ -128,7 +149,7 @@ final class Database: ObservableObject { } private var serverClassifierVersionUrl: URL { - serverUrl.appendingPathComponent("classifier.version") + serverUrl.appendingPathComponent("version") } private var gridStorageFolder: URL { @@ -255,7 +276,10 @@ final class Database: ObservableObject { #warning("Merge changed caps with server updates") } else { oldCap.update(with: cap) - caps[cap.id] = oldCap + let save = oldCap + DispatchQueue.main.async { + self.caps[cap.id] = save + } updates += 1 } } @@ -289,10 +313,10 @@ final class Database: ObservableObject { self.serverClassifierVersion = serverVersion } guard serverVersion > self.classifierVersion else { - print("No new classifier available") + print("No new classifier available (Local: \(classifierVersion) Server: \(serverVersion))") return false } - print("New classifier \(serverVersion) available") + print("New classifier available (Local: \(classifierVersion) Server: \(serverVersion))") return true } @@ -348,45 +372,29 @@ final class Database: ObservableObject { @discardableResult func save(_ image: UIImage, for capId: Int) -> Bool { - guard caps[capId] != nil else { + guard let cap = caps[capId] else { log("Failed to save image for missing cap \(capId)") return false } - guard ensureFolderExistence(imageUploadFolderUrl) else { + guard images.save(image, for: CapImage(cap: cap.id, version: cap.imageCount)) else { return false } - guard let data = image.jpegData(compressionQuality: imageCompressionQuality) else { - log("Failed to compress image for cap: \(capId)") - return false + log("Saved image \(cap.imageCount) for cap \(capId)") + if imageUploads[capId] != nil { + DispatchQueue.main.async { + self.imageUploads[capId]!.append(cap.imageCount) + } + } else { + DispatchQueue.main.async { + self.imageUploads[capId] = [cap.imageCount] + } } - let hash = Data(SHA256.hash(data: data)).hexEncoded.prefix(16) - let url = imageUploadFolderUrl.appendingPathComponent("\(capId)-\(hash).jpg") - do { - try data.write(to: url) - } catch { - log("Failed to save \(url.lastPathComponent): \(error)") - return false + DispatchQueue.main.async { + self.caps[capId]!.imageCount += 1 } - log("Saved \(url.lastPathComponent) for upload") - caps[capId]?.imageCount += 1 return true } - private func loadImageUploadCounts() -> [Int : Int] { - var result = [Int : Int]() - pendingImageUploads.forEach { url in - guard let capId = capId(from: url) else { - return - } - if let old = result[capId] { - result[capId] = old + 1 - } else { - result[capId] = 1 - } - } - return result - } - // MARK: Uploads func startRegularUploads() { @@ -437,12 +445,8 @@ final class Database: ObservableObject { changedCaps.contains(cap) || imageUploads[cap] != nil } - private var pendingImageUploads: [URL] { - (try? fm.contentsOfDirectory(at: imageUploadFolderUrl, includingPropertiesForKeys: nil)) ?? [] - } - var pendingImageUploadCount: Int { - pendingImageUploads.count + imageUploads.values.reduce(0) { $0 + $1.count } } private func capId(from url: URL) -> Int? { @@ -454,24 +458,27 @@ final class Database: ObservableObject { log("No server authentication to upload to server") return } - for url in pendingImageUploads { - guard let capId = capId(from: url) else { - log("Unexpected image \(url.lastPathComponent) in upload folder") - continue - } - guard fm.fileExists(atPath: url.path) else { - log("Missing image \(url.lastPathComponent) in upload folder") - continue - } - guard await upload(imageAt: url, for: capId) else { - log("Failed to upload image \(url.lastPathComponent)") - continue - } - log("Uploaded image \(url.lastPathComponent)") - do { - try fm.removeItem(at: url) - } catch { - log("Failed to remove uploaded image \(url.lastPathComponent): \(error)") + for (cap, images) in imageUploads { + for image in images { + guard let url = self.images.availableImageUrl(CapImage(cap: cap, version: image)) else { + log("Missing upload image \(image) for cap \(cap)") + continue + } + guard await upload(imageAt: url, for: cap) else { + log("Failed to upload image \(url.lastPathComponent)") + continue + } + log("Uploaded image \(image) for cap \(cap)") + let remaining = imageUploads[cap]?.filter { $0 != image } + if let r = remaining, !r.isEmpty { + DispatchQueue.main.async { + self.imageUploads[cap] = r + } + } else { + DispatchQueue.main.async { + self.imageUploads[cap] = nil + } + } } } } diff --git a/Caps/Data/ImageCache.swift b/Caps/Data/ImageCache.swift index 8fc8946..7e419db 100644 --- a/Caps/Data/ImageCache.swift +++ b/Caps/Data/ImageCache.swift @@ -14,6 +14,8 @@ final class ImageCache { private let session: URLSession = .shared private let thumbnailQuality: CGFloat = 0.7 + + private let imageQuality: CGFloat = 0.3 init(folder: URL, server: URL, thumbnailSize: CGFloat) throws { self.folder = folder @@ -26,12 +28,38 @@ final class ImageCache { } private func localImageUrl(_ image: CapImage) -> URL { - folder.appendingPathComponent(String(format: "%04d-%02d.jpg", image.cap, image.cap, image.version)) + folder.appendingPathComponent(String(format: "%04d-%02d.jpg", image.cap, image.version)) } private func remoteImageUrl(_ image: CapImage) -> URL { server.appendingPathComponent(String(format: "images/%04d/%04d-%02d.jpg", image.cap, image.cap, image.version)) } + + @discardableResult + func save(_ image: UIImage, for cap: CapImage) -> Bool { + guard let data = image.jpegData(compressionQuality: imageQuality) else { + return false + } + let localUrl = localImageUrl(cap) + guard removePossibleFile(localUrl) else { + return false + } + do { + try data.write(to: localUrl) + return true + } catch { + print("failed to save image \(localUrl.lastPathComponent): \(error)") + return false + } + } + + func availableImageUrl(_ image: CapImage) -> URL? { + let url = localImageUrl(image) + guard exists(url) else { + return nil + } + return url + } func image(_ image: CapImage, completion: @escaping (UIImage?) -> ()) { Task {