Fix uploads, add server key entry

This commit is contained in:
Christoph Hagen 2022-06-11 11:27:56 +02:00
parent 093d82893b
commit 2b3ab859fc
10 changed files with 155 additions and 100 deletions

View File

@ -7,6 +7,7 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
88DBE72E285495B100D1573B /* FancyTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DBE72D285495B100D1573B /* FancyTextField.swift */; };
E25AAC7C283D855D006E9E7F /* CapsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC7B283D855D006E9E7F /* CapsApp.swift */; }; E25AAC7C283D855D006E9E7F /* CapsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC7B283D855D006E9E7F /* CapsApp.swift */; };
E25AAC7E283D855D006E9E7F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC7D283D855D006E9E7F /* ContentView.swift */; }; E25AAC7E283D855D006E9E7F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC7D283D855D006E9E7F /* ContentView.swift */; };
E25AAC80283D855F006E9E7F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E25AAC7F283D855F006E9E7F /* Assets.xcassets */; }; E25AAC80283D855F006E9E7F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E25AAC7F283D855F006E9E7F /* Assets.xcassets */; };
@ -43,6 +44,7 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
88DBE72D285495B100D1573B /* FancyTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FancyTextField.swift; sourceTree = "<group>"; };
E25AAC78283D855D006E9E7F /* Caps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Caps.app; sourceTree = BUILT_PRODUCTS_DIR; }; E25AAC78283D855D006E9E7F /* Caps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Caps.app; sourceTree = BUILT_PRODUCTS_DIR; };
E25AAC7B283D855D006E9E7F /* CapsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsApp.swift; sourceTree = "<group>"; }; E25AAC7B283D855D006E9E7F /* CapsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsApp.swift; sourceTree = "<group>"; };
E25AAC7D283D855D006E9E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; E25AAC7D283D855D006E9E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@ -162,6 +164,7 @@
E2EA00E0283F658E00F7B269 /* SettingsView.swift */, E2EA00E0283F658E00F7B269 /* SettingsView.swift */,
E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */, E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */,
E2EA00CD283EBEB600F7B269 /* SearchField.swift */, E2EA00CD283EBEB600F7B269 /* SearchField.swift */,
88DBE72D285495B100D1573B /* FancyTextField.swift */,
E2EA00F228438E6B00F7B269 /* CapNameEntryView.swift */, E2EA00F228438E6B00F7B269 /* CapNameEntryView.swift */,
E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */, E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */,
E2EA00CB283EB43E00F7B269 /* SortCaseRowView.swift */, E2EA00CB283EB43E00F7B269 /* SortCaseRowView.swift */,
@ -282,6 +285,7 @@
E2EA00D1283EDD6300F7B269 /* CameraManager.swift in Sources */, E2EA00D1283EDD6300F7B269 /* CameraManager.swift in Sources */,
E25AAC9B283E3395006E9E7F /* CapRowView.swift in Sources */, E25AAC9B283E3395006E9E7F /* CapRowView.swift in Sources */,
E2EA00DB283F5C0600F7B269 /* ContentViewModel.swift in Sources */, E2EA00DB283F5C0600F7B269 /* ContentViewModel.swift in Sources */,
88DBE72E285495B100D1573B /* FancyTextField.swift in Sources */,
E2EA00CC283EB43E00F7B269 /* SortCaseRowView.swift in Sources */, E2EA00CC283EB43E00F7B269 /* SortCaseRowView.swift in Sources */,
E2EA00E7283F6D0800F7B269 /* URL+Extensions.swift in Sources */, E2EA00E7283F6D0800F7B269 /* URL+Extensions.swift in Sources */,
E2EA00D3283EDDF700F7B269 /* CameraError.swift in Sources */, E2EA00D3283EDDF700F7B269 /* CameraError.swift in Sources */,

View File

@ -62,9 +62,6 @@ extension FrameManager: AVCapturePhotoCaptureDelegate {
log("Could not mask image") log("Could not mask image")
return nil return nil
} }
print(image.size)
print(masked.size)
print(masked.scale)
return masked return masked
} }
} }

View File

@ -1,6 +1,5 @@
import SwiftUI import SwiftUI
#warning("TODO: Create new caps")
#warning("TODO: Add colors") #warning("TODO: Add colors")
#warning("TODO: Grid view") #warning("TODO: Grid view")

View File

@ -241,7 +241,7 @@ struct ContentView: View {
image: $database.image, image: $database.image,
capId: $capIdOfNextPhoto) capId: $capIdOfNextPhoto)
} }
.bottomSheet(isPresented: $showSettingsSheet, height: 360) { .sheet(isPresented: $showSettingsSheet) {
SettingsView(isPresented: $showSettingsSheet) SettingsView(isPresented: $showSettingsSheet)
} }
.sheet(isPresented: $showGridView) { .sheet(isPresented: $showGridView) {
@ -266,6 +266,8 @@ struct ContentView: View {
sortType = .match sortType = .match
sortAscending = false sortAscending = false
} }
}.onAppear {
database.startRegularUploads()
} }
} }

