From ddd56b8d17b25aab9d09729267554156e0e25319 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sun, 11 Jan 2026 23:46:01 +0100 Subject: [PATCH 01/11] speeding up the gallery (json serializing) by serializing json off of the main thread --- BeeKit/Managers/RequestManager.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 8113b6114..82cd93dd3 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -62,9 +62,7 @@ public class RequestManager { ).validate().serializingData(emptyRequestMethods: [HTTPMethod.post]).response switch response.result { - case .success(let data): - let asJSON = try? JSONSerialization.jsonObject(with: data) - return asJSON + case .success(let data): return try await Task.detached { try JSONSerialization.jsonObject(with: data) }.value case .failure(let error): logger.error("Error issuing request \(url): \(error, privacy: .public)") From 9de30deb0929e6482afd79796592565720250ee9 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sun, 11 Jan 2026 23:47:25 +0100 Subject: [PATCH 02/11] speeding up the gallery (thumbnail prefetching) by prefetching gallery's thumbnails --- BeeSwift/Gallery/GalleryViewController.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/BeeSwift/Gallery/GalleryViewController.swift b/BeeSwift/Gallery/GalleryViewController.swift index 030a318b9..daff53bc5 100644 --- a/BeeSwift/Gallery/GalleryViewController.swift +++ b/BeeSwift/Gallery/GalleryViewController.swift @@ -6,6 +6,7 @@ // Copyright 2015 APB. All rights reserved. // +import AlamofireImage import BeeKit import CoreData import HealthKit @@ -502,6 +503,18 @@ extension GalleryViewController: UICollectionViewDelegate { } } +// MARK: - Prefetching (using AlamofireImage's ImageDownloader) +extension GalleryViewController: UICollectionViewDataSourcePrefetching { + func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { + let urls = indexPaths.compactMap { indexPath -> URL? in + let goal = fetchedResultsController.object(at: indexPath) + return try? goal.thumbUrl.asURL() + } + let downloader = ImageDownloader.default + urls.forEach { downloader.download(URLRequest(url: $0), completion: { _ in }) } + } +} + extension GalleryViewController { static private var preferredSort: [NSSortDescriptor] { let selectedGoalSort = From 8f928bb7c22d81dbb06f18e5ae04f2d5330b24b4 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sun, 1 Feb 2026 14:59:47 +0100 Subject: [PATCH 03/11] concurrency safety via actor for RequestManager --- BeeKit/Managers/RequestManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 82cd93dd3..2ea941f86 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -34,7 +34,7 @@ public enum ServerError: LocalizedError { } } -public class RequestManager { +public actor RequestManager { public let baseURLString = Config().baseURLString private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "RequestManager") func rawRequest(url: String, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders) async throws From 12b2f52037a69d31f252c73c0341f074e1ac2bb6 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:05:36 +0100 Subject: [PATCH 04/11] RequestManaging protocol to ease mocking in tests --- BeeKit/Managers/CurrentUserManager.swift | 4 +-- BeeKit/Managers/DataPointManager.swift | 4 +-- BeeKit/Managers/GoalManager.swift | 4 +-- BeeKit/Managers/RequestManager.swift | 29 ++++++++++++++++++- BeeKit/Managers/VersionManager.swift | 4 +-- BeeKitTests/DataPointManagerTests.swift | 14 +++++---- BeeKitTests/GoalManagerTests.swift | 20 +++++++++++-- BeeSwift/Gallery/GalleryViewController.swift | 4 +-- .../EditDatapointViewController.swift | 4 +-- .../GoalView/GoalSettingsViewController.swift | 4 +-- BeeSwift/GoalView/GoalViewController.swift | 4 +-- BeeSwift/GoalView/TimerViewController.swift | 4 +-- BeeSwift/MainCoordinator.swift | 4 +-- .../ChooseHKMetricViewController.swift | 4 +-- .../ConfigureHKMetricViewController.swift | 4 +-- ...ConfigureNotificationsViewController.swift | 4 +-- ...itDefaultNotificationsViewController.swift | 4 +-- .../EditGoalNotificationsViewController.swift | 4 +-- .../HealthKitConfigViewController.swift | 4 +-- .../Settings/SettingsViewController.swift | 4 +-- 20 files changed, 89 insertions(+), 42 deletions(-) diff --git a/BeeKit/Managers/CurrentUserManager.swift b/BeeKit/Managers/CurrentUserManager.swift index da0df4487..402702f36 100644 --- a/BeeKit/Managers/CurrentUserManager.swift +++ b/BeeKit/Managers/CurrentUserManager.swift @@ -36,13 +36,13 @@ import SwiftyJSON internal static let keychainPrefix = "CurrentUserManager_" - private let requestManager: RequestManager + private let requestManager: RequestManaging fileprivate static var allKeys: [String] { [accessTokenKey, usernameKey, deadbeatKey, defaultLeadtimeKey, defaultAlertstartKey, defaultDeadlineKey, beemTZKey] } - init(requestManager: RequestManager, container: BeeminderPersistentContainer) { + init(requestManager: RequestManaging, container: BeeminderPersistentContainer) { self.requestManager = requestManager self.modelContainer = container let context = container.newBackgroundContext() diff --git a/BeeKit/Managers/DataPointManager.swift b/BeeKit/Managers/DataPointManager.swift index 1480c4fb1..dd0492379 100644 --- a/BeeKit/Managers/DataPointManager.swift +++ b/BeeKit/Managers/DataPointManager.swift @@ -12,9 +12,9 @@ import SwiftyJSON // prevents effectively no-op updates due to float rounding private let datapointValueEpsilon = 0.00000001 - let requestManager: RequestManager + let requestManager: RequestManaging - init(requestManager: RequestManager, container: BeeminderPersistentContainer) { + init(requestManager: RequestManaging, container: BeeminderPersistentContainer) { self.requestManager = requestManager self.modelContainer = container let context = container.newBackgroundContext() diff --git a/BeeKit/Managers/GoalManager.swift b/BeeKit/Managers/GoalManager.swift index 6d93a1fc4..1a7d0e007 100644 --- a/BeeKit/Managers/GoalManager.swift +++ b/BeeKit/Managers/GoalManager.swift @@ -16,12 +16,12 @@ import SwiftyJSON @NSModelActor(disableGenerateInit: true) public actor GoalManager { private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "GoalManager") - private let requestManager: RequestManager + private let requestManager: RequestManaging private nonisolated let currentUserManager: CurrentUserManager private var queuedGoalsBackgroundTaskRunning: Bool = false - init(requestManager: RequestManager, currentUserManager: CurrentUserManager, container: BeeminderPersistentContainer) + init(requestManager: RequestManaging, currentUserManager: CurrentUserManager, container: BeeminderPersistentContainer) { modelContainer = container let context = container.newBackgroundContext() diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 2ea941f86..d83a50d88 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -34,7 +34,34 @@ public enum ServerError: LocalizedError { } } -public actor RequestManager { + +public protocol RequestManaging { + func get(url: String, parameters: [String: Any]?) async throws -> Any? + func put(url: String, parameters: [String: Any]?) async throws -> Any? + func post(url: String, parameters: [String: Any]?) async throws -> Any? + func delete(url: String, parameters: [String: Any]?) async throws -> Any? + func addDatapoint(urtext: String, slug: String, requestId: String?) async throws -> Any? +} + +public extension RequestManaging { + func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + try await get(url: url, parameters: parameters) + } + func put(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + try await put(url: url, parameters: parameters) + } + func post(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + try await post(url: url, parameters: parameters) + } + func delete(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + try await delete(url: url, parameters: parameters) + } + func addDatapoint(urtext: String, slug: String, requestId: String? = nil) async throws -> Any? { + try await addDatapoint(urtext: urtext, slug: slug, requestId: requestId) + } +} + +public actor RequestManager: RequestManaging { public let baseURLString = Config().baseURLString private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "RequestManager") func rawRequest(url: String, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders) async throws diff --git a/BeeKit/Managers/VersionManager.swift b/BeeKit/Managers/VersionManager.swift index 9a48371f8..0bb59cce0 100644 --- a/BeeKit/Managers/VersionManager.swift +++ b/BeeKit/Managers/VersionManager.swift @@ -23,9 +23,9 @@ private let ageOfReleaseToWarn: TimeInterval = 10.0 * dayInSeconds public class VersionManager { private var minRequiredVersion: String = "1.0" private var updateState = UpdateState.UpToDate - private let requestManager: RequestManager + private let requestManager: RequestManaging - init(requestManager: RequestManager) { self.requestManager = requestManager } + init(requestManager: RequestManaging) { self.requestManager = requestManager } public func lastChckedUpdateState() -> UpdateState { return updateState } diff --git a/BeeKitTests/DataPointManagerTests.swift b/BeeKitTests/DataPointManagerTests.swift index 06bf76a88..820ceba20 100644 --- a/BeeKitTests/DataPointManagerTests.swift +++ b/BeeKitTests/DataPointManagerTests.swift @@ -17,7 +17,11 @@ class MockHealthKitDataPoint: BeeDataPoint { } } -class MockRequestManagerForDataPoint: RequestManager { +class MockRequestManagerForDataPoint: RequestManaging { + func post(url: String, parameters: [String : Any]?) async throws -> Any? { + nil + } + private let queue = DispatchQueue(label: "com.beeminder.MockRequestManagerForDataPoint") private var _responses: [String: Any] = [:] private var _putCalls: [(url: String, parameters: [String: Any])] = [] @@ -32,7 +36,7 @@ class MockRequestManagerForDataPoint: RequestManager { var deleteCalls: [String] { queue.sync { _deleteCalls } } var addDatapointCalls: [(urtext: String, slug: String, requestId: String)] { queue.sync { _addDatapointCalls } } - override func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { let response = queue.sync { () -> Any? in if let response = _responses[url] { _responses.removeValue(forKey: url) @@ -42,15 +46,15 @@ class MockRequestManagerForDataPoint: RequestManager { } return response ?? [] } - override func put(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + func put(url: String, parameters: [String: Any]? = nil) async throws -> Any? { queue.sync { _putCalls.append((url: url, parameters: parameters ?? [:])) } return [:] } - override func delete(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + func delete(url: String, parameters: [String: Any]? = nil) async throws -> Any? { queue.sync { _deleteCalls.append(url) } return [:] } - override func addDatapoint(urtext: String, slug: String, requestId: String? = nil) async throws -> Any? { + func addDatapoint(urtext: String, slug: String, requestId: String? = nil) async throws -> Any? { queue.sync { _addDatapointCalls.append((urtext: urtext, slug: slug, requestId: requestId ?? "")) } return [:] } diff --git a/BeeKitTests/GoalManagerTests.swift b/BeeKitTests/GoalManagerTests.swift index 361d19951..758d9a286 100644 --- a/BeeKitTests/GoalManagerTests.swift +++ b/BeeKitTests/GoalManagerTests.swift @@ -5,9 +5,25 @@ import XCTest @testable import BeeKit -class MockRequestManager: RequestManager { +class MockRequestManager: RequestManaging { + func addDatapoint(urtext: String, slug: String, requestId: String?) async throws -> Any? { + nil + } + + func delete(url: String, parameters: [String : Any]?) async throws -> Any? { + nil + } + + func post(url: String, parameters: [String : Any]?) async throws -> Any? { + nil + } + + func put(url: String, parameters: [String : Any]?) async throws -> Any? { + nil + } + var responses: [String: Any] = [:] - override func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { if let response = responses[url] { return response } XCTFail("Unexpected URL requested: \(url)") return nil diff --git a/BeeSwift/Gallery/GalleryViewController.swift b/BeeSwift/Gallery/GalleryViewController.swift index daff53bc5..9f882160d 100644 --- a/BeeSwift/Gallery/GalleryViewController.swift +++ b/BeeSwift/Gallery/GalleryViewController.swift @@ -30,7 +30,7 @@ class GalleryViewController: UIViewController { private let versionManager: VersionManager private let goalManager: GoalManager private let healthStoreManager: HealthStoreManager - private let requestManager: RequestManager + private let requestManager: RequestManaging private lazy var stackView: UIStackView = { let stackView = UIStackView() stackView.axis = .vertical @@ -107,7 +107,7 @@ class GalleryViewController: UIViewController { versionManager: VersionManager, goalManager: GoalManager, healthStoreManager: HealthStoreManager, - requestManager: RequestManager, + requestManager: RequestManaging, coordinator: MainCoordinator ) { self.currentUserManager = currentUserManager diff --git a/BeeSwift/GoalView/EditDatapointViewController.swift b/BeeSwift/GoalView/EditDatapointViewController.swift index a9a36e3c5..12faabf9a 100644 --- a/BeeSwift/GoalView/EditDatapointViewController.swift +++ b/BeeSwift/GoalView/EditDatapointViewController.swift @@ -21,10 +21,10 @@ class EditDatapointViewController: UIViewController, UITextFieldDelegate { fileprivate var datePicker = InlineDatePicker() fileprivate var valueField = UITextField() fileprivate var commentField = UITextField() - private let requestManager: RequestManager + private let requestManager: RequestManaging private let goalManager: GoalManager - init(goal: Goal, datapoint: DataPoint, requestManager: RequestManager, goalManager: GoalManager) { + init(goal: Goal, datapoint: DataPoint, requestManager: RequestManaging, goalManager: GoalManager) { self.goal = goal self.datapoint = datapoint self.requestManager = requestManager diff --git a/BeeSwift/GoalView/GoalSettingsViewController.swift b/BeeSwift/GoalView/GoalSettingsViewController.swift index e8acf3666..864158808 100644 --- a/BeeSwift/GoalView/GoalSettingsViewController.swift +++ b/BeeSwift/GoalView/GoalSettingsViewController.swift @@ -11,7 +11,7 @@ class GoalSettingsViewController: UIViewController { fileprivate let cellReuseIdentifier = "goalSettingsTableViewCell" let goal: Goal private let currentUserManager: CurrentUserManager - private let requestManager: RequestManager + private let requestManager: RequestManaging private let goalManager: GoalManager private weak var coordinator: MainCoordinator? @@ -27,7 +27,7 @@ class GoalSettingsViewController: UIViewController { init( goal: Goal, currentUserManager: CurrentUserManager, - requestManager: RequestManager, + requestManager: RequestManaging, goalManager: GoalManager, coordinator: MainCoordinator ) { diff --git a/BeeSwift/GoalView/GoalViewController.swift b/BeeSwift/GoalView/GoalViewController.swift index b722b29b6..331bffbe4 100644 --- a/BeeSwift/GoalView/GoalViewController.swift +++ b/BeeSwift/GoalView/GoalViewController.swift @@ -28,7 +28,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable let goal: Goal private let healthStoreManager: HealthStoreManager private let goalManager: GoalManager - private let requestManager: RequestManager + private let requestManager: RequestManaging private let currentUserManager: CurrentUserManager private let viewContext: NSManagedObjectContext private weak var coordinator: MainCoordinator? @@ -61,7 +61,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable goal: Goal, healthStoreManager: HealthStoreManager, goalManager: GoalManager, - requestManager: RequestManager, + requestManager: RequestManaging, currentUserManager: CurrentUserManager, viewContext: NSManagedObjectContext, coordinator: MainCoordinator diff --git a/BeeSwift/GoalView/TimerViewController.swift b/BeeSwift/GoalView/TimerViewController.swift index d0718a2f7..6ebe3e300 100644 --- a/BeeSwift/GoalView/TimerViewController.swift +++ b/BeeSwift/GoalView/TimerViewController.swift @@ -20,10 +20,10 @@ class TimerViewController: UIViewController { var timingSince: Date? var timer: Timer? private let units: TimerUnit - private let requestManager: RequestManager + private let requestManager: RequestManaging var accumulatedSeconds = 0 - init(goal: Goal, requestManager: RequestManager) { + init(goal: Goal, requestManager: RequestManaging) { self.goal = goal self.requestManager = requestManager self.units = Self.timerUnit(goal: goal) ?? .hours diff --git a/BeeSwift/MainCoordinator.swift b/BeeSwift/MainCoordinator.swift index 39ee7e9f7..403372c7f 100644 --- a/BeeSwift/MainCoordinator.swift +++ b/BeeSwift/MainCoordinator.swift @@ -9,7 +9,7 @@ class MainCoordinator { private let versionManager: VersionManager private let goalManager: GoalManager private let healthStoreManager: HealthStoreManager - private let requestManager: RequestManager + private let requestManager: RequestManaging init( navigationController: UINavigationController, currentUserManager: CurrentUserManager, @@ -17,7 +17,7 @@ class MainCoordinator { versionManager: VersionManager, goalManager: GoalManager, healthStoreManager: HealthStoreManager, - requestManager: RequestManager + requestManager: RequestManaging ) { self.navigationController = navigationController self.currentUserManager = currentUserManager diff --git a/BeeSwift/Settings/ChooseHKMetricViewController.swift b/BeeSwift/Settings/ChooseHKMetricViewController.swift index 3cafd9aaf..dd9efccad 100644 --- a/BeeSwift/Settings/ChooseHKMetricViewController.swift +++ b/BeeSwift/Settings/ChooseHKMetricViewController.swift @@ -17,9 +17,9 @@ class ChooseHKMetricViewController: UIViewController { fileprivate var tableView = UITableView() let goal: Goal private let healthStoreManager: HealthStoreManager - private let requestManager: RequestManager + private let requestManager: RequestManaging private weak var coordinator: MainCoordinator? - init(goal: Goal, healthStoreManager: HealthStoreManager, requestManager: RequestManager, coordinator: MainCoordinator) + init(goal: Goal, healthStoreManager: HealthStoreManager, requestManager: RequestManaging, coordinator: MainCoordinator) { self.goal = goal self.healthStoreManager = healthStoreManager diff --git a/BeeSwift/Settings/ConfigureHKMetricViewController.swift b/BeeSwift/Settings/ConfigureHKMetricViewController.swift index bad4a2658..e615ff517 100644 --- a/BeeSwift/Settings/ConfigureHKMetricViewController.swift +++ b/BeeSwift/Settings/ConfigureHKMetricViewController.swift @@ -25,7 +25,7 @@ class ConfigureHKMetricViewController: UIViewController { private let goal: Goal private let metric: HealthKitMetric private let healthStoreManager: HealthStoreManager - private let requestManager: RequestManager + private let requestManager: RequestManaging private let goalManager: GoalManager? private var isRequestInFlight = false @@ -47,7 +47,7 @@ class ConfigureHKMetricViewController: UIViewController { goal: Goal, metric: HealthKitMetric, healthStoreManager: HealthStoreManager, - requestManager: RequestManager, + requestManager: RequestManaging, goalManager: GoalManager? = nil ) { self.goal = goal diff --git a/BeeSwift/Settings/ConfigureNotificationsViewController.swift b/BeeSwift/Settings/ConfigureNotificationsViewController.swift index 84275e354..d8e6403e0 100644 --- a/BeeSwift/Settings/ConfigureNotificationsViewController.swift +++ b/BeeSwift/Settings/ConfigureNotificationsViewController.swift @@ -20,7 +20,7 @@ class ConfigureNotificationsViewController: UIViewController { private let goalManager: GoalManager private let viewContext: NSManagedObjectContext private let currentUserManager: CurrentUserManager - private let requestManager: RequestManager + private let requestManager: RequestManaging private weak var coordinator: MainCoordinator? private lazy var dataSource: NotificationsTableViewDiffibleDataSource = { NotificationsTableViewDiffibleDataSource(goals: [], tableView: tableView) @@ -29,7 +29,7 @@ class ConfigureNotificationsViewController: UIViewController { goalManager: GoalManager, viewContext: NSManagedObjectContext, currentUserManager: CurrentUserManager, - requestManager: RequestManager, + requestManager: RequestManaging, coordinator: MainCoordinator ) { self.goalManager = goalManager diff --git a/BeeSwift/Settings/EditDefaultNotificationsViewController.swift b/BeeSwift/Settings/EditDefaultNotificationsViewController.swift index fe08370a2..f883ea6f6 100644 --- a/BeeSwift/Settings/EditDefaultNotificationsViewController.swift +++ b/BeeSwift/Settings/EditDefaultNotificationsViewController.swift @@ -15,12 +15,12 @@ class EditDefaultNotificationsViewController: EditNotificationsViewController { private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "EditDefaultNotificationsViewController") private let user: User private let currentUserManager: CurrentUserManager - private let requestManager: RequestManager + private let requestManager: RequestManaging private let goalManager: GoalManager private let viewContext: NSManagedObjectContext init( currentUserManager: CurrentUserManager, - requestManager: RequestManager, + requestManager: RequestManaging, goalManager: GoalManager, viewContext: NSManagedObjectContext ) { diff --git a/BeeSwift/Settings/EditGoalNotificationsViewController.swift b/BeeSwift/Settings/EditGoalNotificationsViewController.swift index b42105af4..c7ba73177 100644 --- a/BeeSwift/Settings/EditGoalNotificationsViewController.swift +++ b/BeeSwift/Settings/EditGoalNotificationsViewController.swift @@ -19,13 +19,13 @@ class EditGoalNotificationsViewController: EditNotificationsViewController { let goal: Goal fileprivate var useDefaultsSwitch = UISwitch() private let currentUserManager: CurrentUserManager - private let requestManager: RequestManager + private let requestManager: RequestManaging private let goalManager: GoalManager private let viewContext: NSManagedObjectContext init( goal: Goal, currentUserManager: CurrentUserManager, - requestManager: RequestManager, + requestManager: RequestManaging, goalManager: GoalManager, viewContext: NSManagedObjectContext ) { diff --git a/BeeSwift/Settings/HealthKitConfigViewController.swift b/BeeSwift/Settings/HealthKitConfigViewController.swift index dec31531b..b9409ce53 100644 --- a/BeeSwift/Settings/HealthKitConfigViewController.swift +++ b/BeeSwift/Settings/HealthKitConfigViewController.swift @@ -23,13 +23,13 @@ class HealthKitConfigViewController: UIViewController { private let goalManager: GoalManager private let viewContext: NSManagedObjectContext private let healthStoreManager: HealthStoreManager - private let requestManager: RequestManager + private let requestManager: RequestManaging private weak var coordinator: MainCoordinator? init( goalManager: GoalManager, viewContext: NSManagedObjectContext, healthStoreManager: HealthStoreManager, - requestManager: RequestManager, + requestManager: RequestManaging, coordinator: MainCoordinator ) { self.goalManager = goalManager diff --git a/BeeSwift/Settings/SettingsViewController.swift b/BeeSwift/Settings/SettingsViewController.swift index 422aa7263..b66118f6f 100644 --- a/BeeSwift/Settings/SettingsViewController.swift +++ b/BeeSwift/Settings/SettingsViewController.swift @@ -18,13 +18,13 @@ class SettingsViewController: UIViewController { private let currentUserManager: CurrentUserManager private let viewContext: NSManagedObjectContext private let goalManager: GoalManager - private let requestManager: RequestManager + private let requestManager: RequestManaging private weak var coordinator: MainCoordinator? init( currentUserManager: CurrentUserManager, viewContext: NSManagedObjectContext, goalManager: GoalManager, - requestManager: RequestManager, + requestManager: RequestManaging, coordinator: MainCoordinator ) { self.currentUserManager = currentUserManager From 0200596f129231e8dc6ac99d2350d36b823d6b6b Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:05:56 +0100 Subject: [PATCH 05/11] clean code --- BeeKit/Managers/RequestManager.swift | 23 ----------------------- BeeKit/Managers/ServerError.swift | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 23 deletions(-) create mode 100644 BeeKit/Managers/ServerError.swift diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index d83a50d88..c118ed7a3 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -11,29 +11,6 @@ import Foundation import OSLog import SwiftyJSON -public enum ServerError: LocalizedError { - case notFound - case unauthorized - case forbidden - case serverError(Int) - case custom(String, requestError: Error?) - public var errorDescription: String? { - switch self { - case .notFound: return "Not found" - case .unauthorized: return "Unauthorized" - case .forbidden: return "Permission denied" - case .serverError(let code): return "Server error (\(code)). Please try again later" - case .custom(let message, _): return message - } - } - var requestError: Error? { - switch self { - case .custom(_, let error): return error - default: return nil - } - } -} - public protocol RequestManaging { func get(url: String, parameters: [String: Any]?) async throws -> Any? diff --git a/BeeKit/Managers/ServerError.swift b/BeeKit/Managers/ServerError.swift new file mode 100644 index 000000000..e22390e7d --- /dev/null +++ b/BeeKit/Managers/ServerError.swift @@ -0,0 +1,26 @@ +// Part of BeeSwift. Copyright Beeminder + +import Foundation + +public enum ServerError: LocalizedError { + case notFound + case unauthorized + case forbidden + case serverError(Int) + case custom(String, requestError: Error?) + public var errorDescription: String? { + switch self { + case .notFound: return "Not found" + case .unauthorized: return "Unauthorized" + case .forbidden: return "Permission denied" + case .serverError(let code): return "Server error (\(code)). Please try again later" + case .custom(let message, _): return message + } + } + var requestError: Error? { + switch self { + case .custom(_, let error): return error + default: return nil + } + } +} From c65b2b239ff6657b03994b34fef42f6dad366e14 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:06:33 +0100 Subject: [PATCH 06/11] clean code --- BeeKit/Managers/RequestManager.swift | 24 ----------------------- BeeKit/Managers/RequestManaging.swift | 28 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 24 deletions(-) create mode 100644 BeeKit/Managers/RequestManaging.swift diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index c118ed7a3..b06e133be 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -12,31 +12,7 @@ import OSLog import SwiftyJSON -public protocol RequestManaging { - func get(url: String, parameters: [String: Any]?) async throws -> Any? - func put(url: String, parameters: [String: Any]?) async throws -> Any? - func post(url: String, parameters: [String: Any]?) async throws -> Any? - func delete(url: String, parameters: [String: Any]?) async throws -> Any? - func addDatapoint(urtext: String, slug: String, requestId: String?) async throws -> Any? -} -public extension RequestManaging { - func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - try await get(url: url, parameters: parameters) - } - func put(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - try await put(url: url, parameters: parameters) - } - func post(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - try await post(url: url, parameters: parameters) - } - func delete(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - try await delete(url: url, parameters: parameters) - } - func addDatapoint(urtext: String, slug: String, requestId: String? = nil) async throws -> Any? { - try await addDatapoint(urtext: urtext, slug: slug, requestId: requestId) - } -} public actor RequestManager: RequestManaging { public let baseURLString = Config().baseURLString diff --git a/BeeKit/Managers/RequestManaging.swift b/BeeKit/Managers/RequestManaging.swift new file mode 100644 index 000000000..55e0349c1 --- /dev/null +++ b/BeeKit/Managers/RequestManaging.swift @@ -0,0 +1,28 @@ +// Part of BeeSwift. Copyright Beeminder + + +public protocol RequestManaging { + func get(url: String, parameters: [String: Any]?) async throws -> Any? + func put(url: String, parameters: [String: Any]?) async throws -> Any? + func post(url: String, parameters: [String: Any]?) async throws -> Any? + func delete(url: String, parameters: [String: Any]?) async throws -> Any? + func addDatapoint(urtext: String, slug: String, requestId: String?) async throws -> Any? +} + +public extension RequestManaging { + func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + try await get(url: url, parameters: parameters) + } + func put(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + try await put(url: url, parameters: parameters) + } + func post(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + try await post(url: url, parameters: parameters) + } + func delete(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + try await delete(url: url, parameters: parameters) + } + func addDatapoint(urtext: String, slug: String, requestId: String? = nil) async throws -> Any? { + try await addDatapoint(urtext: urtext, slug: slug, requestId: requestId) + } +} From 9518791a0d858b8a80c3b554e15533341994a8de Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:30:37 +0100 Subject: [PATCH 07/11] DRY and SSOT for requests --- BeeKit/Managers/RequestManager.swift | 55 +++++++++++++------ BeeKit/Managers/RequestManaging.swift | 13 ++--- BeeKit/Managers/SignedRequestManager.swift | 54 ------------------ BeeKit/Managers/SignedRequestManaging.swift | 15 +++++ BeeKit/ServiceLocator.swift | 7 ++- BeeKitTests/DataPointManagerTests.swift | 5 +- BeeKitTests/GoalManagerTests.swift | 20 ++----- .../ChooseHKMetricViewController.swift | 8 ++- 8 files changed, 75 insertions(+), 102 deletions(-) delete mode 100644 BeeKit/Managers/SignedRequestManager.swift create mode 100644 BeeKit/Managers/SignedRequestManaging.swift diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index b06e133be..14af61bbe 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -11,16 +11,12 @@ import Foundation import OSLog import SwiftyJSON - - - -public actor RequestManager: RequestManaging { +public actor RequestManager { public let baseURLString = Config().baseURLString private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "RequestManager") func rawRequest(url: String, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders) async throws -> Any? { - var urlWithSubstitutions = url if url.contains("{username}") { guard let username = await ServiceLocator.currentUserManager.username else { @@ -31,7 +27,6 @@ public actor RequestManager: RequestManaging { } urlWithSubstitutions = urlWithSubstitutions.replacingOccurrences(of: "{username}", with: username) } - let encoding: ParameterEncoding = if method == .get { URLEncoding.default } else { JSONEncoding.default } // TODO let response = await AF.request( "\(baseURLString)/\(urlWithSubstitutions)", @@ -40,25 +35,20 @@ public actor RequestManager: RequestManaging { encoding: encoding, headers: HTTPHeaders.default + headers ).validate().serializingData(emptyRequestMethods: [HTTPMethod.post]).response - switch response.result { case .success(let data): return try await Task.detached { try JSONSerialization.jsonObject(with: data) }.value - case .failure(let error): logger.error("Error issuing request \(url): \(error, privacy: .public)") - // Log out the user on an unauthorized response if case .responseValidationFailed(let reason) = error { if case .unacceptableStatusCode(let code) = reason { if code == 401 { try? await ServiceLocator.currentUserManager.signOut() } } } - // If we receive an error message from the server use it as our user-visible error if let data = response.data, let errorMessage = try JSON(data: data)["error_message"].string { throw ServerError.custom(errorMessage, requestError: error) } - // Handle common HTTP errors with specific error types if case .responseValidationFailed(let reason) = error { if case .unacceptableStatusCode(let code) = reason { @@ -71,10 +61,16 @@ public actor RequestManager: RequestManaging { } } } - throw error } } + func authenticationHeaders() -> HTTPHeaders { + guard let accessToken = ServiceLocator.currentUserManager.accessToken else { return HTTPHeaders() } + return HTTPHeaders([HTTPHeader(name: "Authorization", value: "Bearer " + accessToken)]) + } +} + +extension RequestManager: RequestManaging { public func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { return try await rawRequest(url: url, method: .get, parameters: parameters, headers: authenticationHeaders()) } @@ -87,11 +83,6 @@ public actor RequestManager: RequestManaging { public func delete(url: String, parameters: [String: Any]? = nil) async throws -> Any? { return try await rawRequest(url: url, method: .delete, parameters: parameters, headers: authenticationHeaders()) } - func authenticationHeaders() -> HTTPHeaders { - guard let accessToken = ServiceLocator.currentUserManager.accessToken else { return HTTPHeaders() } - return HTTPHeaders([HTTPHeader(name: "Authorization", value: "Bearer " + accessToken)]) - } - public func addDatapoint(urtext: String, slug: String, requestId: String? = nil) async throws -> Any? { let params = ["urtext": urtext, "requestid": requestId].compactMapValues { $0 } return try await post(url: "api/v1/users/{username}/goals/\(slug)/datapoints.json", parameters: params) @@ -106,3 +97,33 @@ extension HTTPHeaders { return HTTPHeaders(allHeaders) } } + +extension RequestManager: SignedRequestManaging { + public func signedGET(url: String, parameters: [String: Any]?) async throws -> Any? { + let params = signedParameters(parameters) + return try await rawRequest(url: url, method: .get, parameters: params, headers: authenticationHeaders()) + } + public func signedPOST(url: String, parameters: [String: Any]?) async throws -> Any? { + let params = signedParameters(parameters) + return try await rawRequest(url: url, method: .post, parameters: params, headers: authenticationHeaders()) + } + fileprivate func signedParameters(_ params: [String: Any]?) -> [String: Any]? { + if params == nil { return params } + var signed = params + var base = "" + var keys = Array(params!.keys) + keys.sort(by: { $0 < $1 }) + for key in keys { + let value: AnyObject? = params![key] as AnyObject? + if !(value is String) { return params! } + let allowedCharacterSet = (CharacterSet(charactersIn: "@/").inverted) + let escapedKey = key.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) + let escapedValue = (params![key] as AnyObject).addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) + if base.count > 0 { base += "&" } + base += "\(escapedKey!)=\(escapedValue!)" + } + let token = base.hmac(algorithm: HMACAlgorithm.SHA1, key: Config().requestSigningKey) + signed?["beemios_token"] = token + return signed! as [String: Any] + } +} diff --git a/BeeKit/Managers/RequestManaging.swift b/BeeKit/Managers/RequestManaging.swift index 55e0349c1..abefc8519 100644 --- a/BeeKit/Managers/RequestManaging.swift +++ b/BeeKit/Managers/RequestManaging.swift @@ -1,6 +1,5 @@ // Part of BeeSwift. Copyright Beeminder - public protocol RequestManaging { func get(url: String, parameters: [String: Any]?) async throws -> Any? func put(url: String, parameters: [String: Any]?) async throws -> Any? @@ -9,20 +8,20 @@ public protocol RequestManaging { func addDatapoint(urtext: String, slug: String, requestId: String?) async throws -> Any? } -public extension RequestManaging { - func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { +extension RequestManaging { + public func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { try await get(url: url, parameters: parameters) } - func put(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + public func put(url: String, parameters: [String: Any]? = nil) async throws -> Any? { try await put(url: url, parameters: parameters) } - func post(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + public func post(url: String, parameters: [String: Any]? = nil) async throws -> Any? { try await post(url: url, parameters: parameters) } - func delete(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + public func delete(url: String, parameters: [String: Any]? = nil) async throws -> Any? { try await delete(url: url, parameters: parameters) } - func addDatapoint(urtext: String, slug: String, requestId: String? = nil) async throws -> Any? { + public func addDatapoint(urtext: String, slug: String, requestId: String? = nil) async throws -> Any? { try await addDatapoint(urtext: urtext, slug: slug, requestId: requestId) } } diff --git a/BeeKit/Managers/SignedRequestManager.swift b/BeeKit/Managers/SignedRequestManager.swift deleted file mode 100644 index 3cbac6ce5..000000000 --- a/BeeKit/Managers/SignedRequestManager.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// SignedRequestManager.swift -// BeeSwift -// -// Created by Andy Brett on 11/30/17. -// Copyright 2017 APB. All rights reserved. -// - -import Alamofire -import Foundation - -public class SignedRequestManager { - private let requestManager: RequestManager - - init(requestManager: RequestManager) { self.requestManager = requestManager } - - public func signedGET(url: String, parameters: [String: Any]?) async throws -> Any? { - let params = signedParameters(parameters) - return try await requestManager.rawRequest( - url: url, - method: .get, - parameters: params, - headers: requestManager.authenticationHeaders() - ) - } - public func signedPOST(url: String, parameters: [String: Any]?) async throws -> Any? { - let params = signedParameters(parameters) - return try await requestManager.rawRequest( - url: url, - method: .post, - parameters: params, - headers: requestManager.authenticationHeaders() - ) - } - fileprivate func signedParameters(_ params: [String: Any]?) -> [String: Any]? { - if params == nil { return params } - var signed = params - var base = "" - var keys = Array(params!.keys) - keys.sort(by: { $0 < $1 }) - for key in keys { - let value: AnyObject? = params![key] as AnyObject? - if !(value is String) { return params! } - let allowedCharacterSet = (CharacterSet(charactersIn: "@/").inverted) - let escapedKey = key.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) - let escapedValue = (params![key] as AnyObject).addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) - if base.count > 0 { base += "&" } - base += "\(escapedKey!)=\(escapedValue!)" - } - let token = base.hmac(algorithm: HMACAlgorithm.SHA1, key: Config().requestSigningKey) - signed?["beemios_token"] = token - return signed! as [String: Any] - } -} diff --git a/BeeKit/Managers/SignedRequestManaging.swift b/BeeKit/Managers/SignedRequestManaging.swift new file mode 100644 index 000000000..be1ab1519 --- /dev/null +++ b/BeeKit/Managers/SignedRequestManaging.swift @@ -0,0 +1,15 @@ +// Part of BeeSwift. Copyright Beeminder + +public protocol SignedRequestManaging { + func signedGET(url: String, parameters: [String: Any]?) async throws -> Any? + func signedPOST(url: String, parameters: [String: Any]?) async throws -> Any? +} + +extension SignedRequestManaging { + public func signedGET(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + try await signedGET(url: url, parameters: parameters) + } + public func signedPOST(url: String, parameters: [String: Any]? = nil) async throws -> Any? { + try await signedPOST(url: url, parameters: parameters) + } +} diff --git a/BeeKit/ServiceLocator.swift b/BeeKit/ServiceLocator.swift index c1c6dcdf3..0dad79e83 100644 --- a/BeeKit/ServiceLocator.swift +++ b/BeeKit/ServiceLocator.swift @@ -12,10 +12,13 @@ import OSLog public class ServiceLocator { private static let logger = Logger(subsystem: "com.beeminder.beeminder", category: "ServiceLocator") + private static let sharedRequestManager = RequestManager() + + public static let requestManager: RequestManaging = sharedRequestManager + public static let signedRequestManager: SignedRequestManaging = sharedRequestManager + public static let persistentContainer = BeeminderPersistentContainer.create() - public static let requestManager = RequestManager() - public static let signedRequestManager = SignedRequestManager(requestManager: requestManager) public static let currentUserManager = CurrentUserManager( requestManager: requestManager, container: persistentContainer diff --git a/BeeKitTests/DataPointManagerTests.swift b/BeeKitTests/DataPointManagerTests.swift index 820ceba20..b03b0474e 100644 --- a/BeeKitTests/DataPointManagerTests.swift +++ b/BeeKitTests/DataPointManagerTests.swift @@ -18,10 +18,7 @@ class MockHealthKitDataPoint: BeeDataPoint { } class MockRequestManagerForDataPoint: RequestManaging { - func post(url: String, parameters: [String : Any]?) async throws -> Any? { - nil - } - + func post(url: String, parameters: [String: Any]?) async throws -> Any? { nil } private let queue = DispatchQueue(label: "com.beeminder.MockRequestManagerForDataPoint") private var _responses: [String: Any] = [:] private var _putCalls: [(url: String, parameters: [String: Any])] = [] diff --git a/BeeKitTests/GoalManagerTests.swift b/BeeKitTests/GoalManagerTests.swift index 758d9a286..beaff3e69 100644 --- a/BeeKitTests/GoalManagerTests.swift +++ b/BeeKitTests/GoalManagerTests.swift @@ -6,22 +6,10 @@ import XCTest @testable import BeeKit class MockRequestManager: RequestManaging { - func addDatapoint(urtext: String, slug: String, requestId: String?) async throws -> Any? { - nil - } - - func delete(url: String, parameters: [String : Any]?) async throws -> Any? { - nil - } - - func post(url: String, parameters: [String : Any]?) async throws -> Any? { - nil - } - - func put(url: String, parameters: [String : Any]?) async throws -> Any? { - nil - } - + func addDatapoint(urtext: String, slug: String, requestId: String?) async throws -> Any? { nil } + func delete(url: String, parameters: [String: Any]?) async throws -> Any? { nil } + func post(url: String, parameters: [String: Any]?) async throws -> Any? { nil } + func put(url: String, parameters: [String: Any]?) async throws -> Any? { nil } var responses: [String: Any] = [:] func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { if let response = responses[url] { return response } diff --git a/BeeSwift/Settings/ChooseHKMetricViewController.swift b/BeeSwift/Settings/ChooseHKMetricViewController.swift index dd9efccad..578291672 100644 --- a/BeeSwift/Settings/ChooseHKMetricViewController.swift +++ b/BeeSwift/Settings/ChooseHKMetricViewController.swift @@ -19,8 +19,12 @@ class ChooseHKMetricViewController: UIViewController { private let healthStoreManager: HealthStoreManager private let requestManager: RequestManaging private weak var coordinator: MainCoordinator? - init(goal: Goal, healthStoreManager: HealthStoreManager, requestManager: RequestManaging, coordinator: MainCoordinator) - { + init( + goal: Goal, + healthStoreManager: HealthStoreManager, + requestManager: RequestManaging, + coordinator: MainCoordinator + ) { self.goal = goal self.healthStoreManager = healthStoreManager self.requestManager = requestManager From 1f63b9cacd1027ad32ed08416e13c9c70b2af473 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sun, 1 Feb 2026 17:02:19 +0100 Subject: [PATCH 08/11] both using .default to have the same cache --- BeeSwift/Components/GoalImageView.swift | 2 +- BeeSwift/Components/ImageDownloadService.swift | 10 ++++++++++ BeeSwift/Gallery/GalleryViewController.swift | 3 +-- 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 BeeSwift/Components/ImageDownloadService.swift diff --git a/BeeSwift/Components/GoalImageView.swift b/BeeSwift/Components/GoalImageView.swift index 30106b4a5..cd24ea6a8 100644 --- a/BeeSwift/Components/GoalImageView.swift +++ b/BeeSwift/Components/GoalImageView.swift @@ -7,7 +7,7 @@ import OSLog /// Shows the current graph for a goal /// Handles placeholders for loading and queued states, and automatically updates when the goal changes class GoalImageView: UIView { - private static let downloader = ImageDownloader(imageCache: AutoPurgingImageCache()) + private static let downloader = ImageDownloadService.shared.downloader private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "GoalImageView") private let imageView = UIImageView() diff --git a/BeeSwift/Components/ImageDownloadService.swift b/BeeSwift/Components/ImageDownloadService.swift new file mode 100644 index 000000000..2fece7c80 --- /dev/null +++ b/BeeSwift/Components/ImageDownloadService.swift @@ -0,0 +1,10 @@ +// Part of BeeSwift. Copyright Beeminder + +import AlamofireImage + +final class ImageDownloadService { + static let shared = ImageDownloadService() + let downloader: ImageDownloader + + private init() { downloader = ImageDownloader.default } +} diff --git a/BeeSwift/Gallery/GalleryViewController.swift b/BeeSwift/Gallery/GalleryViewController.swift index 9f882160d..01c032fb4 100644 --- a/BeeSwift/Gallery/GalleryViewController.swift +++ b/BeeSwift/Gallery/GalleryViewController.swift @@ -503,14 +503,13 @@ extension GalleryViewController: UICollectionViewDelegate { } } -// MARK: - Prefetching (using AlamofireImage's ImageDownloader) extension GalleryViewController: UICollectionViewDataSourcePrefetching { func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { let urls = indexPaths.compactMap { indexPath -> URL? in let goal = fetchedResultsController.object(at: indexPath) return try? goal.thumbUrl.asURL() } - let downloader = ImageDownloader.default + let downloader = ImageDownloadService.shared.downloader urls.forEach { downloader.download(URLRequest(url: $0), completion: { _ in }) } } } From e3e8cf692761908ce283e0a5e6ce3da641406a44 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 14 Feb 2026 10:18:30 +0100 Subject: [PATCH 09/11] ImageDownloadService not available over ServiceLocator like others --- BeeKit/ImageDownloadService.swift | 10 ++++++++++ BeeKit/ServiceLocator.swift | 3 +++ BeeSwift.xcodeproj/project.pbxproj | 8 ++++++++ BeeSwift/Components/GoalImageView.swift | 2 +- BeeSwift/Components/ImageDownloadService.swift | 10 ---------- BeeSwift/Gallery/GalleryViewController.swift | 2 +- 6 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 BeeKit/ImageDownloadService.swift delete mode 100644 BeeSwift/Components/ImageDownloadService.swift diff --git a/BeeKit/ImageDownloadService.swift b/BeeKit/ImageDownloadService.swift new file mode 100644 index 000000000..8544f933a --- /dev/null +++ b/BeeKit/ImageDownloadService.swift @@ -0,0 +1,10 @@ +// Part of BeeSwift. Copyright Beeminder + +import AlamofireImage + +final public class ImageDownloadService { + static public let shared = ImageDownloadService() + public let downloader: ImageDownloader + + private init() { downloader = ImageDownloader.default } +} diff --git a/BeeKit/ServiceLocator.swift b/BeeKit/ServiceLocator.swift index 0dad79e83..9da69cbab 100644 --- a/BeeKit/ServiceLocator.swift +++ b/BeeKit/ServiceLocator.swift @@ -6,6 +6,7 @@ // Copyright 2023 APB. All rights reserved. // +import AlamofireImage import Foundation import OSLog @@ -36,4 +37,6 @@ public class ServiceLocator { goalManager: goalManager, container: persistentContainer ) + + public static let imageDownloader = ImageDownloadService.shared.downloader } diff --git a/BeeSwift.xcodeproj/project.pbxproj b/BeeSwift.xcodeproj/project.pbxproj index 0a38105de..68e8f822d 100644 --- a/BeeSwift.xcodeproj/project.pbxproj +++ b/BeeSwift.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 9BA7F9052F40723300CF84BE /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = 9BA7F9042F40723300CF84BE /* AlamofireImage */; }; A12BA94E1AFF202200AFEF32 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A12BA94D1AFF202200AFEF32 /* SystemConfiguration.framework */; }; A158DDB61E46EEA10031BD4F /* HealthKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A158DDB51E46EEA10031BD4F /* HealthKit.framework */; }; A17E930C1B09032F0098FCA0 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A17E930B1B09032F0098FCA0 /* QuartzCore.framework */; }; @@ -208,6 +209,7 @@ E458C8292AD12057000DCA5C /* OrderedCollections in Frameworks */, E4015DA22D1E7B2D00F58D94 /* CoreDataEvolution in Frameworks */, E458C8222AD11D40000DCA5C /* Alamofire in Frameworks */, + 9BA7F9052F40723300CF84BE /* AlamofireImage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -369,6 +371,7 @@ E458C8232AD11D7F000DCA5C /* KeychainSwift */, E458C8282AD12057000DCA5C /* OrderedCollections */, E4015DA12D1E7B2D00F58D94 /* CoreDataEvolution */, + 9BA7F9042F40723300CF84BE /* AlamofireImage */, ); productName = BeeKit; productReference = E57BE6E02655EBD900BA540B /* BeeKit.framework */; @@ -1155,6 +1158,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 9BA7F9042F40723300CF84BE /* AlamofireImage */ = { + isa = XCSwiftPackageProductDependency; + package = E462BA3729AC450000E80EF0 /* XCRemoteSwiftPackageReference "AlamofireImage" */; + productName = AlamofireImage; + }; E4015DA12D1E7B2D00F58D94 /* CoreDataEvolution */ = { isa = XCSwiftPackageProductDependency; package = E4015DA02D1E7B2D00F58D94 /* XCRemoteSwiftPackageReference "CoreDataEvolution" */; diff --git a/BeeSwift/Components/GoalImageView.swift b/BeeSwift/Components/GoalImageView.swift index cd24ea6a8..3d06adbc9 100644 --- a/BeeSwift/Components/GoalImageView.swift +++ b/BeeSwift/Components/GoalImageView.swift @@ -7,7 +7,7 @@ import OSLog /// Shows the current graph for a goal /// Handles placeholders for loading and queued states, and automatically updates when the goal changes class GoalImageView: UIView { - private static let downloader = ImageDownloadService.shared.downloader + private static let downloader = ServiceLocator.imageDownloader private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "GoalImageView") private let imageView = UIImageView() diff --git a/BeeSwift/Components/ImageDownloadService.swift b/BeeSwift/Components/ImageDownloadService.swift deleted file mode 100644 index 2fece7c80..000000000 --- a/BeeSwift/Components/ImageDownloadService.swift +++ /dev/null @@ -1,10 +0,0 @@ -// Part of BeeSwift. Copyright Beeminder - -import AlamofireImage - -final class ImageDownloadService { - static let shared = ImageDownloadService() - let downloader: ImageDownloader - - private init() { downloader = ImageDownloader.default } -} diff --git a/BeeSwift/Gallery/GalleryViewController.swift b/BeeSwift/Gallery/GalleryViewController.swift index 01c032fb4..5ff1f0b5e 100644 --- a/BeeSwift/Gallery/GalleryViewController.swift +++ b/BeeSwift/Gallery/GalleryViewController.swift @@ -509,7 +509,7 @@ extension GalleryViewController: UICollectionViewDataSourcePrefetching { let goal = fetchedResultsController.object(at: indexPath) return try? goal.thumbUrl.asURL() } - let downloader = ImageDownloadService.shared.downloader + let downloader = ServiceLocator.imageDownloader urls.forEach { downloader.download(URLRequest(url: $0), completion: { _ in }) } } } From 0c85bc185578e8a8a6c4a16642c7b74a9dc85138 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 14 Feb 2026 10:48:01 +0100 Subject: [PATCH 10/11] what about priority --- BeeKit/Managers/RequestManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 14af61bbe..4fa70fe30 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -36,7 +36,8 @@ public actor RequestManager { headers: HTTPHeaders.default + headers ).validate().serializingData(emptyRequestMethods: [HTTPMethod.post]).response switch response.result { - case .success(let data): return try await Task.detached { try JSONSerialization.jsonObject(with: data) }.value + case .success(let data): + return try await Task.detached(priority: .low) { try JSONSerialization.jsonObject(with: data) }.value case .failure(let error): logger.error("Error issuing request \(url): \(error, privacy: .public)") // Log out the user on an unauthorized response From 33f301f5be4fcf104b2fef3c060ee212e29e0308 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 14 Feb 2026 10:48:22 +0100 Subject: [PATCH 11/11] one TODO down --- BeeKit/Managers/RequestManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 4fa70fe30..094de030c 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -27,7 +27,7 @@ public actor RequestManager { } urlWithSubstitutions = urlWithSubstitutions.replacingOccurrences(of: "{username}", with: username) } - let encoding: ParameterEncoding = if method == .get { URLEncoding.default } else { JSONEncoding.default } // TODO + let encoding: ParameterEncoding = method == .get ? URLEncoding.default : JSONEncoding.default let response = await AF.request( "\(baseURLString)/\(urlWithSubstitutions)", method: method,