diff --git a/Caps.xcodeproj/project.pbxproj b/Caps.xcodeproj/project.pbxproj index 918fe13..1f5b6cb 100644 --- a/Caps.xcodeproj/project.pbxproj +++ b/Caps.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ E25AAC94283D88A4006E9E7F /* Cap.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC93283D88A4006E9E7F /* Cap.swift */; }; E25AAC96283E14DF006E9E7F /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC95283E14DF006E9E7F /* Database.swift */; }; E25AAC9B283E3395006E9E7F /* CapRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC9A283E3395006E9E7F /* CapRowView.swift */; }; + E29E17C92D4D0ABF00E0EE54 /* Database+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29E17C82D4D0ABF00E0EE54 /* Database+Mock.swift */; }; E2EA00C3283E672A00F7B269 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2EA00C2283E672A00F7B269 /* SFSafeSymbols */; }; E2EA00C5283EA72000F7B269 /* SortCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00C4283EA72000F7B269 /* SortCriteria.swift */; }; E2EA00C7283EAA0100F7B269 /* SortSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */; }; @@ -78,6 +79,7 @@ E25AAC95283E14DF006E9E7F /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = ""; }; E25AAC9A283E3395006E9E7F /* CapRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapRowView.swift; sourceTree = ""; }; E268C72D29BF2D8400D813A0 /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = ""; }; + E29E17C82D4D0ABF00E0EE54 /* Database+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database+Mock.swift"; sourceTree = ""; }; E2EA00C4283EA72000F7B269 /* SortCriteria.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortCriteria.swift; sourceTree = ""; }; E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortSelectionView.swift; sourceTree = ""; }; E2EA00CB283EB43E00F7B269 /* SortCaseRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortCaseRowView.swift; sourceTree = ""; }; @@ -100,6 +102,10 @@ E2ED70992A73D86F00067808 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E29E17C32D4D0A9300E0EE54 /* Download */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Download; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ E25AAC75283D855D006E9E7F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -133,6 +139,7 @@ E25AAC7A283D855D006E9E7F /* Caps */ = { isa = PBXGroup; children = ( + E29E17C32D4D0A9300E0EE54 /* Download */, E25AAC7B283D855D006E9E7F /* CapsApp.swift */, E25AAC7D283D855D006E9E7F /* ContentView.swift */, E2EA00CF283EDD2C00F7B269 /* Camera */, @@ -149,6 +156,7 @@ E25AAC81283D855F006E9E7F /* Preview Content */ = { isa = PBXGroup; children = ( + E29E17C82D4D0ABF00E0EE54 /* Database+Mock.swift */, E25AAC82283D855F006E9E7F /* Preview Assets.xcassets */, ); path = "Preview Content"; @@ -234,6 +242,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + E29E17C32D4D0A9300E0EE54 /* Download */, + ); name = Caps; packageProductDependencies = ( E2EA00C2283E672A00F7B269 /* SFSafeSymbols */, @@ -332,6 +343,7 @@ E25AAC8D283D86CF006E9E7F /* Logger.swift in Sources */, E2ED709A2A73D86F00067808 /* String+Extensions.swift in Sources */, E20D105028574E190019BD91 /* CapImage.swift in Sources */, + E29E17C92D4D0ABF00E0EE54 /* Database+Mock.swift in Sources */, E2EA00ED2841170100F7B269 /* UIImage+Extensions.swift in Sources */, E20D105228589AAC0019BD91 /* FileManager+Extensions.swift in Sources */, 882C955E2AE7F0DE00657886 /* ClassifierDownloadView.swift in Sources */, diff --git a/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9d32ecf..6bc3c18 100644 --- a/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,13 @@ { + "originHash" : "c78617470808606280f24ff566c34cb1b032779babfb39f1ddbcda602b363bd4", "pins" : [ { "identity" : "bottom-sheet", "kind" : "remoteSourceControl", "location" : "https://github.com/weitieda/bottom-sheet", "state" : { - "revision" : "4e074d49f3148577ac66cf47b85a99d016480d01", - "version" : "1.0.10" + "revision" : "6b21007153365235418f3943a960a1f9546592e0", + "version" : "1.0.12" } }, { @@ -19,5 +20,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Caps.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate b/Caps.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate index 65ecd56..a8dbc4a 100644 Binary files a/Caps.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate and b/Caps.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Caps/Data/Database.swift b/Caps/Data/Database.swift index ff05138..dc96dd0 100644 --- a/Caps/Data/Database.swift +++ b/Caps/Data/Database.swift @@ -3,6 +3,7 @@ import SwiftUI import Vision import CryptoKit +@MainActor final class Database: ObservableObject { @AppStorage("classifier") @@ -33,6 +34,8 @@ final class Database: ObservableObject { private let encoder = JSONEncoder() private let decoder = JSONDecoder() + private let progressObserver = ClassifierProgressObserver() + @AppStorage("serverUrl") var serverPath: String = "" { didSet { @@ -150,9 +153,7 @@ final class Database: ObservableObject { } set { _classifierClassesCache = newValue - DispatchQueue.main.async { - self._classifierClassesString = newValue.map { "\($0)" }.joined(separator: ",") - } + self._classifierClassesString = newValue.map { "\($0)" }.joined(separator: ",") } } @@ -187,6 +188,11 @@ final class Database: ObservableObject { updatePendingImageUploadCount(imageUploads: imageUploads) } + convenience init(caps: [Int : Cap]) { + self.init() + self.caps = caps + } + func mainImage(for cap: Int) -> Int { caps[cap]?.mainImage ?? 0 } @@ -358,9 +364,7 @@ final class Database: ObservableObject { } else { oldCap.update(with: cap) let save = oldCap - DispatchQueue.main.async { - self.caps[cap.id] = save - } + self.caps[cap.id] = save updates += 1 } } @@ -394,9 +398,7 @@ final class Database: ObservableObject { log("Classifier version has an invalid value '\(string)'") return false } - DispatchQueue.main.async { - self.storedServerClassifierVersion = serverVersion - } + self.storedServerClassifierVersion = serverVersion return true } @@ -409,19 +411,16 @@ final class Database: ObservableObject { log("Downloading classifier") let progress = ClassifierProgress() - DispatchQueue.main.async { - self.classifierDownloadProgress = progress - } + self.classifierDownloadProgress = progress defer { - DispatchQueue.main.async { - self.classifierDownloadProgress = nil - } + self.classifierDownloadProgress = nil } let tempUrl: URL let response: URLResponse do { - (tempUrl, response) = try await URLSession.shared.download(from: serverClassifierUrl, delegate: progress) + progressObserver.delegate = self + (tempUrl, response) = try await URLSession.shared.download(from: serverClassifierUrl, delegate: progressObserver) } catch { log("Failed to download classifier version: \(error)") return false @@ -439,14 +438,14 @@ final class Database: ObservableObject { log("Failed to replace classifier: \(error)") return false } - DispatchQueue.main.async { - self.storedLocalClassifierVersion = self.serverClassifierVersion - log("Downloaded classifier \(self.localClassifierVersion)") - self.classifier = nil - } + + self.storedLocalClassifierVersion = self.serverClassifierVersion + log("Downloaded classifier \(self.localClassifierVersion)") + self.classifier = nil + return true } - + @discardableResult func downloadClassifierClasses() async -> Bool { guard let serverClassifierClassesUrl else { @@ -509,9 +508,7 @@ final class Database: ObservableObject { func save(newCap name: String) -> Cap { let cap = Cap(id: nextCapId, name: name) caps[cap.id] = cap - DispatchQueue.main.async { - self.changedCaps.insert(cap.id) - } + self.changedCaps.insert(cap.id) return cap } @@ -526,17 +523,11 @@ final class Database: ObservableObject { } log("Saved image \(cap.imageCount) for cap \(capId)") if imageUploads[capId] != nil { - DispatchQueue.main.async { - self.imageUploads[capId]!.append(cap.imageCount) - } + self.imageUploads[capId]!.append(cap.imageCount) } else { - DispatchQueue.main.async { - self.imageUploads[capId] = [cap.imageCount] - } - } - DispatchQueue.main.async { - self.caps[capId]!.imageCount += 1 + self.imageUploads[capId] = [cap.imageCount] } + self.caps[capId]!.imageCount += 1 return true } @@ -558,14 +549,10 @@ final class Database: ObservableObject { return } log("Starting upload timer") - DispatchQueue.main.async { - self.uploadTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: self.uploadTimerElapsed) - } - } - - private func uploadTimerElapsed(timer: Timer) { - Task { - await uploadAll() + self.uploadTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in + Task { + await self.uploadAll() + } } } @@ -574,22 +561,16 @@ final class Database: ObservableObject { log("Already uploading") return } - DispatchQueue.main.async { - self.isUploading = true - } + self.isUploading = true defer { - DispatchQueue.main.async { - self.isUploading = false - } + self.isUploading = false } guard !changedCaps.isEmpty || pendingImageUploadCount > 0 else { return } log("Starting uploads") let uploaded = await uploadAllChangedCaps() - DispatchQueue.main.async { - self.changedCaps.subtract(uploaded) - } + self.changedCaps.subtract(uploaded) await uploadAllImages() log("Uploads finished") } @@ -622,14 +603,10 @@ final class Database: ObservableObject { } 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 - } + if let remaining, !remaining.isEmpty { + self.imageUploads[cap] = remaining } else { - DispatchQueue.main.async { - self.imageUploads[cap] = nil - } + self.imageUploads[cap] = nil } } } @@ -664,9 +641,7 @@ final class Database: ObservableObject { if httpResponse.statusCode == 410 { log("Missing cap for image \(url.lastPathComponent), reupload cap") // Missing cap, force upload - DispatchQueue.main.async { - self.changedCaps.insert(cap) - } + self.changedCaps.insert(cap) } else { log("Failed to upload image \(url.lastPathComponent): Response \(httpResponse.statusCode)") } @@ -736,9 +711,7 @@ final class Database: ObservableObject { log("Failed to upload cap \(cap.id): Response \(httpResponse.statusCode)") return false } - DispatchQueue.main.async { - self.changedCaps.remove(cap.id) - } + self.changedCaps.remove(cap.id) return true } catch { log("Failed to upload cap \(cap.id): \(error)") @@ -761,10 +734,8 @@ final class Database: ObservableObject { } cap.mainImage = version let finalCap = cap - DispatchQueue.main.async { - self.caps[capId] = finalCap - log("Set main image \(version) for \(capId)") - } + self.caps[capId] = finalCap + log("Set main image \(version) for \(capId)") return finalCap } @@ -852,9 +823,7 @@ final class Database: ObservableObject { // Delete cached images images.removeCachedImages(for: cap) // Delete cap - DispatchQueue.main.async { - self.caps[cap] = nil - } + self.caps[cap] = nil log("Deleted cap \(cap)") return true } catch { @@ -888,19 +857,24 @@ final class Database: ObservableObject { log("Image removed") return } - DispatchQueue.global().async { + Task { guard let classifier = self.getClassifier() else { return } log("Image classification started") - classifier.recognize(image: image) { matches in - DispatchQueue.main.async { - self.matches = matches ?? [:] + let matches = await withCheckedContinuation { continuation in + classifier.recognize(image: image) { matches in + continuation.resume(returning: matches) } } + self.update(matches: matches ?? [:]) } } - + + func update(matches: [Int : Float]) { + self.matches = matches + } + func canClassify(cap id: Int) -> Bool { classifierClasses.contains(id) } @@ -1027,68 +1001,13 @@ final class Database: ObservableObject { } } -extension Database { - - final class ClassifierProgress: NSObject, ObservableObject { - - @Published - var bytesLoaded: Double - - @Published - var total: Double - - var percentage: Double { - guard total > 0 else { - return 0.0 +extension Database: ClassifierProgressDelegate { + + nonisolated func classifierProgress(_ progress: ClassifierProgress) { + Task { + await MainActor.run { + self.classifierDownloadProgress = progress } - return bytesLoaded * 100 / total - } - - init(bytesLoaded: Double = 0, total: Double = 0) { - self.bytesLoaded = bytesLoaded - self.total = total } } } - -extension Database.ClassifierProgress: URLSessionDownloadDelegate { - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - - } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - DispatchQueue.main.async { - self.bytesLoaded = Double(totalBytesWritten) - self.total = Double(totalBytesExpectedToWrite) - } - } -} - -extension Database { - - static var mock: Database { - let db = Database() - db.serverPath = "https://caps.christophhagen.de" - db.caps = [ - Cap(id: 123, name: "My new cap"), - Cap(id: 234, name: "My favorite cap"), - Cap(id: 345, name: "My oldest cap"), - Cap(id: 456, name: "My new cap"), - Cap(id: 567, name: "My favorite cap"), - Cap(id: 678, name: "My oldest cap"), - ].reduce(into: [:]) { $0[$1.id] = $1 } - db.image = UIImage(systemSymbol: .photo) - return db - } - - static var largeMock: Database { - let db = Database() - db.serverPath = "https://caps.christophhagen.de" - db.caps = (1..<500) - .map { Cap(id: $0, name: "Cap \($0)") } - .reduce(into: [:]) { $0[$1.id] = $1 } - db.image = UIImage(systemSymbol: .photo) - return db - } -} diff --git a/Caps/Download/ClassifierProgress.swift b/Caps/Download/ClassifierProgress.swift new file mode 100644 index 0000000..3e2932c --- /dev/null +++ b/Caps/Download/ClassifierProgress.swift @@ -0,0 +1,19 @@ + +struct ClassifierProgress { + + let bytesLoaded: Double + + let total: Double + + var percentage: Double { + guard total > 0 else { + return 0.0 + } + return bytesLoaded * 100 / total + } + + init(bytesLoaded: Double = 0, total: Double = 0) { + self.bytesLoaded = bytesLoaded + self.total = total + } +} diff --git a/Caps/Download/ClassifierProgressDelegate.swift b/Caps/Download/ClassifierProgressDelegate.swift new file mode 100644 index 0000000..30091bf --- /dev/null +++ b/Caps/Download/ClassifierProgressDelegate.swift @@ -0,0 +1,5 @@ + +protocol ClassifierProgressDelegate: AnyObject { + + func classifierProgress(_ progress: ClassifierProgress) +} diff --git a/Caps/Download/ClassifierProgressObserver.swift b/Caps/Download/ClassifierProgressObserver.swift new file mode 100644 index 0000000..7116a97 --- /dev/null +++ b/Caps/Download/ClassifierProgressObserver.swift @@ -0,0 +1,20 @@ +import Foundation + +final class ClassifierProgressObserver: NSObject { + + weak var delegate: ClassifierProgressDelegate? +} + + +extension ClassifierProgressObserver: URLSessionDownloadDelegate { + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + delegate?.classifierProgress(.init( + bytesLoaded: Double(totalBytesWritten), + total: Double(totalBytesExpectedToWrite))) + } +} diff --git a/Caps/Preview Content/Database+Mock.swift b/Caps/Preview Content/Database+Mock.swift new file mode 100644 index 0000000..c9222a6 --- /dev/null +++ b/Caps/Preview Content/Database+Mock.swift @@ -0,0 +1,32 @@ +import UIKit + +extension Database { + + static var mock: Database { + let caps = [ + Cap(id: 123, name: "My new cap"), + Cap(id: 234, name: "My favorite cap"), + Cap(id: 345, name: "My oldest cap"), + Cap(id: 456, name: "My new cap"), + Cap(id: 567, name: "My favorite cap"), + Cap(id: 678, name: "My oldest cap"), + ].reduce(into: [:]) { $0[$1.id] = $1 } + + let db = Database(caps: caps) + db.serverPath = "https://caps.christophhagen.de" + + db.image = UIImage(systemSymbol: .photo) + return db + } + + static var largeMock: Database { + let caps = (1..<500) + .map { Cap(id: $0, name: "Cap \($0)") } + .reduce(into: [:]) { $0[$1.id] = $1 } + + let db = Database(caps: caps) + db.serverPath = "https://caps.christophhagen.de" + db.image = UIImage(systemSymbol: .photo) + return db + } +} diff --git a/Caps/Views/ClassifierDownloadView.swift b/Caps/Views/ClassifierDownloadView.swift index 755d003..35d6ee6 100644 --- a/Caps/Views/ClassifierDownloadView.swift +++ b/Caps/Views/ClassifierDownloadView.swift @@ -2,9 +2,8 @@ import SwiftUI struct ClassifierDownloadView: View { - @ObservedObject - var progress: Database.ClassifierProgress - + let progress: ClassifierProgress + var body: some View { VStack { ProgressView("Downloading classifier...", value: progress.bytesLoaded, total: Double(progress.total)) @@ -19,5 +18,8 @@ struct ClassifierDownloadView: View { } #Preview { - ClassifierDownloadView(progress: .init(bytesLoaded: 12_300_000, total: 24_500_000)) + ClassifierDownloadView(progress: .init( + bytesLoaded: 12_300_000, + total: 24_500_000) + ) }