View File

@ -49,6 +49,15 @@ struct Cap {
self.mainImage = data.mainImage self.mainImage = data.mainImage
self.classifierVersion = data.classifierVersion self.classifierVersion = data.classifierVersion
} }
var data: CapData {
.init(id: id,
name: name,
count: imageCount,
mainImage: mainImage,
classifierVersion: classifierVersion,
color: color)
}
mutating func update(with data: CapData) { mutating func update(with data: CapData) {
self.name = data.name self.name = data.name

View File

@ -52,10 +52,10 @@ final class Database: ObservableObject {
let serverUrl: URL let serverUrl: URL
@AppStorage("authKey") @AppStorage("authKey")
private var serverAuthenticationKey: String? private var serverAuthenticationKey: String = ""
var hasServerAuthentication: Bool { var hasServerAuthentication: Bool {
serverAuthenticationKey != nil serverAuthenticationKey != ""
} }
@Published @Published
@ -120,7 +120,6 @@ final class Database: ObservableObject {
diskCapacity: Database.imageCacheStorage, diskCapacity: Database.imageCacheStorage,
directory: cacheDirectory) directory: cacheDirectory)
loadCaps() loadCaps()
} }
@Published @Published
@ -323,7 +322,9 @@ final class Database: ObservableObject {
func save(newCap name: String) -> Cap { func save(newCap name: String) -> Cap {
let cap = Cap(id: nextCapId, name: name, classifier: serverClassifierVersion) let cap = Cap(id: nextCapId, name: name, classifier: serverClassifierVersion)
caps[cap.id] = cap caps[cap.id] = cap
#warning("Upload new cap") DispatchQueue.main.async {
self.changedCaps.insert(cap.id)
}
return cap return cap
} }
@ -350,14 +351,9 @@ final class Database: ObservableObject {
} }
log("Saved \(url.lastPathComponent) for upload") log("Saved \(url.lastPathComponent) for upload")
caps[capId]?.imageCount += 1 caps[capId]?.imageCount += 1
updateImageUploadCounts()
return true return true
} }
private func updateImageUploadCounts() {
}
private func loadImageUploadCounts() -> [Int : Int] { private func loadImageUploadCounts() -> [Int : Int] {
var result = [Int : Int]() var result = [Int : Int]()
pendingImageUploads.forEach { url in pendingImageUploads.forEach { url in
@ -376,9 +372,10 @@ final class Database: ObservableObject {
// MARK: Uploads // MARK: Uploads
func startRegularUploads() { func startRegularUploads() {
guard uploadTimer != nil else { guard uploadTimer == nil else {
return return
} }
log("Starting upload timer")
DispatchQueue.main.async { DispatchQueue.main.async {
self.uploadTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: self.uploadTimerElapsed) self.uploadTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: self.uploadTimerElapsed)
} }
@ -392,13 +389,19 @@ final class Database: ObservableObject {
private func uploadAll() async { private func uploadAll() async {
guard !isUploading else { guard !isUploading else {
log("Already uploading")
return return
} }
DispatchQueue.main.async { DispatchQueue.main.async {
self.isUploading = true self.isUploading = true
} }
await uploadAllChangedCaps() log("Starting uploads")
let uploaded = await uploadAllChangedCaps()
DispatchQueue.main.async {
self.changedCaps.subtract(uploaded)
}
await uploadAllImages() await uploadAllImages()
log("Uploads finished")
DispatchQueue.main.async { DispatchQueue.main.async {
self.isUploading = false self.isUploading = false
} }
@ -428,18 +431,22 @@ final class Database: ObservableObject {
log("No server authentication to upload to server") log("No server authentication to upload to server")
return return
} }
updateImageUploadCounts()
for url in pendingImageUploads { for url in pendingImageUploads {
guard let capId = capId(from: url) else { guard let capId = capId(from: url) else {
log("Unexpected image \(url.lastPathComponent) in upload folder") log("Unexpected image \(url.lastPathComponent) in upload folder")
continue continue
} }
guard await upload(imageAt: url, for: capId) else { guard fm.fileExists(atPath: url.path) else {
log("Missing image \(url.lastPathComponent) in upload folder")
continue continue
} }
guard await upload(imageAt: url, for: capId) else {
log("Failed to upload image \(url.lastPathComponent)")
continue
}
log("Uploaded image \(url.lastPathComponent)")
do { do {
try fm.removeItem(at: url) try fm.removeItem(at: url)
updateImageUploadCounts()
} catch { } catch {
log("Failed to remove uploaded image \(url.lastPathComponent): \(error)") log("Failed to remove uploaded image \(url.lastPathComponent): \(error)")
} }
@ -448,16 +455,20 @@ final class Database: ObservableObject {
@discardableResult @discardableResult
private func upload(imageAt url: URL, for cap: Int) async -> Bool { private func upload(imageAt url: URL, for cap: Int) async -> Bool {
guard let key = serverAuthenticationKey else { guard hasServerAuthentication else {
return false
}
guard let data = try? Data(contentsOf: url) else {
return false return false
} }
let url = serverUrl let url = serverUrl
.appendingPathComponent("images") .appendingPathComponent("images")
.appendingPathComponent("\(cap)?key=\(key)") .appendingPathComponent("\(cap)")
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.addValue(serverAuthenticationKey, forHTTPHeaderField: "key")
request.httpMethod = "POST" request.httpMethod = "POST"
do { do {
let (_, response) = try await URLSession.shared.upload(for: request, fromFile: url) let (_, response) = try await URLSession.shared.upload(for: request, from: data)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
log("Unexpected response for upload of image \(url.lastPathComponent): \(response)") log("Unexpected response for upload of image \(url.lastPathComponent): \(response)")
return false return false
@ -477,43 +488,45 @@ final class Database: ObservableObject {
changedCaps.count changedCaps.count
} }
private func uploadAllChangedCaps() async { private func uploadAllChangedCaps() async -> Set<Int> {
guard hasServerAuthentication else { guard hasServerAuthentication else {
log("No server authentication to upload to server") log("No server authentication to upload to server")
return return .init()
} }
var uploaded = Set<Int>() var uploaded = Set<Int>()
for capId in changedCaps { for capId in changedCaps {
guard let cap = caps[capId] else { guard let cap = caps[capId] else {
log("Missing cap \(capId) to upload")
uploaded.insert(capId) uploaded.insert(capId)
continue continue
} }
guard await upload(cap: cap) else { guard await upload(cap: cap) else {
continue continue
} }
log("Uploaded cap \(capId)")
uploaded.insert(capId) uploaded.insert(capId)
} }
changedCaps.subtract(uploaded) return uploaded
} }
@discardableResult @discardableResult
private func upload(cap: Cap) async -> Bool { private func upload(cap: Cap) async -> Bool {
guard let key = serverAuthenticationKey else { guard hasServerAuthentication else {
return false return false
} }
let data: Data let data: Data
do { do {
/// `Cap` and `CapData` have equivalent JSON layout /// `Cap` and `CapData` have equivalent JSON layout
data = try encoder.encode(cap) data = try encoder.encode(cap.data)
} catch { } catch {
log("Failed to encode cap \(cap.id) for upload: \(error)") log("Failed to encode cap \(cap.id) for upload: \(error)")
return false return false
} }
let url = serverUrl let url = serverUrl
.appendingPathComponent("images") .appendingPathComponent("cap")
.appendingPathComponent("\(cap)?key=\(key)")
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.addValue(serverAuthenticationKey, forHTTPHeaderField: "key")
do { do {
let (_, response) = try await URLSession.shared.upload(for: request, from: data) let (_, response) = try await URLSession.shared.upload(for: request, from: data)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
@ -524,7 +537,9 @@ final class Database: ObservableObject {
log("Failed to upload cap \(cap.id): Response \(httpResponse.statusCode)") log("Failed to upload cap \(cap.id): Response \(httpResponse.statusCode)")
return false return false
} }
changedCaps.remove(cap.id) DispatchQueue.main.async {
self.changedCaps.remove(cap.id)
}
return true return true
} catch { } catch {
log("Failed to upload cap \(cap.id): \(error)") log("Failed to upload cap \(cap.id): \(error)")

View File

@ -0,0 +1,47 @@
import SwiftUI
import SFSafeSymbols
struct FancyTextField: View {
@Binding
var text: String
let icon: SFSymbol
let placeholder: String
let showClearButton: Bool = true
var body: some View {
TextField("Search", text: $text, prompt: Text(placeholder))
.padding(7)
.padding(.horizontal, 25)
.background(Color(.systemGray5))
.cornerRadius(8)
.overlay(
HStack {
Image(systemSymbol: icon)
.foregroundColor(.gray)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.leading, 8)
if showClearButton && text != "" {
Button(action: {
self.text = ""
}) {
Image(systemSymbol: .multiplyCircleFill)
.foregroundColor(.gray)
.padding(.trailing, 8)
}
}
}
)
}
}
struct FancyTextField_Previews: PreviewProvider {
static var previews: some View {
FancyTextField(text: .constant("Text"),
icon: .magnifyingglass,
placeholder: "Enter text")
}
}

View File

@ -7,28 +7,7 @@ struct SearchField: View {
var searchString: String var searchString: String
var body: some View { var body: some View {
TextField("Search", text: $searchString, prompt: Text("Search...")) FancyTextField(text: $searchString, icon: .magnifyingglass, placeholder: "Search...")
.padding(7)
.padding(.horizontal, 25)
.background(Color(.systemGray5))
.cornerRadius(8)
.overlay(
HStack {
Image(systemSymbol: .magnifyingglass)
.foregroundColor(.gray)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.leading, 8)
if searchString != "" {
Button(action: {
self.searchString = ""
}) {
Image(systemSymbol: .multiplyCircleFill)
.foregroundColor(.gray)
.padding(.trailing, 8)
}
}
}
)
} }
} }

View File

@ -1,65 +1,68 @@
import SwiftUI import SwiftUI
import SFSafeSymbols
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject @EnvironmentObject
var database: Database var database: Database
@Binding @Binding
var isPresented: Bool var isPresented: Bool
@AppStorage("authKey")
private var serverAuthenticationKey: String = ""
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 3) { NavigationView {
HStack { VStack(alignment: .leading, spacing: 3) {
Text("Settings") Text("Authentication")
.font(.title2) .font(.footnote)
.bold() .textCase(.uppercase)
.foregroundColor(.secondary)
.padding(.top)
Group {
FancyTextField(text: $serverAuthenticationKey, icon: .key, placeholder: "Server key")
}.padding(.horizontal)
Text("Statistics")
.font(.footnote)
.textCase(.uppercase)
.foregroundColor(.secondary)
.padding(.top)
Group {
SettingsStatisticRow(label: "Caps", value: "\(database.numberOfCaps)")
SettingsStatisticRow(label: "Total images", value: "\(database.numberOfImages)")
SettingsStatisticRow(label: "Images per cap", value: String(format: "%.1f", database.averageImageCount))
}.padding(.horizontal)
Text("Classifier")
.font(.footnote)
.textCase(.uppercase)
.foregroundColor(.secondary)
.padding(.top)
Group {
SettingsStatisticRow(label: "Version", value: "\(database.classifierVersion)")
SettingsStatisticRow(label: "Recognized caps", value: "\(database.classifierClassCount)")
}.padding(.horizontal)
Text("Storage")
.font(.footnote)
.textCase(.uppercase)
.foregroundColor(.secondary)
.padding(.top)
Group {
SettingsStatisticRow(label: "Image cache", value: byteString(database.imageCacheSize))
SettingsStatisticRow(label: "Database", value: byteString(database.databaseSize))
SettingsStatisticRow(label: "Classifier", value: byteString(database.classifierSize))
}.padding(.horizontal)
Spacer() Spacer()
Button(action: hide) {
Image(systemSymbol: .xmarkCircleFill)
.foregroundColor(.gray)
.font(.system(size: 26))
}
} }
Text("Statistics") .padding(.horizontal)
.font(.footnote) .navigationTitle("Settings")
.textCase(.uppercase)
.foregroundColor(.secondary)
.padding(.top)
Group {
SettingsStatisticRow(label: "Caps", value: "\(database.numberOfCaps)")
SettingsStatisticRow(label: "Total images", value: "\(database.numberOfImages)")
SettingsStatisticRow(label: "Images per cap", value: String(format: "%.1f", database.averageImageCount))
}.padding(.horizontal)
Text("Classifier")
.font(.footnote)
.textCase(.uppercase)
.foregroundColor(.secondary)
.padding(.top)
Group {
SettingsStatisticRow(label: "Version", value: "\(database.classifierVersion)")
SettingsStatisticRow(label: "Recognized caps", value: "\(database.classifierClassCount)")
}.padding(.horizontal)
Text("Storage")
.font(.footnote)
.textCase(.uppercase)
.foregroundColor(.secondary)
.padding(.top)
Group {
SettingsStatisticRow(label: "Image cache", value: byteString(database.imageCacheSize))
SettingsStatisticRow(label: "Database", value: byteString(database.databaseSize))
SettingsStatisticRow(label: "Classifier", value: byteString(database.classifierSize))
}.padding(.horizontal)
Spacer()
} }
.padding(.horizontal)
} }
private func hide() { private func hide() {
isPresented = false isPresented = false
} }
private func byteString(_ count: Int) -> String { private func byteString(_ count: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(count), countStyle: .file) ByteCountFormatter.string(fromByteCount: Int64(count), countStyle: .file)
} }
@ -69,6 +72,6 @@ struct SettingsView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
SettingsView(isPresented: .constant(true)) SettingsView(isPresented: .constant(true))
.environmentObject(Database.mock) .environmentObject(Database.mock)
.previewLayout(.fixed(width: 375, height: 330)) //.previewLayout(.fixed(width: 375, height: 410))
} }
} }