diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index d9e4871882..965fcca42a 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -354,12 +354,6 @@ A98556852493F901000FD662 /* AlertStore+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */; }; A987CD4924A58A0100439ADC /* ZipArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987CD4824A58A0100439ADC /* ZipArchive.swift */; }; A999D40624663D18004C89D4 /* PumpManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A999D40524663D18004C89D4 /* PumpManagerError.swift */; }; - A99A114229A581F4007919CE /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114129A581F4007919CE /* BolusAction.swift */; }; - A99A114429A5829A007919CE /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114329A5829A007919CE /* CarbAction.swift */; }; - A99A114629A582A2007919CE /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114529A582A2007919CE /* OverrideAction.swift */; }; - A99A114E29A5879D007919CE /* BolusActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114B29A5879C007919CE /* BolusActionTests.swift */; }; - A99A114F29A5879D007919CE /* CarbActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114C29A5879C007919CE /* CarbActionTests.swift */; }; - A99A115029A5879D007919CE /* OverrideActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114D29A5879C007919CE /* OverrideActionTests.swift */; }; A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */; }; A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */; }; A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; @@ -1339,12 +1333,6 @@ A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AlertStore+SimulatedCoreData.swift"; sourceTree = ""; }; A987CD4824A58A0100439ADC /* ZipArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipArchive.swift; sourceTree = ""; }; A999D40524663D18004C89D4 /* PumpManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerError.swift; sourceTree = ""; }; - A99A114129A581F4007919CE /* BolusAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; - A99A114329A5829A007919CE /* CarbAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; - A99A114529A582A2007919CE /* OverrideAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; - A99A114B29A5879C007919CE /* BolusActionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusActionTests.swift; sourceTree = ""; }; - A99A114C29A5879C007919CE /* CarbActionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbActionTests.swift; sourceTree = ""; }; - A99A114D29A5879C007919CE /* OverrideActionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideActionTests.swift; sourceTree = ""; }; A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportView.swift; sourceTree = ""; }; A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportViewModel.swift; sourceTree = ""; }; A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserNotifications+Loop.swift"; sourceTree = ""; }; @@ -2810,19 +2798,6 @@ A99A114029A581D6007919CE /* Remote */ = { isa = PBXGroup; children = ( - A99A114129A581F4007919CE /* BolusAction.swift */, - A99A114329A5829A007919CE /* CarbAction.swift */, - A99A114529A582A2007919CE /* OverrideAction.swift */, - ); - path = Remote; - sourceTree = ""; - }; - A99A114A29A58789007919CE /* Remote */ = { - isa = PBXGroup; - children = ( - A99A114B29A5879C007919CE /* BolusActionTests.swift */, - A99A114C29A5879C007919CE /* CarbActionTests.swift */, - A99A114D29A5879C007919CE /* OverrideActionTests.swift */, ); path = Remote; sourceTree = ""; @@ -2838,7 +2813,6 @@ A9E6DFED246A0460005B1A1C /* Models */ = { isa = PBXGroup; children = ( - A99A114A29A58789007919CE /* Remote */, A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */, A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */, A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */, @@ -3868,7 +3842,6 @@ C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */, 4372E487213C86240068E043 /* SampleValue.swift in Sources */, 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */, - A99A114229A581F4007919CE /* BolusAction.swift in Sources */, C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */, @@ -3929,7 +3902,6 @@ 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, - A99A114629A582A2007919CE /* OverrideAction.swift in Sources */, E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, @@ -3977,7 +3949,6 @@ A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */, 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */, - A99A114429A5829A007919CE /* CarbAction.swift in Sources */, A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, @@ -4149,12 +4120,10 @@ buildActionMask = 2147483647; files = ( A9DF02CB24F72B9E00B7C988 /* CriticalEventLogTests.swift in Sources */, - A99A114F29A5879D007919CE /* CarbActionTests.swift in Sources */, B44251B3252350CE00605937 /* ChartAxisValuesStaticGeneratorTests.swift in Sources */, 1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */, C1777A6625A125F100595963 /* ManualEntryDoseViewModelTests.swift in Sources */, C16B984026B4898800256B05 /* DoseEnactorTests.swift in Sources */, - A99A115029A5879D007919CE /* OverrideActionTests.swift in Sources */, A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */, A9DFAFB324F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift in Sources */, A963B27A252CEBAE0062AA12 /* SetBolusUserInfoTests.swift in Sources */, @@ -4174,7 +4143,6 @@ B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */, 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */, A91E4C2124F867A700BE9213 /* StoredAlertTests.swift in Sources */, - A99A114E29A5879D007919CE /* BolusActionTests.swift in Sources */, 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */, A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */, E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 9c48de6c02..c3b11f82a7 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -407,7 +407,6 @@ final class DeviceDataManager { overrideHistory: overrideHistory, insulinDeliveryStore: doseStore.insulinDeliveryStore ) - settingsManager.remoteDataServicesManager = remoteDataServicesManager @@ -416,7 +415,10 @@ final class DeviceDataManager { alertManager: alertManager, analyticsServicesManager: analyticsServicesManager, loggingServicesManager: loggingServicesManager, - remoteDataServicesManager: remoteDataServicesManager + remoteDataServicesManager: remoteDataServicesManager, + settingsManager: settingsManager, + servicesManagerDelegate: loopManager, + servicesManagerDosingDelegate: self ) let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] @@ -1339,6 +1341,7 @@ extension DeviceDataManager: LoopDataManagerDelegate { self.crashRecoveryManager.dosingFinished() } } + } extension Notification.Name { @@ -1347,152 +1350,14 @@ extension Notification.Name { static let PumpEventsAdded = Notification.Name(rawValue: "com.loopKit.notification.PumpEventsAdded") } -// MARK: - Remote Notification Handling -extension DeviceDataManager { - - func handleRemoteNotification(_ notification: [String: AnyObject]) { - Task { - let backgroundTask = await beginBackgroundTask(name: "Remote Data Upload") - await handleRemoteNotification(notification) - await endBackgroundTask(backgroundTask) - } - } - - func handleRemoteNotification(_ notification: [String: AnyObject]) async { - - defer { - log.default("Remote Notification: Finished handling") - } - - guard FeatureFlags.remoteCommandsEnabled else { - log.error("Remote Notification: Remote Commands not enabled.") - return - } - - let command: RemoteCommand - do { - command = try await remoteDataServicesManager.commandFromPushNotification(notification) - } catch { - log.error("Remote Notification: Parse Error: %{public}@", String(describing: error)) - return - } - - await handleRemoteCommand(command) - } - - func handleRemoteCommand(_ command: RemoteCommand) async { - - log.default("Remote Notification: Handling command %{public}@", String(describing: command)) - - switch command.action { - case .temporaryScheduleOverride(let overrideAction): - do { - try command.validate() - try await handleOverrideAction(overrideAction) - } catch { - log.error("Remote Notification: Override Action Error: %{public}@", String(describing: error)) - } - case .cancelTemporaryOverride(let overrideCancelAction): - do { - try command.validate() - try await handleOverrideCancelAction(overrideCancelAction) - } catch { - log.error("Remote Notification: Override Action Cancel Error: %{public}@", String(describing: error)) - } - case .bolusEntry(let bolusAction): - do { - try command.validate() - try await handleBolusAction(bolusAction) - } catch { - await NotificationManager.sendRemoteBolusFailureNotification(for: error, amount: bolusAction.amountInUnits) - log.error("Remote Notification: Bolus Action Error: %{public}@", String(describing: error)) - } - case .carbsEntry(let carbAction): - do { - try command.validate() - try await handleCarbAction(carbAction) - } catch { - await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: carbAction.amountInGrams) - log.error("Remote Notification: Carb Action Error: %{public}@", String(describing: error)) - } - } - } - - //Remote Overrides - - func handleOverrideAction(_ action: OverrideAction) async throws { - let remoteOverride = try action.toValidOverride(allowedPresets: loopManager.settings.overridePresets) - await activateRemoteOverride(remoteOverride) - } - - func handleOverrideCancelAction(_ action: OverrideCancelAction) async throws { - await activateRemoteOverride(nil) - } - - func activateRemoteOverride(_ remoteOverride: TemporaryScheduleOverride?) async { - loopManager.mutateSettings { settings in settings.scheduleOverride = remoteOverride } - await remoteDataServicesManager.triggerUpload(for: .overrides) - } - - //Remote Bolus - - func handleBolusAction(_ action: BolusAction) async throws { - let validBolusAmount = try action.toValidBolusAmount(maximumBolus: loopManager.settings.maximumBolus) - try await self.enactBolus(units: validBolusAmount, activationType: .manualNoRecommendation) - await remoteDataServicesManager.triggerUpload(for: .dose) - self.analyticsServicesManager.didBolus(source: "Remote", units: validBolusAmount) - } - - //Remote Carb Entry - - func handleCarbAction(_ action: CarbAction) async throws { - let candidateCarbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: carbStore.defaultAbsorptionTimes.medium, - minAbsorptionTime: LoopConstants.minCarbAbsorptionTime, - maxAbsorptionTime: LoopConstants.maxCarbAbsorptionTime, - maxCarbEntryQuantity: LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()), - maxCarbEntryPastTime: LoopConstants.maxCarbEntryPastTime, - maxCarbEntryFutureTime: LoopConstants.maxCarbEntryFutureTime - ) - - let _ = try await addRemoteCarbEntry(candidateCarbEntry) - await remoteDataServicesManager.triggerUpload(for: .carb) - } - - //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version - func addRemoteCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { - return try await withCheckedThrowingContinuation { continuation in - carbStore.addCarbEntry(carbEntry) { result in - switch result { - case .success(let storedCarbEntry): - self.analyticsServicesManager.didAddCarbs(source: "Remote", amount: carbEntry.quantity.doubleValue(for: .gram())) - continuation.resume(returning: storedCarbEntry) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - //Background Uploads +// MARK: - ServicesManagerDosingDelegate + +extension DeviceDataManager: ServicesManagerDosingDelegate { - func beginBackgroundTask(name: String) async -> UIBackgroundTaskIdentifier? { - var backgroundTask: UIBackgroundTaskIdentifier? - backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: name) { - guard let backgroundTask = backgroundTask else {return} - Task { - await UIApplication.shared.endBackgroundTask(backgroundTask) - } - - self.log.error("Background Task Expired: %{public}@", name) - } - - return backgroundTask + func deliverBolus(amountInUnits: Double) async throws { + try await enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) } - func endBackgroundTask(_ backgroundTask: UIBackgroundTaskIdentifier?) async { - guard let backgroundTask else {return} - await UIApplication.shared.endBackgroundTask(backgroundTask) - } } // MARK: - Critical Event Log Export diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 3ec8c27fe6..e0c7758097 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -344,7 +344,7 @@ class LoopAppManager: NSObject { guard let notification = notification else { return false } - deviceDataManager?.handleRemoteNotification(notification) + deviceDataManager?.servicesManager.handleRemoteNotification(notification) return true } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index ffc66ee314..75359044c9 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -2350,3 +2350,125 @@ extension LoopDataManager { } } } + +extension LoopDataManager: ServicesManagerDelegate { + + //Overrides + + func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws { + + guard let preset = settings.overridePresets.first(where: { $0.name == name }) else { + throw EnactOverrideError.unknownPreset(name) + } + + var remoteOverride = preset.createOverride(enactTrigger: .remote(remoteAddress)) + + if let duration { + remoteOverride.duration = duration + } + + await enactOverride(remoteOverride) + } + + + func cancelCurrentOverride() async throws { + await enactOverride(nil) + } + + func enactOverride(_ override: TemporaryScheduleOverride?) async { + mutateSettings { settings in settings.scheduleOverride = override } + } + + enum EnactOverrideError: LocalizedError { + + case unknownPreset(String) + + var errorDescription: String? { + switch self { + case .unknownPreset(let presetName): + return String(format: NSLocalizedString("Unknown preset: %1$@", comment: "Override error description: unknown preset (1: preset name)."), presetName) + } + } + } + + //Carb Entry + + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { + + let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium + if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { + throw CarbActionError.invalidAbsorptionTime(absorptionTime) + } + + guard amountInGrams > 0.0 else { + throw CarbActionError.invalidCarbs + } + + guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()) else { + throw CarbActionError.exceedsMaxCarbs + } + + if let startDate = startDate { + let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) + let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) + guard startDate <= maxStartDate && startDate >= minStartDate else { + throw CarbActionError.invalidStartDate(startDate) + } + } + + let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) + let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) + + let _ = try await devliverCarbEntry(candidateCarbEntry) + } + + enum CarbActionError: LocalizedError { + + case invalidAbsorptionTime(TimeInterval) + case invalidStartDate(Date) + case exceedsMaxCarbs + case invalidCarbs + + var errorDescription: String? { + switch self { + case .exceedsMaxCarbs: + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") + case .invalidCarbs: + return NSLocalizedString("Invalid carb amount", comment: "Carb error description: invalid carb amount.") + case .invalidAbsorptionTime(let absorptionTime): + let absorptionHoursFormatted = Self.numberFormatter.string(from: absorptionTime.hours) ?? "" + return String(format: NSLocalizedString("Invalid absorption time: %1$@ hours", comment: "Carb error description: invalid absorption time. (1: Input duration in hours)."), absorptionHoursFormatted) + case .invalidStartDate(let startDate): + let startDateFormatted = Self.dateFormatter.string(from: startDate) + return String(format: NSLocalizedString("Start time is out of range: %@", comment: "Carb error description: invalid start time is out of range."), startDateFormatted) + } + } + + static var numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() + + static var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.timeStyle = .medium + return formatter + }() + } + + //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version + func devliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { + return try await withCheckedThrowingContinuation { continuation in + carbStore.addCarbEntry(carbEntry) { result in + switch result { + case .success(let storedCarbEntry): + continuation.resume(returning: storedCarbEntry) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + +} diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index c43e3939bb..996d147047 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -79,27 +79,6 @@ extension NotificationManager { // MARK: - Notifications - @MainActor - static func sendRemoteCommandExpiredNotification(timeExpired: TimeInterval) { - let notification = UNMutableNotificationContent() - - notification.title = NSLocalizedString("Remote Command Expired", comment: "The notification title for the remote command expiration error") - - notification.body = String(format: NSLocalizedString("The remote command expired %.0f minutes ago.", comment: "The notification body for a remote command expiration. (1: Expiration in minutes)"), fabs(timeExpired / 60.0)) - notification.sound = .default - - notification.categoryIdentifier = LoopNotificationCategory.remoteCommandExpired.rawValue - - let request = UNNotificationRequest( - // Only support 1 expiration notification at once - identifier: LoopNotificationCategory.remoteCommandExpired.rawValue, - content: notification, - trigger: nil - ) - - UNUserNotificationCenter.current().add(request) - } - static func sendBolusFailureNotification(for error: PumpManagerError, units: Double, at startDate: Date, activationType: BolusActivationType) { let notification = UNMutableNotificationContent() @@ -160,10 +139,10 @@ extension NotificationManager { } @MainActor - static func sendRemoteBolusFailureNotification(for error: Error, amount: Double) { + static func sendRemoteBolusFailureNotification(for error: Error, amountInUnits: Double) { let notification = UNMutableNotificationContent() let quantityFormatter = QuantityFormatter(for: .internationalUnit()) - guard let amountDescription = quantityFormatter.numberFormatter.string(from: amount) else { + guard let amountDescription = quantityFormatter.numberFormatter.string(from: amountInUnits) else { return } diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index e8a8ea0868..14a3416900 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -596,26 +596,6 @@ extension RemoteDataServicesManager { } extension RemoteDataServicesManager { - - func serviceForPushNotification(_ notification: [String: AnyObject]) -> RemoteDataService? { - - let defaultServiceIdentifier = "NightscoutService" - let serviceIdentifier = notification["serviceIdentifier"] as? String ?? defaultServiceIdentifier - return remoteDataServices.first(where: {$0.serviceIdentifier == serviceIdentifier}) - } - - func commandFromPushNotification(_ notification: [String : AnyObject]) async throws -> RemoteCommand { - - enum RemoteDataServicesManagerCommandError: LocalizedError { - case missingNotificationService - } - - guard let service = serviceForPushNotification(notification) else { - throw RemoteDataServicesManagerCommandError.missingNotificationService - } - - return try await service.commandFromPushNotification(notification) - } public func temporaryScheduleOverrideHistoryDidUpdate() { triggerUpload(for: .overrides) @@ -657,6 +637,35 @@ extension RemoteDataServicesManager { } } +//Remote Commands +extension RemoteDataServicesManager { + + public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { + let service = try serviceForPushNotification(notification) + return try await service.remoteNotificationWasReceived(notification) + } + + func serviceForPushNotification(_ notification: [String: AnyObject]) throws -> RemoteDataService { + let defaultServiceIdentifier = "NightscoutService" + let serviceIdentifier = notification["serviceIdentifier"] as? String ?? defaultServiceIdentifier + guard let service = remoteDataServices.first(where: {$0.serviceIdentifier == serviceIdentifier}) else { + throw RemoteDataServicesManagerCommandError.unsupportedServiceIdentifier(serviceIdentifier) + } + return service + } + + enum RemoteDataServicesManagerCommandError: LocalizedError { + case unsupportedServiceIdentifier(String) + + var errorDescription: String? { + switch self { + case .unsupportedServiceIdentifier(let serviceIdentifier): + return String(format: NSLocalizedString("Unsupported Notification Service: %1$@", comment: "Error message when a service can't be found to handle a push notification. (1: Service Identifier)"), serviceIdentifier) + } + } + } +} + protocol RemoteDataServicesManagerDelegate: AnyObject { var shouldSyncToRemoteService: Bool {get} } diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 3140029e08..2593560706 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -10,6 +10,7 @@ import os.log import LoopKit import LoopKitUI import LoopCore +import Combine class ServicesManager { @@ -23,11 +24,18 @@ class ServicesManager { let remoteDataServicesManager: RemoteDataServicesManager + let settingsManager: SettingsManager + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + private var services = [Service]() private let servicesLock = UnfairLock() private let log = OSLog(category: "ServicesManager") + + lazy private var cancellables = Set() @PersistedProperty(key: "Services") var rawServices: [Service.RawValue]? @@ -37,13 +45,19 @@ class ServicesManager { alertManager: AlertManager, analyticsServicesManager: AnalyticsServicesManager, loggingServicesManager: LoggingServicesManager, - remoteDataServicesManager: RemoteDataServicesManager + remoteDataServicesManager: RemoteDataServicesManager, + settingsManager: SettingsManager, + servicesManagerDelegate: ServicesManagerDelegate, + servicesManagerDosingDelegate: ServicesManagerDosingDelegate ) { self.pluginManager = pluginManager self.alertManager = alertManager self.analyticsServicesManager = analyticsServicesManager self.loggingServicesManager = loggingServicesManager self.remoteDataServicesManager = remoteDataServicesManager + self.settingsManager = settingsManager + self.servicesManagerDelegate = servicesManagerDelegate + self.servicesManagerDosingDelegate = servicesManagerDosingDelegate restoreState() } @@ -172,6 +186,56 @@ class ServicesManager { } } } + + func handleRemoteNotification(_ notification: [String: AnyObject]) { + Task { + log.default("Remote Notification: Handling notification %{public}@", notification) + + guard FeatureFlags.remoteCommandsEnabled else { + log.error("Remote Notification: Remote Commands not enabled.") + return + } + + let backgroundTask = await beginBackgroundTask(name: "Handle Remote Notification") + do { + try await remoteDataServicesManager.remoteNotificationWasReceived(notification) + } catch { + log.error("Remote Notification: Error: %{public}@", String(describing: error)) + } + + await endBackgroundTask(backgroundTask) + log.default("Remote Notification: Finished handling") + } + } + + private func beginBackgroundTask(name: String) async -> UIBackgroundTaskIdentifier? { + var backgroundTask: UIBackgroundTaskIdentifier? + backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: name) { + guard let backgroundTask = backgroundTask else {return} + Task { + await UIApplication.shared.endBackgroundTask(backgroundTask) + } + + self.log.error("Background Task Expired: %{public}@", name) + } + + return backgroundTask + } + + private func endBackgroundTask(_ backgroundTask: UIBackgroundTaskIdentifier?) async { + guard let backgroundTask else {return} + await UIApplication.shared.endBackgroundTask(backgroundTask) + } +} + +public protocol ServicesManagerDosingDelegate: AnyObject { + func deliverBolus(amountInUnits: Double) async throws +} + +public protocol ServicesManagerDelegate: AnyObject { + func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws + func cancelCurrentOverride() async throws + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws } // MARK: - ServiceDelegate @@ -201,6 +265,105 @@ extension ServicesManager: ServiceDelegate { log.default("Service with identifier '%{public}@' deleted", service.serviceIdentifier) removeActiveService(service) } + + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws { + + var duration: TemporaryScheduleOverride.Duration? = nil + if let durationTime = durationTime { + + guard durationTime <= LoopConstants.maxOverrideDurationTime else { + throw OverrideActionError.durationExceedsMax(LoopConstants.maxOverrideDurationTime) + } + + guard durationTime >= 0 else { + throw OverrideActionError.negativeDuration + } + + if durationTime == 0 { + duration = .indefinite + } else { + duration = .finite(durationTime) + } + } + + try await servicesManagerDelegate?.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) + await remoteDataServicesManager.triggerUpload(for: .overrides) + } + + enum OverrideActionError: LocalizedError { + + case durationExceedsMax(TimeInterval) + case negativeDuration + + var errorDescription: String? { + switch self { + case .durationExceedsMax(let maxDurationTime): + return String(format: NSLocalizedString("Duration exceeds: %1$.1f hours", comment: "Override error description: duration exceed max (1: max duration in hours)."), maxDurationTime.hours) + case .negativeDuration: + return String(format: NSLocalizedString("Negative duration not allowed", comment: "Override error description: negative duration error.")) + } + } + } + + func cancelRemoteOverride() async throws { + try await servicesManagerDelegate?.cancelCurrentOverride() + await remoteDataServicesManager.triggerUpload(for: .overrides) + } + + func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { + do { + try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) + await NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) + await remoteDataServicesManager.triggerUpload(for: .carb) + analyticsServicesManager.didAddCarbs(source: "Remote", amount: amountInGrams) + } catch { + await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) + throw error + } + } + + func deliverRemoteBolus(amountInUnits: Double) async throws { + do { + + guard amountInUnits > 0 else { + throw BolusActionError.invalidBolus + } + + guard let maxBolusAmount = settingsManager.loopSettings.maximumBolus else { + throw BolusActionError.missingMaxBolus + } + + guard amountInUnits <= maxBolusAmount else { + throw BolusActionError.exceedsMaxBolus + } + + try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) + await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) + await remoteDataServicesManager.triggerUpload(for: .dose) + analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) + } catch { + await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) + throw error + } + } + + enum BolusActionError: LocalizedError { + + case invalidBolus + case missingMaxBolus + case exceedsMaxBolus + + var errorDescription: String? { + switch self { + case .invalidBolus: + return NSLocalizedString("Invalid Bolus Amount", comment: "Bolus error description: invalid bolus amount.") + case .missingMaxBolus: + return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Bolus error description: missing maximum bolus in settings.") + case .exceedsMaxBolus: + return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Bolus error description: bolus exceeds maximum bolus in settings.") + } + } + } } extension ServicesManager: AlertIssuer { diff --git a/Loop/Models/Remote/BolusAction.swift b/Loop/Models/Remote/BolusAction.swift deleted file mode 100644 index 054b618089..0000000000 --- a/Loop/Models/Remote/BolusAction.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// BolusAction.swift -// Loop -// -// Created by Bill Gestrich on 2/21/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import LoopKit - -extension BolusAction { - func toValidBolusAmount(maximumBolus: Double?) throws -> Double { - - guard amountInUnits > 0 else { - throw BolusActionError.invalidBolus - } - - guard let maxBolusAmount = maximumBolus else { - throw BolusActionError.missingMaxBolus - } - - guard amountInUnits <= maxBolusAmount else { - throw BolusActionError.exceedsMaxBolus - } - - return amountInUnits - } -} - -enum BolusActionError: LocalizedError { - - case invalidBolus - case missingMaxBolus - case exceedsMaxBolus - - var errorDescription: String? { - switch self { - case .invalidBolus: - return NSLocalizedString("Invalid Bolus Amount", comment: "Remote command error description: invalid bolus amount.") - case .missingMaxBolus: - return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Remote command error description: missing maximum bolus in settings.") - case .exceedsMaxBolus: - return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Remote command error description: bolus exceeds maximum bolus in settings.") - } - } -} diff --git a/Loop/Models/Remote/CarbAction.swift b/Loop/Models/Remote/CarbAction.swift deleted file mode 100644 index fd00ed9d6a..0000000000 --- a/Loop/Models/Remote/CarbAction.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// CarbAction.swift -// Loop -// -// Created by Bill Gestrich on 2/21/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import LoopKit -import HealthKit - -extension CarbAction { - - func toValidCarbEntry(defaultAbsorptionTime: TimeInterval, - minAbsorptionTime: TimeInterval, - maxAbsorptionTime: TimeInterval, - maxCarbEntryQuantity: Double, - maxCarbEntryPastTime: TimeInterval, - maxCarbEntryFutureTime: TimeInterval, - nowDate: Date = Date()) throws -> NewCarbEntry { - - let absorptionTime = absorptionTime ?? defaultAbsorptionTime - if absorptionTime < minAbsorptionTime || absorptionTime > maxAbsorptionTime { - throw CarbActionError.invalidAbsorptionTime(absorptionTime) - } - - guard amountInGrams > 0.0 else { - throw CarbActionError.invalidCarbs - } - - guard amountInGrams <= maxCarbEntryQuantity else { - throw CarbActionError.exceedsMaxCarbs - } - - if let startDate = startDate { - let maxStartDate = nowDate.addingTimeInterval(maxCarbEntryFutureTime) - let minStartDate = nowDate.addingTimeInterval(maxCarbEntryPastTime) - guard startDate <= maxStartDate && startDate >= minStartDate else { - throw CarbActionError.invalidStartDate(startDate) - } - } - - let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) - return NewCarbEntry(quantity: quantity, startDate: startDate ?? nowDate, foodType: foodType, absorptionTime: absorptionTime) - } -} - -enum CarbActionError: LocalizedError { - - case invalidAbsorptionTime(TimeInterval) - case invalidStartDate(Date) - case exceedsMaxCarbs - case invalidCarbs - - var errorDescription: String? { - switch self { - case .exceedsMaxCarbs: - return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Remote command error description: carbs exceed maximum amount.") - case .invalidCarbs: - return NSLocalizedString("Invalid carb amount", comment: "Remote command error description: invalid carb amount.") - case .invalidAbsorptionTime(let absorptionTime): - return String(format: NSLocalizedString("Invalid absorption time: %d hours", comment: "Remote command error description: invalid absorption time."), absorptionTime.hours) - case .invalidStartDate(let startDate): - return String(format: NSLocalizedString("Start time is out of range: %@", comment: "Remote command error description: invalid start time is out of range."), Self.dateFormatter.string(from: startDate)) - } - } - - static var dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.timeStyle = .medium - return formatter - }() -} diff --git a/Loop/Models/Remote/OverrideAction.swift b/Loop/Models/Remote/OverrideAction.swift deleted file mode 100644 index 28ea26e383..0000000000 --- a/Loop/Models/Remote/OverrideAction.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// OverrideAction.swift -// Loop -// -// Created by Bill Gestrich on 2/21/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import LoopKit - -extension OverrideAction { - - func toValidOverride(allowedPresets: [TemporaryScheduleOverridePreset]) throws -> TemporaryScheduleOverride { - guard let preset = allowedPresets.first(where: { $0.name == name }) else { - throw OverrideActionError.unknownPreset(name) - } - - var remoteOverride = preset.createOverride(enactTrigger: .remote(remoteAddress)) - - if let durationTime = durationTime { - - guard durationTime <= LoopConstants.maxOverrideDurationTime else { - throw OverrideActionError.durationExceedsMax(LoopConstants.maxOverrideDurationTime) - } - - guard durationTime >= 0 else { - throw OverrideActionError.negativeDuration - } - - if durationTime == 0 { - remoteOverride.duration = .indefinite - } else { - remoteOverride.duration = .finite(durationTime) - } - } - - return remoteOverride - } -} - -enum OverrideActionError: LocalizedError { - - case unknownPreset(String) - case durationExceedsMax(TimeInterval) - case negativeDuration - - var errorDescription: String? { - switch self { - case .unknownPreset(let presetName): - return String(format: NSLocalizedString("Unknown preset: %1$@", comment: "Remote command error description: unknown preset (1: preset name)."), presetName) - case .durationExceedsMax(let maxDurationTime): - return String(format: NSLocalizedString("Duration exceeds: %1$.1f hours", comment: "Remote command error description: duration exceed max (1: max duration in hours)."), maxDurationTime.hours) - case .negativeDuration: - return String(format: NSLocalizedString("Negative duration not allowed", comment: "Remote command error description: negative duration error.")) - } - } -} diff --git a/Loop/View Controllers/CarbEntryViewController.swift b/Loop/View Controllers/CarbEntryViewController.swift index 7a8c950a34..92b7ef7be4 100644 --- a/Loop/View Controllers/CarbEntryViewController.swift +++ b/Loop/View Controllers/CarbEntryViewController.swift @@ -405,8 +405,8 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable cell.datePicker.preferredDatePickerStyle = .wheels } #endif - cell.datePicker.maximumDate = date.addingTimeInterval(.hours(1)) - cell.datePicker.minimumDate = date.addingTimeInterval(.hours(-12)) + cell.datePicker.maximumDate = date.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) + cell.datePicker.minimumDate = date.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) cell.datePicker.minuteInterval = 1 cell.date = date cell.delegate = self diff --git a/LoopTests/Models/Remote/BolusActionTests.swift b/LoopTests/Models/Remote/BolusActionTests.swift deleted file mode 100644 index 2129ea68b4..0000000000 --- a/LoopTests/Models/Remote/BolusActionTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// BolusActionTests.swift -// LoopKitTests -// -// Created by Bill Gestrich on 1/14/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import XCTest -@testable import Loop -import LoopKit - -final class BolusActionTests: XCTestCase { - - override func setUpWithError() throws { - } - - override func tearDownWithError() throws { - } - - func testToValidBolusAtMaxAmount_Succeeds() throws { - - //Arrange - let maxBolusAmount = 10.0 - let bolusAmount = maxBolusAmount - let action = BolusAction(amountInUnits: bolusAmount) - - //Act - let validatedBolusAmount = try action.toValidBolusAmount(maximumBolus: 10.0) - - //Assert - XCTAssertEqual(validatedBolusAmount, bolusAmount) - - } - - func testToValidBolusAmount_AboveMaxAmount_Fails() throws { - - //Arrange - let maxBolusAmount = 10.0 - let bolusAmount = maxBolusAmount + 0.1 - let action = BolusAction(amountInUnits: bolusAmount) - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidBolusAmount(maximumBolus: maxBolusAmount) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? BolusActionError, case .exceedsMaxBolus = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - - func testToValidBolusAmount_AtZero_Fails() throws { - - //Arrange - let bolusAmount = 0.0 - let action = BolusAction(amountInUnits: bolusAmount) - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidBolusAmount(maximumBolus: 10.0) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? BolusActionError, case .invalidBolus = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - - func testToValidBolusAmount_NegativeAmount_Fails() throws { - - //Arrange - let bolusAmount = -1.0 - let action = BolusAction(amountInUnits: bolusAmount) - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidBolusAmount(maximumBolus: 10.0) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? BolusActionError, case .invalidBolus = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - - -} diff --git a/LoopTests/Models/Remote/CarbActionTests.swift b/LoopTests/Models/Remote/CarbActionTests.swift deleted file mode 100644 index 277051252a..0000000000 --- a/LoopTests/Models/Remote/CarbActionTests.swift +++ /dev/null @@ -1,402 +0,0 @@ -// -// CarbActionTests.swift -// LoopTests -// -// Created by Bill Gestrich on 1/14/23. -// Copyright © 2022 LoopKit Authors. All rights reserved. -// - -import XCTest -import HealthKit -@testable import Loop -import LoopKit - -class CarbActionTests: XCTestCase { - - override func setUpWithError() throws { - } - - override func tearDownWithError() throws { - } - - - func testToValidCarbEntry_Succeeds() throws { - - //Arrange - let expectedCarbsInGrams = 15.0 - let expectedDate = Date() - let expectedAbsorptionTime = TimeInterval(hours: 4.0) - let foodType = "🍕" - - let action = CarbAction(amountInGrams: expectedCarbsInGrams, absorptionTime: expectedAbsorptionTime, foodType: foodType, startDate: expectedDate) - - //Act - let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: expectedCarbsInGrams, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: .hours(1) - ) - - //Assert - XCTAssertEqual(carbEntry.startDate, expectedDate) - XCTAssertEqual(carbEntry.absorptionTime, expectedAbsorptionTime) - XCTAssertEqual(carbEntry.quantity, HKQuantity(unit: .gram(), doubleValue: expectedCarbsInGrams)) - XCTAssertEqual(carbEntry.foodType, foodType) - } - - func testToValidCarbEntry_MissingAbsorptionHours_UsesDefaultAbsorption() throws { - - //Arrange - let defaultAbsorptionTime = TimeInterval(hours: 4.0) - let action = CarbAction(amountInGrams: 15.0, startDate: Date()) - - //Act - let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: defaultAbsorptionTime, - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: .hours(1)) - - //Assert - XCTAssertEqual(carbEntry.absorptionTime, defaultAbsorptionTime) - } - - func testToValidCarbEntry_AtMinAbsorptionHours_Succeeds() throws { - - //Arrange - let minAbsorptionTime = TimeInterval(hours: 0.5) - let action = CarbAction(amountInGrams: 15.0, - absorptionTime: minAbsorptionTime, - startDate: Date()) - - //Act - let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: minAbsorptionTime, - minAbsorptionTime: minAbsorptionTime, - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: .hours(1)) - - //Assert - XCTAssertEqual(carbEntry.absorptionTime, minAbsorptionTime) - } - - func testToValidCarbEntry_BelowMinAbsorptionHours_Fails() throws { - - //Arrange - let minAbsorptionTime = TimeInterval(hours: 0.5) - let aborptionOverrideTime = TimeInterval(hours: 0.4) - let action = CarbAction(amountInGrams: 15.0, - absorptionTime: aborptionOverrideTime, - startDate: Date()) - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidCarbEntry(defaultAbsorptionTime: minAbsorptionTime, - minAbsorptionTime: minAbsorptionTime, - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: .hours(1) - ) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? CarbActionError, case .invalidAbsorptionTime = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - - func testToValidCarbEntry_AtMaxAbsorptionHours_Succeeds() throws { - - //Arrange - let maxAbsorptionTime = TimeInterval(hours: 5.0) - let action = CarbAction(amountInGrams: 15.0, - absorptionTime: maxAbsorptionTime, - startDate: Date()) - - //Act - let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: maxAbsorptionTime, - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: maxAbsorptionTime, - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: .hours(1) - ) - - //Assert - XCTAssertEqual(carbEntry.absorptionTime, maxAbsorptionTime) - } - - func testToValidCarbEntry_AboveMaxAbsorptionHours_Fails() throws { - - //Arrange - let maxAbsorptionTime = TimeInterval(hours: 5.0) - let absorptionTime = TimeInterval(hours: 5.1) - let action = CarbAction(amountInGrams: 15.0, - absorptionTime: absorptionTime, - startDate: Date()) - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidCarbEntry(defaultAbsorptionTime: maxAbsorptionTime, - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: maxAbsorptionTime, - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: .hours(1) - ) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? CarbActionError, case .invalidAbsorptionTime = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - - func testToValidCarbEntry_AtMinStartTime_Succeeds() throws { - - //Arrange - let maxCarbEntryPastTime = TimeInterval(hours: -12) - let nowDate = Date() - let startDate = nowDate.addingTimeInterval(maxCarbEntryPastTime) - let action = CarbAction(amountInGrams: 15.0, - absorptionTime: TimeInterval(hours: 5.0), - startDate: startDate) - - //Act - let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: maxCarbEntryPastTime, - maxCarbEntryFutureTime: .hours(1), - nowDate: nowDate - ) - - //Assert - XCTAssertEqual(carbEntry.startDate, startDate) - } - - func testToValidCarbEntry_BeforeMinStartTime_Fails() throws { - - //Arrange - let maxCarbEntryPastTime = TimeInterval(hours: -12) - let nowDate = Date() - let startDate = nowDate.addingTimeInterval(maxCarbEntryPastTime - 1) - let action = CarbAction(amountInGrams: 15.0, - absorptionTime: TimeInterval(hours: 5.0), - startDate: startDate) - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: maxCarbEntryPastTime, - maxCarbEntryFutureTime: .hours(1) - ) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? CarbActionError, case .invalidStartDate = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - - func testToValidCarbEntry_AtMaxStartTime_Succeeds() throws { - - //Arrange - let maxCarbEntryFutureTime = TimeInterval(hours: 1) - let nowDate = Date() - let startDate = nowDate.addingTimeInterval(maxCarbEntryFutureTime) - let action = CarbAction(amountInGrams: 15.0, - absorptionTime: TimeInterval(hours: 5.0), - startDate: startDate) - - //Act - let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: maxCarbEntryFutureTime, - nowDate: nowDate - ) - - //Assert - XCTAssertEqual(carbEntry.startDate, startDate) - } - - func testToValidCarbEntry_AfterMaxStartTime_Fails() throws { - - //Arrange - let maxCarbEntryFutureTime = TimeInterval(hours: 1) - let nowDate = Date() - let startDate = nowDate.addingTimeInterval(maxCarbEntryFutureTime + 1) - let action = CarbAction(amountInGrams: 15.0, - absorptionTime: TimeInterval(hours: 5.0), - startDate: startDate) - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: maxCarbEntryFutureTime - ) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? CarbActionError, case .invalidStartDate = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - - func testToValidCarbEntry_AtMaxCarbs_Succeeds() throws { - - let maxCarbsAmount = 200.0 - let carbsAmount = maxCarbsAmount - - //Arrange - let action = CarbAction(amountInGrams: carbsAmount, - absorptionTime: TimeInterval(hours: 5.0), - startDate: Date()) - - //Act - let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: maxCarbsAmount, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: TimeInterval(hours: 1) - ) - - //Assert - XCTAssertEqual(carbEntry.quantity, HKQuantity(unit: .gram(), doubleValue: carbsAmount)) - } - - func testToValidCarbEntry_AboveMaxCarbs_Fails() throws { - - let maxCarbsAmount = 200.0 - let carbsAmount = maxCarbsAmount + 1 - - //Arrange - let action = CarbAction(amountInGrams: carbsAmount, - absorptionTime: TimeInterval(hours: 5.0), - startDate: Date()) - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: .hours(1.0) - ) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? CarbActionError, case .exceedsMaxCarbs = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - - func testToValidCarbEntry_NegativeCarbs_Fails() throws { - - let carbsAmount = -1.0 - - //Arrange - let action = CarbAction(amountInGrams: carbsAmount, - absorptionTime: TimeInterval(hours: 5.0), - startDate: Date()) - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: .hours(1.0) - ) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? CarbActionError, case .invalidCarbs = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - - func testToValidCarbEntry_ZeroCarbs_Fails() throws { - - let carbsAmount = 0.0 - - //Arrange - let action = CarbAction(amountInGrams: carbsAmount, - absorptionTime: TimeInterval(hours: 5.0), - startDate: Date()) - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), - minAbsorptionTime: TimeInterval(hours: 0.5), - maxAbsorptionTime: TimeInterval(hours: 5.0), - maxCarbEntryQuantity: 200, - maxCarbEntryPastTime: .hours(-12), - maxCarbEntryFutureTime: .hours(1.0) - ) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? CarbActionError, case .invalidCarbs = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - -} - - -//MARK: Utils - -func dateFormatter() -> ISO8601DateFormatter { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter -} - diff --git a/LoopTests/Models/Remote/OverrideActionTests.swift b/LoopTests/Models/Remote/OverrideActionTests.swift deleted file mode 100644 index fe9f5d7fb3..0000000000 --- a/LoopTests/Models/Remote/OverrideActionTests.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// OverrideActionTests.swift -// LoopKitTests -// -// Created by Bill Gestrich on 1/14/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import XCTest -@testable import Loop -import LoopKit - -final class OverrideActionTests: XCTestCase { - - override func setUpWithError() throws { - - } - - override func tearDownWithError() throws { - - } - - func testToValidOverride_Succeeds() throws { - - //Arrange - let durationTime = TimeInterval(hours: 1.0) - let remoteAddress = "1234-54321" - let overrideName = "My-Override" - let action = OverrideAction(name: overrideName, durationTime: durationTime, remoteAddress: remoteAddress) - let presets = [TemporaryScheduleOverridePreset(symbol: "", name: overrideName, settings: .init(targetRange: .none), duration: .indefinite)] - - //Act - let validOverride = try action.toValidOverride(allowedPresets: presets) - - //Assert - XCTAssertEqual(validOverride.duration, .finite(durationTime)) - switch validOverride.enactTrigger { - case .remote(let triggerAddress): - XCTAssertEqual(triggerAddress, remoteAddress) - default: - XCTFail("Unexpected trigger trigger type") - } - } - - func testToValidOverride_WhenOverrideNotInPresets_Fails() throws { - - //Arrange - let action = OverrideAction(name: "Unknown-Override", durationTime: TimeInterval(hours: 1.0), remoteAddress: "1234-54321") - let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidOverride(allowedPresets: presets) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? OverrideActionError, case .unknownPreset = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - - func testToValidOverride_WhenNoDuration_YieldsIndefiniteOverride() throws { - - //Arrange - let action = OverrideAction(name: "My-Override", durationTime: nil, remoteAddress: "1234-54321") - let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] - - //Act - let validOverride = try action.toValidOverride(allowedPresets: presets) - - //Assert - XCTAssertEqual(validOverride.duration, .indefinite) - } - - func testToValidOverride_WhenDurationZero_YieldsIndefiniteOverride() throws { - - //Arrange - let action = OverrideAction(name: "My-Override", durationTime: TimeInterval(hours: 0), remoteAddress: "1234-54321") - let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] - - //Act - let validOverride = try action.toValidOverride(allowedPresets: presets) - - //Assert - XCTAssertEqual(validOverride.duration, .indefinite) - } - - func testToValidOverride_WhenNegativeDuration_Fails() throws { - - //Arrange - let action = OverrideAction(name: "My-Override", durationTime: TimeInterval(hours: -1.0), remoteAddress: "1234-54321") - let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidOverride(allowedPresets: presets) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? OverrideActionError, case .negativeDuration = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - - //Limit to 24 hour duration - - func testToValidOverride_WhenAtMaxDuration_Succeeds() throws { - - //Arrange - let duration = TimeInterval(hours: 24) - let action = OverrideAction(name: "My-Override", durationTime: duration, remoteAddress: "1234-54321") - let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] - - //Act - let validOverride = try action.toValidOverride(allowedPresets: presets) - - //Assert - XCTAssertEqual(validOverride.duration, .finite(duration)) - - } - - func testToValidOverride_WhenAtMaxDuration_Fails() throws { - - //Arrange - let duration = TimeInterval(hours: 24) + 1 - let action = OverrideAction(name: "My-Override", durationTime: duration, remoteAddress: "1234-54321") - let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] - - //Act - var thrownError: Error? = nil - do { - let _ = try action.toValidOverride(allowedPresets: presets) - } catch { - thrownError = error - } - - //Assert - guard let validationError = thrownError as? OverrideActionError, case .durationExceedsMax = validationError else { - XCTFail("Unexpected type \(thrownError.debugDescription)") - return - } - } - -}