diff --git a/remote_carb_edit/README.md b/remote_carb_edit/README.md new file mode 100644 index 0000000..980b7f4 --- /dev/null +++ b/remote_carb_edit/README.md @@ -0,0 +1,32 @@ +# Remote carb delete and edit + +Lets a caregiver app (LoopFollow) delete or edit a carb entry that Loop created within the last 24 hours, through the same OTP-protected APNS remote command path as remote carbs. + +Payload keys: + +- Delete: `carbs-delete` carrying the entry's `syncIdentifier` (Loop uploads it on every Carb Correction treatment). +- Edit: `carbs-edit` carrying the `syncIdentifier`, plus the required replacement values `carbs-edit-entry` (g), `carbs-edit-absorption-time` (hours) and `carbs-edit-start-time` (ISO 8601). `carbs-edit-food-type` is optional and keeps the existing food type when absent. An edit missing any required value is rejected before validation. +- Both: `otp`, `sent-at` and `expiration` are required, in addition to the usual `remote-address` and `entered-by`. + +Edits keep the entry's `syncIdentifier`. Replacement values are validated with the same limits as remote carb entries (absorption time range, maximum carb amount, start time window). + +Loop acknowledges each command through the existing return push (`encrypted_return_notification`): `command_status` `success` / `failed`, `command_type` `carbs_delete` / `carbs_edit`, and `sync_identifier` identifying the entry. Failures also post the usual Nightscout Note with the error text. Loop shows a local notification for both outcomes using the remote carbs notification categories. + +## Capability signal + +Every devicestatus upload carries `loop.testingDetails.remoteCommands`, a list of the remote command keys this build accepts (`["carbs-delete", "carbs-edit"]`). It is refreshed every loop cycle, so a follower sees it within minutes of the patched build going live. LoopFollow hides the delete and edit actions when the field is absent, which keeps an unpatched Loop from receiving commands it would reject. + +Touches Loop, LoopKit and NightscoutService. Generated against LoopWorkspace `dev` (3.14.8). Apply from the LoopWorkspace folder: + +``` +git apply --whitespace=nowarn dev_remote_carb_edit.patch +``` + +## Combining with remote_window + +- `dev_remote_carb_edit_remote_window.patch`: this customization applied on top of `remote_window/remote_window.patch`. +- `remote_window/remote_window_remote_carb_edit.patch`: remote_window applied on top of `dev_remote_carb_edit.patch`. + +Both orders produce the same tree. The selector script tries each folder's patches in sorted order and uses the first one that applies, so choosing both customizations picks the right file whichever is applied first. Applying by hand, use the plain patch for the first customization and the combined one for the second. + +With remote_window, `sent-at` is required on every remote command (the notification is rejected without it), and delete and edit commands appear in the remote command history with the entry's `syncIdentifier`. diff --git a/remote_carb_edit/dev_remote_carb_edit.patch b/remote_carb_edit/dev_remote_carb_edit.patch new file mode 100644 index 0000000..921b0be --- /dev/null +++ b/remote_carb_edit/dev_remote_carb_edit.patch @@ -0,0 +1,1008 @@ +Submodule Loop c2fddb76..2bf859f3: +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index c9aef285..8f497568 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -2536,32 +2536,83 @@ extension LoopDataManager: ServicesManagerDelegate { + //Carb Entry + + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { +- + let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium ++ let startDate = startDate ?? Date() ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) ++ ++ let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) ++ let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) ++ ++ let _ = try await devliverCarbEntry(candidateCarbEntry) ++ } ++ ++ /// Remote edits and deletes may only target entries this app created within the last 24 hours. ++ static let remoteCarbEditWindow: TimeInterval = .hours(24) ++ ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let entry = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ deleteCarbEntry(entry) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ return entry ++ } ++ ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { ++ let existing = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) ++ ++ let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) ++ let replacement = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType ?? existing.foodType, absorptionTime: absorptionTime) ++ ++ return try await withCheckedThrowingContinuation { continuation in ++ addCarbEntry(replacement, replacing: existing) { result in ++ switch result { ++ case .success(let storedCarbEntry): ++ continuation.resume(returning: storedCarbEntry) ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ } ++ ++ private func validateRemoteCarbParameters(amountInGrams: Double, absorptionTime: TimeInterval, startDate: Date) throws { + 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 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) ++ } ++ ++ private func remoteEditableCarbEntry(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let now = Date() ++ let entries = try await carbStore.getCarbEntries(start: now.addingTimeInterval(-Self.remoteCarbEditWindow), end: now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime)) ++ guard let entry = entries.first(where: { $0.syncIdentifier == syncIdentifier }) else { ++ throw CarbActionError.entryNotFound ++ } ++ guard entry.createdByCurrentApp else { ++ throw CarbActionError.entryNotEditable ++ } ++ return entry + } + + enum CarbActionError: LocalizedError { +@@ -2570,6 +2621,8 @@ extension LoopDataManager: ServicesManagerDelegate { + case invalidStartDate(Date) + case exceedsMaxCarbs + case invalidCarbs ++ case entryNotFound ++ case entryNotEditable + + var errorDescription: String? { + switch self { +@@ -2577,6 +2630,10 @@ extension LoopDataManager: ServicesManagerDelegate { + 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 .entryNotFound: ++ return NSLocalizedString("Carb entry not found or older than 24 hours", comment: "Carb error description: remote edit target not found.") ++ case .entryNotEditable: ++ return NSLocalizedString("Carb entry was not created by Loop", comment: "Carb error description: remote edit target not created by this app.") + 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) +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 996d1470..3eb3154b 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -201,6 +201,36 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ @MainActor ++ static func sendRemoteCarbChangeNotification(body: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbs.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ @MainActor ++ static func sendRemoteCarbChangeFailureNotification(body: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbsFailure.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ + static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { + let notification = UNMutableNotificationContent() + /// Notifications should expire after the missed meal is no longer relevant +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 2393ceb0..4f99e6f9 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -239,6 +239,8 @@ 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 ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } + + // MARK: - StatefulPluggableDelegate +@@ -329,6 +331,48 @@ extension ServicesManager: ServiceDelegate { + } + } + ++ func deleteRemoteCarbs(syncIdentifier: String) async throws { ++ do { ++ guard let servicesManagerDelegate else { ++ throw RemoteCarbCommandError.internalError ++ } ++ let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) ++ let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram()))) ++ await NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.triggerUpload(for: .carb) ++ } catch { ++ let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") ++ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws { ++ let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) ++ do { ++ guard let servicesManagerDelegate else { ++ throw RemoteCarbCommandError.internalError ++ } ++ _ = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ await NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.triggerUpload(for: .carb) ++ } catch { ++ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteCarbCommandError: LocalizedError { ++ case internalError ++ ++ var errorDescription: String? { ++ switch self { ++ case .internalError: ++ return NSLocalizedString("Internal error", comment: "Remote carb command error description: internal error.") ++ } ++ } ++ } ++ + func deliverRemoteBolus(amountInUnits: Double) async throws { + do { + +diff --git a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +index a7ffef2e..692a4ec2 100644 +--- a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift ++++ b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +@@ -34,6 +34,8 @@ protocol CarbStoreProtocol: AnyObject { + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) ++ ++ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] + + func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbStatus]>) -> Void) + +diff --git a/Loop/LoopTests/Mock Stores/MockCarbStore.swift b/Loop/LoopTests/Mock Stores/MockCarbStore.swift +index 4a5c016e..dd35a787 100644 +--- a/Loop/LoopTests/Mock Stores/MockCarbStore.swift ++++ b/Loop/LoopTests/Mock Stores/MockCarbStore.swift +@@ -12,6 +12,7 @@ import LoopKit + + class MockCarbStore: CarbStoreProtocol { + var carbHistory: [StoredCarbEntry]? ++ var carbEntries: [StoredCarbEntry] = [] + + init(for scenario: DosingTestScenario = .flatAndStable) { + self.scenario = scenario // The store returns different effect values based on the scenario +@@ -72,6 +73,10 @@ class MockCarbStore: CarbStoreProtocol { + completion(.failure(.notConfigured)) + } + ++ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] { ++ return carbEntries ++ } ++ + func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbStatus]>) -> Void) { + completion(.failure(.notConfigured)) + } +Submodule LoopKit 325bd820..850ea31a: +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 7fdc53ad..4b2cecf3 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -13,4 +13,6 @@ public protocol RemoteActionDelegate: AnyObject { + func cancelRemoteOverride() async throws + func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws + func deliverRemoteBolus(amountInUnits: Double) async throws ++ func deleteRemoteCarbs(syncIdentifier: String) async throws ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws + } +Submodule NightscoutService fe075ef0..32e513c8: +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 69f7a26..9f75373 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -28,12 +28,17 @@ + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */; }; ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */; }; ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */; }; + A941B09129BD4A9E00F91340 /* BolusRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */; }; + A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */; }; + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */; }; + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */; }; + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */; }; + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */; }; ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */; }; ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */; }; ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */; }; + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */; }; + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */; }; + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */; }; +@@ -47,6 +52,8 @@ + A9AA6E5B29EB072E008FFA78 /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */; }; + A9AA6E6329EB07D7008FFA78 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5D29EB07D6008FFA78 /* Action.swift */; }; + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */; }; ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04073CA740189C4259808331 /* CarbEditAction.swift */; }; ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */; }; + A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */; }; + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */; }; + A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; +@@ -162,12 +169,17 @@ + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotification.swift; sourceTree = ""; }; ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotification.swift; sourceTree = ""; }; ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotification.swift; sourceTree = ""; }; + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotification.swift; sourceTree = ""; }; + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotification.swift; sourceTree = ""; }; + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotification.swift; sourceTree = ""; }; + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotification.swift; sourceTree = ""; }; + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotificationCapabilitiesTestCase.swift; sourceTree = ""; }; + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotificationTestCase.swift; sourceTree = ""; }; + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredGlucoseSample.swift; sourceTree = ""; }; +@@ -183,6 +195,8 @@ + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; + A9AA6E5D29EB07D6008FFA78 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; ++ 04073CA740189C4259808331 /* CarbEditAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditAction.swift; sourceTree = ""; }; ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteAction.swift; sourceTree = ""; }; + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelAction.swift; sourceTree = ""; }; + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; + A9B007B222BD6FF7000131DE /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; +@@ -321,6 +335,9 @@ + A91BAC2B22BC691A00ABF1BB /* Info.plist */, + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */, + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */, ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */, ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */, ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */, + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, +@@ -361,6 +378,8 @@ + children = ( + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */, + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */, ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */, ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */, + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, +@@ -415,6 +434,8 @@ + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, ++ 04073CA740189C4259808331 /* CarbEditAction.swift */, ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */, + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */, + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */, + ); +@@ -716,12 +737,16 @@ + A993990322BC7095005E39D3 /* NightscoutService.swift in Sources */, + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */, + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */, ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */, ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */, + A90E39A922BC7AD10016DFE8 /* Bundle.swift in Sources */, + A90E39A322BC782C0016DFE8 /* SyncCarbObject.swift in Sources */, + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */, + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */, + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */, + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */, ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */, ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */, + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */, + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */, + C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */, +@@ -754,6 +779,9 @@ + buildActionMask = 2147483647; + files = ( + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */, ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */, ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */, ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */, + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 4e1d49e..637988b 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -159,7 +159,8 @@ extension StoredDosingDecision { + automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, +- failureReason: automaticDoseDecision?.loopStatusFailureReason), ++ failureReason: automaticDoseDecision?.loopStatusFailureReason, ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 4626630..169fb86 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -24,7 +24,7 @@ public final class NightscoutService: Service { + + public static let localizedTitle = LocalizedString("Nightscout", comment: "The title of the Nightscout service") + +- public let objectIdCacheKeepTime = TimeInterval(24 * 60 * 60) ++ public let objectIdCacheKeepTime = TimeInterval(48 * 60 * 60) + + public weak var serviceDelegate: ServiceDelegate? + +@@ -423,6 +423,7 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch + var success = false + var message = "" ++ var syncIdentifier: String? = nil + + do { + switch action { +@@ -458,6 +459,26 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + ) + success = true + message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) ++ ++ case .carbsDelete(let deleteCommand): ++ commandType = .carbsDelete ++ syncIdentifier = deleteCommand.syncIdentifier ++ try await self.serviceDelegate?.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) ++ success = true ++ message = "Carb entry deleted successfully" ++ ++ case .carbsEdit(let editCommand): ++ commandType = .carbsEdit ++ syncIdentifier = editCommand.syncIdentifier ++ try await self.serviceDelegate?.replaceRemoteCarbs( ++ syncIdentifier: editCommand.syncIdentifier, ++ amountInGrams: editCommand.amountInGrams, ++ absorptionTime: editCommand.absorptionTime, ++ foodType: editCommand.foodType, ++ startDate: editCommand.startDate ++ ) ++ success = true ++ message = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) + } + } catch { + message = "Command failed: \(error.localizedDescription)" +@@ -467,7 +488,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + to: returnInfo, + commandType: commandType, + success: false, +- message: message ++ message: message, ++ syncIdentifier: syncIdentifier + ) + } + throw error +@@ -479,7 +501,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + to: returnInfo, + commandType: commandType, + success: success, +- message: message ++ message: message, ++ syncIdentifier: syncIdentifier + ) + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..91ad56c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -13,6 +13,8 @@ public enum Action: Codable { + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) + case carbsEntry(CarbAction) ++ case carbsDelete(CarbDeleteAction) ++ case carbsEdit(CarbEditAction) + + public var description: String { + return "\(actionName) \(actionParameterDescription)" +@@ -22,6 +24,10 @@ public enum Action: Codable { + switch self { + case .carbsEntry: + return LocalizedString("Carb Entry", comment: "The remote action name for Carb Entry") ++ case .carbsDelete: ++ return LocalizedString("Delete Carbs", comment: "The remote action name for deleting a carb entry") ++ case .carbsEdit: ++ return LocalizedString("Edit Carbs", comment: "The remote action name for editing a carb entry") + case .bolusEntry: + return LocalizedString("Bolus Entry", comment: "The remote action name for Bolus Entry") + case .cancelTemporaryOverride: +@@ -36,6 +42,11 @@ public enum Action: Codable { + case .carbsEntry(let carbAction): + let amountFormatted = Self.numberFormatter.string(from: carbAction.amountInGrams as NSNumber) ?? "" + return "\(amountFormatted)" + " " + carbGramAbbreviation ++ case .carbsDelete(let deleteAction): ++ return String(deleteAction.syncIdentifier.prefix(8)) ++ case .carbsEdit(let editAction): ++ let amountFormatted = Self.numberFormatter.string(from: editAction.amountInGrams as NSNumber) ?? "" ++ return "\(amountFormatted)" + " " + carbGramAbbreviation + case .bolusEntry(let bolusAction): + let amountFormatted = Self.numberFormatter.string(from: bolusAction.amountInUnits as NSNumber) ?? "" + return "\(amountFormatted)" + " " + bolusUnitAbbreviation +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +new file mode 100644 +index 0000000..77e9900 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +@@ -0,0 +1,15 @@ ++// ++// CarbDeleteAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbDeleteAction: Codable { ++ ++ public let syncIdentifier: String ++ ++ public init(syncIdentifier: String) { ++ self.syncIdentifier = syncIdentifier ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +new file mode 100644 +index 0000000..0aea20b +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +@@ -0,0 +1,23 @@ ++// ++// CarbEditAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbEditAction: Codable { ++ ++ public let syncIdentifier: String ++ public let amountInGrams: Double ++ public let absorptionTime: TimeInterval ++ public let foodType: String? ++ public let startDate: Date ++ ++ public init(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String? = nil, startDate: Date) { ++ self.syncIdentifier = syncIdentifier ++ self.amountInGrams = amountInGrams ++ self.absorptionTime = absorptionTime ++ self.foodType = foodType ++ self.startDate = startDate ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..6092bc9 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -21,12 +21,14 @@ class RemoteNotificationResponseManager { + let commandStatus: String + let commandType: String + let timestamp: TimeInterval ++ let syncIdentifier: String? + + enum CodingKeys: String, CodingKey { + case aps + case commandStatus = "command_status" + case commandType = "command_type" + case timestamp ++ case syncIdentifier = "sync_identifier" + } + } + +@@ -43,6 +45,8 @@ class RemoteNotificationResponseManager { + enum CommandType: String { + case bolus = "bolus" + case carbs = "carbs" ++ case carbsDelete = "carbs_delete" ++ case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" + } +@@ -51,7 +55,8 @@ class RemoteNotificationResponseManager { + to returnInfo: ReturnNotificationInfo?, + commandType: CommandType, + success: Bool, +- message: String ++ message: String, ++ syncIdentifier: String? = nil + ) async { + guard let returnInfo = returnInfo else { + os_log("No return notification info provided, skipping response", log: log, type: .info) +@@ -74,7 +79,8 @@ class RemoteNotificationResponseManager { + ), + commandStatus: success ? "success" : "failed", + commandType: commandType.rawValue, +- timestamp: Date().timeIntervalSince1970 ++ timestamp: Date().timeIntervalSince1970, ++ syncIdentifier: syncIdentifier + ) + + await sendPushNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +new file mode 100644 +index 0000000..1593a74 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +@@ -0,0 +1,44 @@ ++// ++// CarbDeleteRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-delete" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .carbsDelete(CarbDeleteAction(syncIdentifier: syncIdentifier)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-delete"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +new file mode 100644 +index 0000000..fde3e4c +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +@@ -0,0 +1,58 @@ ++// ++// CarbEditRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Replacement values use prefixed keys so an unpatched Loop never reads them as a new carb entry. ++public struct CarbEditRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let amount: Double ++ public let absorptionInHours: Double ++ public let foodType: String? ++ public let startDate: Date ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-edit" ++ case amount = "carbs-edit-entry" ++ case absorptionInHours = "carbs-edit-absorption-time" ++ case foodType = "carbs-edit-food-type" ++ case startDate = "carbs-edit-start-time" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ public func absorptionTime() -> TimeInterval { ++ return TimeInterval(hours: absorptionInHours) ++ } ++ ++ func toRemoteAction() -> Action { ++ let action = CarbEditAction(syncIdentifier: syncIdentifier, amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) ++ return .carbsEdit(action) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-edit"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 0c5f8e3..6f0524d 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -22,12 +22,22 @@ protocol RemoteNotification: Codable { + + func toRemoteAction() -> Action + func otpValidationRequired() -> Bool ++ func expirationRequired() -> Bool + func getReturnNotificationInfo() -> ReturnNotificationInfo? + + static func includedInNotification(_ notification: [String: Any]) -> Bool + } + ++/// Followers read this to know which remote commands this build accepts. ++enum RemoteNotificationCapabilities { ++ static let supported: [String] = ["carbs-delete", "carbs-edit"] ++} ++ + extension RemoteNotification { ++ ++ func expirationRequired() -> Bool { ++ return false ++ } + + var id: String { + //There is no unique identifier so we use the sent date when available +@@ -102,6 +112,10 @@ extension Dictionary { + func toRemoteNotification() throws -> RemoteNotification { + if BolusRemoteNotification.includedInNotification(self) { + return try BolusRemoteNotification(dictionary: self) ++ } else if CarbDeleteRemoteNotification.includedInNotification(self) { ++ return try CarbDeleteRemoteNotification(dictionary: self) ++ } else if CarbEditRemoteNotification.includedInNotification(self) { ++ return try CarbEditRemoteNotification(dictionary: self) + } else if CarbRemoteNotification.includedInNotification(self) { + return try CarbRemoteNotification(dictionary: self) + } else if OverrideRemoteNotification.includedInNotification(self) { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +index 634eb4e..21d0e0a 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +@@ -16,6 +16,14 @@ struct RemoteCommandValidator { + } + + func validate(remoteNotification: RemoteNotification) throws { ++ if remoteNotification.expirationRequired() { ++ guard remoteNotification.expiration != nil else { ++ throw NotificationValidationError.missingExpiration ++ } ++ guard remoteNotification.sentAt != nil else { ++ throw NotificationValidationError.missingSentAt ++ } ++ } + try validateExpirationDate(remoteNotification: remoteNotification) + if remoteNotification.otpValidationRequired() { + try validateOTP(remoteNotification: remoteNotification) +@@ -45,11 +53,17 @@ struct RemoteCommandValidator { + enum NotificationValidationError: LocalizedError { + case missingOTP + case expiredNotification ++ case missingExpiration ++ case missingSentAt + + var errorDescription: String? { + switch self { + case .missingOTP: + return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") ++ case .missingExpiration: ++ return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") ++ case .missingSentAt: ++ return LocalizedString("Missing sent-at time", comment: "Remote command error description: missing sent-at.") + case .expiredNotification: + return LocalizedString("Expired", comment: "Remote command error description: expired.") + } +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..6a3aba6 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +@@ -0,0 +1,67 @@ ++// ++// CarbDeleteRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbDeleteRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbDeleteNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": syncIdentifier as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let deleteNotification = try XCTUnwrap(remoteNotification as? CarbDeleteRemoteNotification) ++ XCTAssertEqual(deleteNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(deleteNotification.otp, "12345") ++ XCTAssertTrue(deleteNotification.otpValidationRequired()) ++ XCTAssertTrue(deleteNotification.expirationRequired()) ++ guard case .carbsDelete(let action) = deleteNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsDelete action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ } ++ ++ func testValidate_MissingExpiration_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ let remoteNotification = try notification.toRemoteNotification() ++ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) ++ ++ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in ++ guard case RemoteCommandValidator.NotificationValidationError.missingExpiration = error else { ++ return XCTFail("Unexpected error \(error)") ++ } ++ } ++ } ++ ++ func testValidate_MissingSentAt_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ let remoteNotification = try notification.toRemoteNotification() ++ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) ++ ++ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in ++ guard case RemoteCommandValidator.NotificationValidationError.missingSentAt = error else { ++ return XCTFail("Unexpected error \(error)") ++ } ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..989f7c8 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +@@ -0,0 +1,108 @@ ++// ++// CarbEditRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbEditRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbEditNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let startDateString = "2023-02-25T20:46:35.778Z" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": syncIdentifier as AnyObject, ++ "carbs-edit-entry": 22.0 as AnyObject, ++ "carbs-edit-absorption-time": 3.0 as AnyObject, ++ "carbs-edit-food-type": "🍕" as AnyObject, ++ "carbs-edit-start-time": startDateString as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let editNotification = try XCTUnwrap(remoteNotification as? CarbEditRemoteNotification) ++ XCTAssertEqual(editNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(editNotification.amount, 22.0) ++ XCTAssertEqual(editNotification.absorptionTime(), TimeInterval(hours: 3.0)) ++ XCTAssertEqual(editNotification.foodType, "🍕") ++ XCTAssertEqual(editNotification.startDate, dateFormatter().date(from: startDateString)) ++ XCTAssertTrue(editNotification.otpValidationRequired()) ++ XCTAssertTrue(editNotification.expirationRequired()) ++ guard case .carbsEdit(let action) = editNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsEdit action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(action.amountInGrams, 22.0) ++ XCTAssertEqual(action.absorptionTime, TimeInterval(hours: 3.0)) ++ XCTAssertEqual(action.startDate, dateFormatter().date(from: startDateString)) ++ } ++ ++ func testParseCarbEditNotification_FoodTypeAbsent_Succeeds() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let editNotification = try XCTUnwrap(try notification.toRemoteNotification() as? CarbEditRemoteNotification) ++ XCTAssertNil(editNotification.foodType) ++ } ++ ++ func testParseCarbEditNotification_MissingStartTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_MissingAbsorptionTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_DoesNotDecodeAsCarbEntry() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertFalse(CarbRemoteNotification.includedInNotification(notification)) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(notification)) ++ } ++ ++ func dateFormatter() -> ISO8601DateFormatter { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +new file mode 100644 +index 0000000..e284d3f +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +@@ -0,0 +1,21 @@ ++// ++// RemoteNotificationCapabilitiesTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class RemoteNotificationCapabilitiesTestCase: XCTestCase { ++ ++ func testSupportedCommands_MatchNotificationKeys() { ++ XCTAssertEqual(RemoteNotificationCapabilities.supported, ["carbs-delete", "carbs-edit"]) ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(["carbs-delete": "id"])) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(["carbs-edit": "id"])) ++ for key in RemoteNotificationCapabilities.supported { ++ let notification: [String: Any] = [key: "id"] ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(notification) ++ || CarbEditRemoteNotification.includedInNotification(notification), key) ++ } ++ } ++} diff --git a/remote_carb_edit/dev_remote_carb_edit_remote_window.patch b/remote_carb_edit/dev_remote_carb_edit_remote_window.patch new file mode 100644 index 0000000..0d45ea2 --- /dev/null +++ b/remote_carb_edit/dev_remote_carb_edit_remote_window.patch @@ -0,0 +1,1071 @@ +Submodule Loop 5ef1fba3..7f5a369c: +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index ff60c424..974122ce 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -2552,19 +2552,9 @@ extension LoopDataManager: ServicesManagerDelegate { + //Carb Entry + + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { +- + 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 +- } ++ let startDate = startDate ?? Date() ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) + + let maxAllowedConflictingAmountInGrams = 0.0 // Not allowing any carbs + let minTreatmentIntervalInMinutes = 10.0 +@@ -2579,19 +2569,80 @@ extension LoopDataManager: ServicesManagerDelegate { + throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) + } + +- 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 candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) + + return try await deliverCarbEntry(candidateCarbEntry) + } ++ ++ /// Remote edits and deletes may only target entries this app created within the last 24 hours. ++ static let remoteCarbEditWindow: TimeInterval = .hours(24) ++ ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let entry = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ deleteCarbEntry(entry) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ return entry ++ } ++ ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { ++ let existing = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) ++ ++ let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) ++ let replacement = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType ?? existing.foodType, absorptionTime: absorptionTime) ++ ++ return try await withCheckedThrowingContinuation { continuation in ++ addCarbEntry(replacement, replacing: existing) { result in ++ switch result { ++ case .success(let storedCarbEntry): ++ continuation.resume(returning: storedCarbEntry) ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ } ++ ++ private func validateRemoteCarbParameters(amountInGrams: Double, absorptionTime: TimeInterval, startDate: Date) throws { ++ 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 ++ } ++ ++ let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) ++ let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) ++ guard startDate <= maxStartDate && startDate >= minStartDate else { ++ throw CarbActionError.invalidStartDate(startDate) ++ } ++ } ++ ++ private func remoteEditableCarbEntry(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let now = Date() ++ let entries = try await carbStore.getCarbEntries(start: now.addingTimeInterval(-Self.remoteCarbEditWindow), end: now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime)) ++ guard let entry = entries.first(where: { $0.syncIdentifier == syncIdentifier }) else { ++ throw CarbActionError.entryNotFound ++ } ++ guard entry.createdByCurrentApp else { ++ throw CarbActionError.entryNotEditable ++ } ++ return entry ++ } + + enum CarbActionError: LocalizedError { + +@@ -2600,6 +2651,8 @@ extension LoopDataManager: ServicesManagerDelegate { + case exceedsMaxCarbs + case invalidCarbs + case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) ++ case entryNotFound ++ case entryNotEditable + + var errorDescription: String? { + switch self { +@@ -2607,6 +2660,10 @@ extension LoopDataManager: ServicesManagerDelegate { + 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 .entryNotFound: ++ return NSLocalizedString("Carb entry not found or older than 24 hours", comment: "Carb error description: remote edit target not found.") ++ case .entryNotEditable: ++ return NSLocalizedString("Carb entry was not created by Loop", comment: "Carb error description: remote edit target not created by this app.") + 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) +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 996d1470..3eb3154b 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -201,6 +201,36 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ @MainActor ++ static func sendRemoteCarbChangeNotification(body: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbs.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ @MainActor ++ static func sendRemoteCarbChangeFailureNotification(body: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbsFailure.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ + static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { + let notification = UNMutableNotificationContent() + /// Notifications should expire after the missed meal is no longer relevant +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 9d1d3502..1a3d714b 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -239,6 +239,8 @@ public protocol ServicesManagerDelegate: AnyObject { + func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride + func cancelCurrentOverride() async throws -> TemporaryScheduleOverride + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } + + // MARK: - StatefulPluggableDelegate +@@ -357,6 +359,39 @@ extension ServicesManager: ServiceDelegate { + } + } + ++ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { ++ do { ++ guard let servicesManagerDelegate else { ++ throw CarbActionError.internalError ++ } ++ let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) ++ let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram()))) ++ await NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.triggerUpload(for: .carb) ++ return deleted ++ } catch { ++ let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") ++ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { ++ let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) ++ do { ++ guard let servicesManagerDelegate else { ++ throw CarbActionError.internalError ++ } ++ let replacement = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ await NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.triggerUpload(for: .carb) ++ return replacement ++ } catch { ++ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ + func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { + do { + guard let servicesManagerDosingDelegate else { +diff --git a/Loop/LoopTests/Mock Stores/MockCarbStore.swift b/Loop/LoopTests/Mock Stores/MockCarbStore.swift +index 4a5c016e..dd35a787 100644 +--- a/Loop/LoopTests/Mock Stores/MockCarbStore.swift ++++ b/Loop/LoopTests/Mock Stores/MockCarbStore.swift +@@ -12,6 +12,7 @@ import LoopKit + + class MockCarbStore: CarbStoreProtocol { + var carbHistory: [StoredCarbEntry]? ++ var carbEntries: [StoredCarbEntry] = [] + + init(for scenario: DosingTestScenario = .flatAndStable) { + self.scenario = scenario // The store returns different effect values based on the scenario +@@ -72,6 +73,10 @@ class MockCarbStore: CarbStoreProtocol { + completion(.failure(.notConfigured)) + } + ++ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] { ++ return carbEntries ++ } ++ + func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbStatus]>) -> Void) { + completion(.failure(.notConfigured)) + } +Submodule LoopKit fd22df46..e7ce9e26: +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index e4279746..8fb254b6 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -13,4 +13,6 @@ public protocol RemoteActionDelegate: AnyObject { + func cancelRemoteOverride() async throws -> TemporaryScheduleOverride + func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry + func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry ++ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } +Submodule NightscoutService be4ce92d..a7b9002f: +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 69f7a26..9f75373 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -28,12 +28,17 @@ + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */; }; ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */; }; ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */; }; + A941B09129BD4A9E00F91340 /* BolusRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */; }; + A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */; }; + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */; }; + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */; }; + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */; }; + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */; }; ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */; }; ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */; }; ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */; }; + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */; }; + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */; }; + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */; }; +@@ -47,6 +52,8 @@ + A9AA6E5B29EB072E008FFA78 /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */; }; + A9AA6E6329EB07D7008FFA78 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5D29EB07D6008FFA78 /* Action.swift */; }; + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */; }; ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04073CA740189C4259808331 /* CarbEditAction.swift */; }; ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */; }; + A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */; }; + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */; }; + A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; +@@ -162,12 +169,17 @@ + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotification.swift; sourceTree = ""; }; ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotification.swift; sourceTree = ""; }; ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotification.swift; sourceTree = ""; }; + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotification.swift; sourceTree = ""; }; + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotification.swift; sourceTree = ""; }; + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotification.swift; sourceTree = ""; }; + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotification.swift; sourceTree = ""; }; + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotificationCapabilitiesTestCase.swift; sourceTree = ""; }; + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotificationTestCase.swift; sourceTree = ""; }; + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredGlucoseSample.swift; sourceTree = ""; }; +@@ -183,6 +195,8 @@ + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; + A9AA6E5D29EB07D6008FFA78 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; ++ 04073CA740189C4259808331 /* CarbEditAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditAction.swift; sourceTree = ""; }; ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteAction.swift; sourceTree = ""; }; + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelAction.swift; sourceTree = ""; }; + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; + A9B007B222BD6FF7000131DE /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; +@@ -321,6 +335,9 @@ + A91BAC2B22BC691A00ABF1BB /* Info.plist */, + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */, + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */, ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */, ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */, ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */, + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, +@@ -361,6 +378,8 @@ + children = ( + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */, + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */, ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */, ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */, + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, +@@ -415,6 +434,8 @@ + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, ++ 04073CA740189C4259808331 /* CarbEditAction.swift */, ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */, + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */, + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */, + ); +@@ -716,12 +737,16 @@ + A993990322BC7095005E39D3 /* NightscoutService.swift in Sources */, + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */, + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */, ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */, ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */, + A90E39A922BC7AD10016DFE8 /* Bundle.swift in Sources */, + A90E39A322BC782C0016DFE8 /* SyncCarbObject.swift in Sources */, + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */, + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */, + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */, + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */, ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */, ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */, + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */, + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */, + C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */, +@@ -754,6 +779,9 @@ + buildActionMask = 2147483647; + files = ( + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */, ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */, ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */, ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */, + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 4e1d49e..637988b 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -159,7 +159,8 @@ extension StoredDosingDecision { + automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, +- failureReason: automaticDoseDecision?.loopStatusFailureReason), ++ failureReason: automaticDoseDecision?.loopStatusFailureReason, ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 7adb37d..a968a64 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -24,7 +24,7 @@ public final class NightscoutService: Service { + + public static let localizedTitle = LocalizedString("Nightscout", comment: "The title of the Nightscout service") + +- public let objectIdCacheKeepTime = TimeInterval(24 * 60 * 60) ++ public let objectIdCacheKeepTime = TimeInterval(48 * 60 * 60) + + public weak var serviceDelegate: ServiceDelegate? + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..91ad56c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -13,6 +13,8 @@ public enum Action: Codable { + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) + case carbsEntry(CarbAction) ++ case carbsDelete(CarbDeleteAction) ++ case carbsEdit(CarbEditAction) + + public var description: String { + return "\(actionName) \(actionParameterDescription)" +@@ -22,6 +24,10 @@ public enum Action: Codable { + switch self { + case .carbsEntry: + return LocalizedString("Carb Entry", comment: "The remote action name for Carb Entry") ++ case .carbsDelete: ++ return LocalizedString("Delete Carbs", comment: "The remote action name for deleting a carb entry") ++ case .carbsEdit: ++ return LocalizedString("Edit Carbs", comment: "The remote action name for editing a carb entry") + case .bolusEntry: + return LocalizedString("Bolus Entry", comment: "The remote action name for Bolus Entry") + case .cancelTemporaryOverride: +@@ -36,6 +42,11 @@ public enum Action: Codable { + case .carbsEntry(let carbAction): + let amountFormatted = Self.numberFormatter.string(from: carbAction.amountInGrams as NSNumber) ?? "" + return "\(amountFormatted)" + " " + carbGramAbbreviation ++ case .carbsDelete(let deleteAction): ++ return String(deleteAction.syncIdentifier.prefix(8)) ++ case .carbsEdit(let editAction): ++ let amountFormatted = Self.numberFormatter.string(from: editAction.amountInGrams as NSNumber) ?? "" ++ return "\(amountFormatted)" + " " + carbGramAbbreviation + case .bolusEntry(let bolusAction): + let amountFormatted = Self.numberFormatter.string(from: bolusAction.amountInUnits as NSNumber) ?? "" + return "\(amountFormatted)" + " " + bolusUnitAbbreviation +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +new file mode 100644 +index 0000000..77e9900 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +@@ -0,0 +1,15 @@ ++// ++// CarbDeleteAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbDeleteAction: Codable { ++ ++ public let syncIdentifier: String ++ ++ public init(syncIdentifier: String) { ++ self.syncIdentifier = syncIdentifier ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +new file mode 100644 +index 0000000..0aea20b +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +@@ -0,0 +1,23 @@ ++// ++// CarbEditAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbEditAction: Codable { ++ ++ public let syncIdentifier: String ++ public let amountInGrams: Double ++ public let absorptionTime: TimeInterval ++ public let foodType: String? ++ public let startDate: Date ++ ++ public init(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String? = nil, startDate: Date) { ++ self.syncIdentifier = syncIdentifier ++ self.amountInGrams = amountInGrams ++ self.absorptionTime = absorptionTime ++ self.foodType = foodType ++ self.startDate = startDate ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..6092bc9 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -21,12 +21,14 @@ class RemoteNotificationResponseManager { + let commandStatus: String + let commandType: String + let timestamp: TimeInterval ++ let syncIdentifier: String? + + enum CodingKeys: String, CodingKey { + case aps + case commandStatus = "command_status" + case commandType = "command_type" + case timestamp ++ case syncIdentifier = "sync_identifier" + } + } + +@@ -43,6 +45,8 @@ class RemoteNotificationResponseManager { + enum CommandType: String { + case bolus = "bolus" + case carbs = "carbs" ++ case carbsDelete = "carbs_delete" ++ case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" + } +@@ -51,7 +55,8 @@ class RemoteNotificationResponseManager { + to returnInfo: ReturnNotificationInfo?, + commandType: CommandType, + success: Bool, +- message: String ++ message: String, ++ syncIdentifier: String? = nil + ) async { + guard let returnInfo = returnInfo else { + os_log("No return notification info provided, skipping response", log: log, type: .info) +@@ -74,7 +79,8 @@ class RemoteNotificationResponseManager { + ), + commandStatus: success ? "success" : "failed", + commandType: commandType.rawValue, +- timestamp: Date().timeIntervalSince1970 ++ timestamp: Date().timeIntervalSince1970, ++ syncIdentifier: syncIdentifier + ) + + await sendPushNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +new file mode 100644 +index 0000000..eda9134 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +@@ -0,0 +1,44 @@ ++// ++// CarbDeleteRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-delete" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .carbsDelete(CarbDeleteAction(syncIdentifier: syncIdentifier)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-delete"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +new file mode 100644 +index 0000000..f1ffb51 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +@@ -0,0 +1,58 @@ ++// ++// CarbEditRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Replacement values use prefixed keys so an unpatched Loop never reads them as a new carb entry. ++public struct CarbEditRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let amount: Double ++ public let absorptionInHours: Double ++ public let foodType: String? ++ public let startDate: Date ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-edit" ++ case amount = "carbs-edit-entry" ++ case absorptionInHours = "carbs-edit-absorption-time" ++ case foodType = "carbs-edit-food-type" ++ case startDate = "carbs-edit-start-time" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ public func absorptionTime() -> TimeInterval { ++ return TimeInterval(hours: absorptionInHours) ++ } ++ ++ func toRemoteAction() -> Action { ++ let action = CarbEditAction(syncIdentifier: syncIdentifier, amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) ++ return .carbsEdit(action) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-edit"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 24e63d7..be43210 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -22,12 +22,22 @@ protocol RemoteNotification: Codable { + + func toRemoteAction() -> Action + func otpValidationRequired() -> Bool ++ func expirationRequired() -> Bool + func getReturnNotificationInfo() -> ReturnNotificationInfo? + + static func includedInNotification(_ notification: [String: Any]) -> Bool + } + ++/// Followers read this to know which remote commands this build accepts. ++enum RemoteNotificationCapabilities { ++ static let supported: [String] = ["carbs-delete", "carbs-edit"] ++} ++ + extension RemoteNotification { ++ ++ func expirationRequired() -> Bool { ++ return false ++ } + + var id: String { + //There is no unique identifier so we use the sent date +@@ -98,6 +108,10 @@ extension Dictionary { + func toRemoteNotification() throws -> RemoteNotification { + if BolusRemoteNotification.includedInNotification(self) { + return try BolusRemoteNotification(dictionary: self) ++ } else if CarbDeleteRemoteNotification.includedInNotification(self) { ++ return try CarbDeleteRemoteNotification(dictionary: self) ++ } else if CarbEditRemoteNotification.includedInNotification(self) { ++ return try CarbEditRemoteNotification(dictionary: self) + } else if CarbRemoteNotification.includedInNotification(self) { + return try CarbRemoteNotification(dictionary: self) + } else if OverrideRemoteNotification.includedInNotification(self) { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +index 84ff118..331ed5c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +@@ -46,6 +46,7 @@ class RemoteCommandSourceV1: RemoteCommandSource { + + var commandType: RemoteNotificationResponseManager.CommandType = .bolus + var successMessage = "" ++ var syncIdentifier: String? = nil + + do { + try await recentNotifications.trackReceivedRemoteNotification(remoteNotification, rawNotification: notification) +@@ -96,6 +97,32 @@ class RemoteCommandSourceV1: RemoteCommandSource { + status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), + notificationJSON: notification + ) ++ case .carbsDelete(let deleteCommand): ++ commandType = .carbsDelete ++ syncIdentifier = deleteCommand.syncIdentifier ++ let deletedEntry = try await serviceDelegate.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) ++ successMessage = "Carb entry deleted successfully" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: deletedEntry.syncIdentifier ?? "", completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .carbsEdit(let editCommand): ++ commandType = .carbsEdit ++ syncIdentifier = editCommand.syncIdentifier ++ let replacementEntry = try await serviceDelegate.replaceRemoteCarbs( ++ syncIdentifier: editCommand.syncIdentifier, ++ amountInGrams: editCommand.amountInGrams, ++ absorptionTime: editCommand.absorptionTime, ++ foodType: editCommand.foodType, ++ startDate: editCommand.startDate ++ ) ++ successMessage = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), ++ notificationJSON: notification ++ ) + case .temporaryScheduleOverride(let overrideCommand): + commandType = .override + let override = try await serviceDelegate.enactRemoteOverride( +@@ -117,7 +144,8 @@ class RemoteCommandSourceV1: RemoteCommandSource { + to: returnInfo, + commandType: commandType, + success: true, +- message: successMessage ++ message: successMessage, ++ syncIdentifier: syncIdentifier + ) + } + } catch { +@@ -134,7 +162,8 @@ class RemoteCommandSourceV1: RemoteCommandSource { + to: returnInfo, + commandType: commandType, + success: false, +- message: "Command failed: \(error.localizedDescription)" ++ message: "Command failed: \(error.localizedDescription)", ++ syncIdentifier: syncIdentifier + ) + } + } +@@ -223,6 +252,14 @@ public class StoredRemoteNotification: NSObject, Codable { + self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) + } + ++ convenience init(carbDeleteNotification: CarbDeleteRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .carbsDelete(carbDeleteNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(carbEditNotification: CarbEditRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) ++ } ++ + convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { + self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) + } +@@ -306,6 +343,10 @@ public class StoredRemoteNotification: NSObject, Codable { + return bolusNotification + case let .carbs(carbNotification): + return carbNotification ++ case let .carbsDelete(carbDeleteNotification): ++ return carbDeleteNotification ++ case let .carbsEdit(carbEditNotification): ++ return carbEditNotification + case let .override(overrideNotification): + return overrideNotification + case let .overrideCancel(overrideCancelNotification): +@@ -332,6 +373,8 @@ public class StoredRemoteNotification: NSObject, Codable { + public enum RemoteNotificationType: Codable { + case bolus(BolusRemoteNotification) + case carbs(CarbRemoteNotification) ++ case carbsDelete(CarbDeleteRemoteNotification) ++ case carbsEdit(CarbEditRemoteNotification) + case override(OverrideRemoteNotification) + case overrideCancel(OverrideCancelRemoteNotification) + } +@@ -384,6 +427,12 @@ actor RecentNotifications { + } else if let carbNotification = remoteNotification as? CarbRemoteNotification { + let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) + try storeNotification(storedNotification) ++ } else if let carbDeleteNotification = remoteNotification as? CarbDeleteRemoteNotification { ++ let storedNotification = StoredRemoteNotification(carbDeleteNotification: carbDeleteNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { ++ let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) + } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { + let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) + try storeNotification(storedNotification) +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +index fce4623..5be6e1c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +@@ -16,6 +16,11 @@ struct RemoteCommandValidator { + } + + func validate(remoteNotification: RemoteNotification) throws { ++ if remoteNotification.expirationRequired() { ++ guard remoteNotification.expiration != nil else { ++ throw NotificationValidationError.missingExpiration ++ } ++ } + try validateExpirationDate(remoteNotification: remoteNotification) + if remoteNotification.otpValidationRequired() { + try validateOTP(remoteNotification: remoteNotification) +@@ -45,11 +50,14 @@ struct RemoteCommandValidator { + enum NotificationValidationError: LocalizedError { + case missingOTP + case expiredNotification(sentDate: Date, receivedDate: Date) ++ case missingExpiration + + var errorDescription: String? { + switch self { + case .missingOTP: + return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") ++ case .missingExpiration: ++ return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") + case .expiredNotification(let sentDate, let receivedDate): + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "h:mm:ss a" +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..f03768c +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +@@ -0,0 +1,61 @@ ++// ++// CarbDeleteRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbDeleteRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbDeleteNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": syncIdentifier as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let deleteNotification = try XCTUnwrap(remoteNotification as? CarbDeleteRemoteNotification) ++ XCTAssertEqual(deleteNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(deleteNotification.otp, "12345") ++ XCTAssertTrue(deleteNotification.otpValidationRequired()) ++ XCTAssertTrue(deleteNotification.expirationRequired()) ++ guard case .carbsDelete(let action) = deleteNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsDelete action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ } ++ ++ func testValidate_MissingExpiration_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ let remoteNotification = try notification.toRemoteNotification() ++ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) ++ ++ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in ++ guard case RemoteCommandValidator.NotificationValidationError.missingExpiration = error else { ++ return XCTFail("Unexpected error \(error)") ++ } ++ } ++ } ++ ++ func testParseCarbDeleteNotification_MissingSentAt_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..989f7c8 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +@@ -0,0 +1,108 @@ ++// ++// CarbEditRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbEditRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbEditNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let startDateString = "2023-02-25T20:46:35.778Z" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": syncIdentifier as AnyObject, ++ "carbs-edit-entry": 22.0 as AnyObject, ++ "carbs-edit-absorption-time": 3.0 as AnyObject, ++ "carbs-edit-food-type": "🍕" as AnyObject, ++ "carbs-edit-start-time": startDateString as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let editNotification = try XCTUnwrap(remoteNotification as? CarbEditRemoteNotification) ++ XCTAssertEqual(editNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(editNotification.amount, 22.0) ++ XCTAssertEqual(editNotification.absorptionTime(), TimeInterval(hours: 3.0)) ++ XCTAssertEqual(editNotification.foodType, "🍕") ++ XCTAssertEqual(editNotification.startDate, dateFormatter().date(from: startDateString)) ++ XCTAssertTrue(editNotification.otpValidationRequired()) ++ XCTAssertTrue(editNotification.expirationRequired()) ++ guard case .carbsEdit(let action) = editNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsEdit action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(action.amountInGrams, 22.0) ++ XCTAssertEqual(action.absorptionTime, TimeInterval(hours: 3.0)) ++ XCTAssertEqual(action.startDate, dateFormatter().date(from: startDateString)) ++ } ++ ++ func testParseCarbEditNotification_FoodTypeAbsent_Succeeds() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let editNotification = try XCTUnwrap(try notification.toRemoteNotification() as? CarbEditRemoteNotification) ++ XCTAssertNil(editNotification.foodType) ++ } ++ ++ func testParseCarbEditNotification_MissingStartTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_MissingAbsorptionTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_DoesNotDecodeAsCarbEntry() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertFalse(CarbRemoteNotification.includedInNotification(notification)) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(notification)) ++ } ++ ++ func dateFormatter() -> ISO8601DateFormatter { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +new file mode 100644 +index 0000000..e284d3f +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +@@ -0,0 +1,21 @@ ++// ++// RemoteNotificationCapabilitiesTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class RemoteNotificationCapabilitiesTestCase: XCTestCase { ++ ++ func testSupportedCommands_MatchNotificationKeys() { ++ XCTAssertEqual(RemoteNotificationCapabilities.supported, ["carbs-delete", "carbs-edit"]) ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(["carbs-delete": "id"])) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(["carbs-edit": "id"])) ++ for key in RemoteNotificationCapabilities.supported { ++ let notification: [String: Any] = [key: "id"] ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(notification) ++ || CarbEditRemoteNotification.includedInNotification(notification), key) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +index 2a3fff3..0bfd600 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +@@ -54,6 +54,10 @@ extension StoredRemoteNotification { + return "Bolus" + case .carbs: + return "Carbs" ++ case .carbsDelete: ++ return "Delete Carbs" ++ case .carbsEdit: ++ return "Edit Carbs" + case .override: + return "Override" + case .overrideCancel: +@@ -74,6 +78,10 @@ extension StoredRemoteNotification { + return String(format: "%.2f U", notification.amount) + case .carbs(let notification): + return String(format: "%.0f g", notification.amount) ++ case .carbsDelete(let notification): ++ return String(notification.syncIdentifier.prefix(8)) ++ case .carbsEdit(let notification): ++ return String(format: "%.0f g", notification.amount) + case .override(let notification): + return notification.name + case .overrideCancel: diff --git a/remote_window/remote_window_remote_carb_edit.patch b/remote_window/remote_window_remote_carb_edit.patch new file mode 100644 index 0000000..3b4de2d --- /dev/null +++ b/remote_window/remote_window_remote_carb_edit.patch @@ -0,0 +1,1920 @@ +Submodule Loop 6571f8f8...7f5a369c: +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 3d07128e..981f1e30 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -829,13 +829,14 @@ extension DeviceDataManager { + + // MARK: - Client API + extension DeviceDataManager { +- func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { ++ func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ result: Swift.Result) -> Void = { _ in }) { + guard let pumpManager = pumpManager else { +- completion(LoopError.configurationError(.pumpManager)) ++ completion(.failure(LoopError.configurationError(.pumpManager))) + return + } + +- self.loopManager.addRequestedBolus(DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true)) { ++ let doseEntry = DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true) ++ self.loopManager.addRequestedBolus(doseEntry) { + pumpManager.enactBolus(units: units, activationType: activationType) { (error) in + if let error = error { + self.log.error("%{public}@", String(describing: error)) +@@ -851,11 +852,23 @@ extension DeviceDataManager { + } + + self.loopManager.bolusRequestFailed(error) { +- completion(error) ++ completion(.failure(error)) + } + } else { + self.loopManager.bolusConfirmed() { +- completion(nil) ++ // The DoseEntry from the store is returned as it has the syncIdentifier set. ++ self.doseStore.getDoses(start: doseEntry.startDate, includeMutable: true) { result in ++ switch result { ++ case .success(let doses): ++ if let storedEntry = doses.filter({ $0.programmedUnits == doseEntry.programmedUnits }).first { ++ completion(.success(storedEntry)) ++ } else { ++ completion(.failure(EnactBolusError.storageFailure)) ++ } ++ case .failure: ++ completion(.failure(EnactBolusError.storageFailure)) ++ } ++ } + } + } + } +@@ -864,17 +877,17 @@ extension DeviceDataManager { + } + } + +- func enactBolus(units: Double, activationType: BolusActivationType) async throws { ++ func enactBolus(units: Double, activationType: BolusActivationType) async throws -> DoseEntry { + return try await withCheckedThrowingContinuation { continuation in +- enactBolus(units: units, activationType: activationType) { error in +- if let error = error { +- continuation.resume(throwing: error) +- return +- } +- continuation.resume() ++ enactBolus(units: units, activationType: activationType) { result in ++ continuation.resume(with: result) + } + } + } ++ ++ enum EnactBolusError: Error { ++ case storageFailure ++ } + + var pumpManagerStatus: PumpManagerStatus? { + return pumpManager?.status +@@ -1459,10 +1472,49 @@ extension Notification.Name { + + extension DeviceDataManager: ServicesManagerDosingDelegate { + +- func deliverBolus(amountInUnits: Double) async throws { +- try await enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) ++ func deliverBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { ++ let minTreatmentIntervalInMinutes = 10.0 ++ let boluses = try await doseStore.getBoluses(start: Date().addingTimeInterval(-.days(1))) ++ // Look back further for safety in case caregiver didn't know about other treatments before they send this (i.e. Upload delays) ++ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) ++ let conflictingEntries = boluses.filter({$0.startDate >= conflictStartDate}) ++ let conflictingBolusAmount = conflictingEntries.map({$0.programmedUnits}).reduce(0, {$0 + $1}) ++ let adjustedDoseAmount = amountInUnits - conflictingBolusAmount ++ guard adjustedDoseAmount <= amountInUnits else { // Added safety check for the bolus math ++ throw BolusActionError.invalidCalculation ++ } ++ guard adjustedDoseAmount > 0.0 else { ++ throw BolusActionError.bolusAmountAdjustedToZero(amountInUnits, conflictStartDate) ++ } ++ return try await enactBolus(units: adjustedDoseAmount, activationType: .manualNoRecommendation) + } + ++ enum BolusActionError: LocalizedError { ++ case bolusAmountAdjustedToZero( _ requestedAmountInUnits: Double, _ conflictStartDate: Date) ++ case invalidCalculation ++ ++ var errorDescription: String? { ++ switch self { ++ case .bolusAmountAdjustedToZero(let amountInUnits, let conflictStartDate): ++ let treatmentAmountFormatted = Self.numberFormatter.string(from: HKQuantity(unit: .internationalUnit(), doubleValue: amountInUnits), unit: .internationalUnit()) ?? "" ++ return String(format: NSLocalizedString("All boluses delivered after %1$@ were subtracted from the requested bolus amount for safety. Those amount(s) exceed the requested amount of %2$@ so the remote bolus was not delivered.", comment: "Bolus error description: no remote bolus delivered."), conflictStartDate.formatted(date: .omitted, time: .complete), treatmentAmountFormatted) ++ case .invalidCalculation: ++ return String(format: NSLocalizedString("An invalid bolus calculation occurred.", comment: "Bolus error description: invalid calculation.")) ++ } ++ } ++ ++ static var numberFormatter: NumberFormatter = { ++ let formatter = NumberFormatter() ++ formatter.numberStyle = .decimal ++ return formatter ++ }() ++ ++ static var dateFormatter: DateFormatter = { ++ let formatter = DateFormatter() ++ formatter.timeStyle = .medium ++ return formatter ++ }() ++ } + } + + // MARK: - Critical Event Log Export +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 8f497568..974122ce 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -2497,7 +2497,7 @@ extension LoopDataManager: ServicesManagerDelegate { + + //Overrides + +- func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws { ++ func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride { + + guard let preset = settings.overridePresets.first(where: { $0.name == name }) else { + throw EnactOverrideError.unknownPreset(name) +@@ -2510,11 +2510,27 @@ extension LoopDataManager: ServicesManagerDelegate { + } + + await enactOverride(remoteOverride) ++ return remoteOverride + } + + +- func cancelCurrentOverride() async throws { ++ func cancelCurrentOverride() async throws -> TemporaryScheduleOverride { ++ guard let currentOverride = settings.scheduleOverride else { ++ throw OverrideCancelError.overrideNotActive ++ } + await enactOverride(nil) ++ return currentOverride ++ } ++ ++ enum OverrideCancelError: LocalizedError { ++ case overrideNotActive ++ ++ var errorDescription: String? { ++ switch self { ++ case .overrideNotActive: ++ return String(format: NSLocalizedString("No overrides active for cancellation", comment: "Cancel Override error description: override not active for cancellation.")) ++ } ++ } + } + + func enactOverride(_ override: TemporaryScheduleOverride?) async { +@@ -2535,15 +2551,28 @@ extension LoopDataManager: ServicesManagerDelegate { + + //Carb Entry + +- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { ++ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { + let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium + let startDate = startDate ?? Date() + try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) +- ++ ++ let maxAllowedConflictingAmountInGrams = 0.0 // Not allowing any carbs ++ let minTreatmentIntervalInMinutes = 10.0 ++ let entries = try await carbStore.getCarbEntries(start: Date().addingTimeInterval(-.days(1)), end: Date().addingTimeInterval(.days(1))) ++ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) // Look back a little further for added safety (i.e. Network delays) ++ let conflictingEntries = entries.filter({ entry in ++ let createdDate = entry.userCreatedDate ?? entry.startDate ++ return createdDate >= conflictStartDate ++ }) ++ let conflictingCarbAmount = conflictingEntries.map({$0.quantity.doubleValue(for: .gram())}).reduce(0, {$0 + $1}) ++ guard conflictingCarbAmount <= maxAllowedConflictingAmountInGrams else { ++ throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) ++ } ++ + let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) + let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) + +- let _ = try await devliverCarbEntry(candidateCarbEntry) ++ return try await deliverCarbEntry(candidateCarbEntry) + } + + /// Remote edits and deletes may only target entries this app created within the last 24 hours. +@@ -2621,11 +2650,12 @@ extension LoopDataManager: ServicesManagerDelegate { + case invalidStartDate(Date) + case exceedsMaxCarbs + case invalidCarbs ++ case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) + case entryNotFound + case entryNotEditable + + var errorDescription: String? { +- switch self { ++ switch self { + case .exceedsMaxCarbs: + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") + case .invalidCarbs: +@@ -2640,6 +2670,10 @@ extension LoopDataManager: ServicesManagerDelegate { + 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) ++ case .conflictingTreatments(let conflictingAmountInGrams, let minutesRequiredBetweenTreatments): ++ let treatmentAmountFormatted = Self.numberFormatter.string(from: HKQuantity(unit: .gram(), doubleValue: conflictingAmountInGrams), unit: .gram()) ?? "" ++ let minutesRequiredBetweenTreatmentsFormatted = Self.numberFormatter.string(from: minutesRequiredBetweenTreatments) ?? "" ++ return String(format: NSLocalizedString("Conflicting carb treatments (%1$@) have occurred. At least %2$@ minutes must pass between carb entries.", comment: "Carb error description: conflicting treatments occurred."), treatmentAmountFormatted, minutesRequiredBetweenTreatmentsFormatted) + } + } + +@@ -2657,7 +2691,7 @@ extension LoopDataManager: ServicesManagerDelegate { + } + + //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version +- func devliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { ++ func deliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { + return try await withCheckedThrowingContinuation { continuation in + carbStore.addCarbEntry(carbEntry) { result in + switch result { +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 4f99e6f9..1a3d714b 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -232,13 +232,13 @@ class ServicesManager { + } + + public protocol ServicesManagerDosingDelegate: AnyObject { +- func deliverBolus(amountInUnits: Double) async throws ++ func deliverBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry + } + + 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 ++ func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride ++ func cancelCurrentOverride() async throws -> TemporaryScheduleOverride ++ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry + func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry + func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } +@@ -275,7 +275,10 @@ extension ServicesManager: ServiceDelegate { + return semanticVersion + } + +- func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws { ++ func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride { ++ guard let servicesManagerDelegate else { ++ throw OverrideActionError.internalError ++ } + + var duration: TemporaryScheduleOverride.Duration? = nil + if let durationTime = durationTime { +@@ -295,14 +298,16 @@ extension ServicesManager: ServiceDelegate { + } + } + +- try await servicesManagerDelegate?.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) ++ let override = try await servicesManagerDelegate.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) + await remoteDataServicesManager.triggerUpload(for: .overrides) ++ return override + } + + enum OverrideActionError: LocalizedError { + + case durationExceedsMax(TimeInterval) + case negativeDuration ++ case internalError + + var errorDescription: String? { + switch self { +@@ -310,36 +315,60 @@ extension ServicesManager: ServiceDelegate { + 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.")) ++ case .internalError: ++ return String(format: NSLocalizedString("Internal error", comment: "Override error description: Internal error.")) + } + } + } + +- func cancelRemoteOverride() async throws { +- try await servicesManagerDelegate?.cancelCurrentOverride() ++ func cancelRemoteOverride() async throws -> TemporaryScheduleOverride { ++ guard let servicesManagerDelegate else { ++ throw CarbActionError.internalError ++ } ++ ++ let cancelledOverride = try await servicesManagerDelegate.cancelCurrentOverride() + await remoteDataServicesManager.triggerUpload(for: .overrides) ++ return cancelledOverride + } + +- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { ++ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { + do { +- try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ guard let servicesManagerDelegate else { ++ throw CarbActionError.internalError ++ } ++ ++ let carbEntry = try await servicesManagerDelegate.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate, userCreatedDate: userCreatedDate) + await NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) + await remoteDataServicesManager.triggerUpload(for: .carb) + analyticsServicesManager.didAddCarbs(source: "Remote", amount: amountInGrams) ++ return carbEntry + } catch { + await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) + throw error + } + } + +- func deleteRemoteCarbs(syncIdentifier: String) async throws { ++ enum CarbActionError: LocalizedError { ++ case internalError ++ ++ var errorDescription: String? { ++ switch self { ++ case .internalError: ++ return NSLocalizedString("Internal error", comment: "Carb error description: internal error.") ++ } ++ } ++ } ++ ++ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { + do { + guard let servicesManagerDelegate else { +- throw RemoteCarbCommandError.internalError ++ throw CarbActionError.internalError + } + let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) + let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram()))) + await NotificationManager.sendRemoteCarbChangeNotification(body: body) + await remoteDataServicesManager.triggerUpload(for: .carb) ++ return deleted + } catch { + let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") + await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) +@@ -347,34 +376,27 @@ extension ServicesManager: ServiceDelegate { + } + } + +- func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws { ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { + let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) + do { + guard let servicesManagerDelegate else { +- throw RemoteCarbCommandError.internalError ++ throw CarbActionError.internalError + } +- _ = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ let replacement = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) + await NotificationManager.sendRemoteCarbChangeNotification(body: body) + await remoteDataServicesManager.triggerUpload(for: .carb) ++ return replacement + } catch { + await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) + throw error + } + } + +- enum RemoteCarbCommandError: LocalizedError { +- case internalError +- +- var errorDescription: String? { +- switch self { +- case .internalError: +- return NSLocalizedString("Internal error", comment: "Remote carb command error description: internal error.") +- } +- } +- } +- +- func deliverRemoteBolus(amountInUnits: Double) async throws { ++ func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { + do { ++ guard let servicesManagerDosingDelegate else { ++ throw BolusActionError.internalError ++ } + + guard amountInUnits > 0 else { + throw BolusActionError.invalidBolus +@@ -388,10 +410,11 @@ extension ServicesManager: ServiceDelegate { + throw BolusActionError.exceedsMaxBolus + } + +- try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) +- await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) ++ let doseEntry = try await servicesManagerDosingDelegate.deliverBolus(amountInUnits: amountInUnits, userCreatedDate: userCreatedDate) ++ await NotificationManager.sendRemoteBolusNotification(amount: doseEntry.programmedUnits) + await remoteDataServicesManager.triggerUpload(for: .dose) + analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) ++ return doseEntry + } catch { + await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) + throw error +@@ -403,6 +426,7 @@ extension ServicesManager: ServiceDelegate { + case invalidBolus + case missingMaxBolus + case exceedsMaxBolus ++ case internalError + + var errorDescription: String? { + switch self { +@@ -412,6 +436,8 @@ extension ServicesManager: ServiceDelegate { + 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.") ++ case .internalError: ++ return NSLocalizedString("Internal error", comment: "Bolus error description: internal error.") + } + } + } +diff --git a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +index 692a4ec2..41221127 100644 +--- a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift ++++ b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +@@ -34,8 +34,6 @@ protocol CarbStoreProtocol: AnyObject { + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) +- +- func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] + + func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbStatus]>) -> Void) + +@@ -53,6 +51,8 @@ protocol CarbStoreProtocol: AnyObject { + func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) ++ ++ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] + } + + extension CarbStore: CarbStoreProtocol { } +diff --git a/Loop/Loop/View Models/BolusEntryViewModel.swift b/Loop/Loop/View Models/BolusEntryViewModel.swift +index a86f20e0..a4432efc 100644 +--- a/Loop/Loop/View Models/BolusEntryViewModel.swift ++++ b/Loop/Loop/View Models/BolusEntryViewModel.swift +@@ -29,7 +29,7 @@ protocol BolusEntryViewModelDelegate: AnyObject { + + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) + +- func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void) ++ func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ result: Swift.Result) -> Void) + + func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) + +Submodule LoopKit 7223f861...e7ce9e26: +diff --git a/LoopKit/LoopKit/InsulinKit/DoseStore.swift b/LoopKit/LoopKit/InsulinKit/DoseStore.swift +index 712f9048..be7c17dd 100644 +--- a/LoopKit/LoopKit/InsulinKit/DoseStore.swift ++++ b/LoopKit/LoopKit/InsulinKit/DoseStore.swift +@@ -1274,8 +1274,18 @@ extension DoseStore { + public func getDoses(start: Date? = nil, end: Date? = nil) async throws -> [DoseEntry] { + return try await insulinDeliveryStore.getDoses(start: start, end: end) + } +- +- ++ ++ /// Retrieves doses overlapping supplied range ++ /// ++ /// - Parameters: ++ /// - start: The earliest date of dose entries to retrieve. ++ /// - end: The latest date of dose entries to retrieve, if provided. ++ /// - includeMutable: Whether to include mutable dose entries or not. ++ /// - completion: A closure called once the dose entries have been retrieved. ++ /// - result: An array of dose entries, in chronological order by startDate, or error. ++ public func getDoses(start: Date, end: Date? = nil, includeMutable: Bool, completion: @escaping (_ result: Swift.Result<[DoseEntry], Error>) -> Void) { ++ insulinDeliveryStore.getDoseEntries(start: start, end: end, includeMutable: includeMutable, completion: completion) ++ } + + /// Retrieves the maximum insulin on-board value from the two timeline values nearest to the specified date + /// +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 4b2cecf3..8fb254b6 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -9,10 +9,10 @@ + import Foundation + + public protocol RemoteActionDelegate: AnyObject { +- func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws +- func cancelRemoteOverride() async throws +- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws +- func deliverRemoteBolus(amountInUnits: Double) async throws +- func deleteRemoteCarbs(syncIdentifier: String) async throws +- func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws ++ func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride ++ func cancelRemoteOverride() async throws -> TemporaryScheduleOverride ++ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry ++ func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry ++ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } +Submodule NightscoutService 169054f3...a7b9002f: +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift +index 4a85888..fa35651 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift +@@ -117,25 +117,27 @@ extension NightscoutClient { + + extension NightscoutClient { + +- func createDoses(_ data: [DoseEntry], usingObjectIdCache objectIdCache: ObjectIdCache, completion: @escaping (Result<[String], Error>) -> Void) { +- guard !data.isEmpty else { +- completion(.success([])) +- return +- } +- +- let source = "loop://\(UIDevice.current.name)" +- ++ func createDoses( ++ _ data: [DoseEntry], ++ sourceMessage: (DoseEntry) -> String, ++ usingObjectIdCache objectIdCache: ObjectIdCache, ++ completion: @escaping (Result<[String], Error>) -> Void ++ ) { + let treatments = data.compactMap { (dose) -> NightscoutTreatment? in + var objectId: String? = nil +- ++ + if let syncIdentifier = dose.syncIdentifier { + objectId = objectIdCache.findObjectIdBySyncIdentifier(syncIdentifier) + } +- +- return dose.treatment(enteredBy: source, withObjectId: objectId) ++ ++ return dose.treatment(enteredBy: sourceMessage(dose), withObjectId: objectId) + } +- +- ++ ++ guard !treatments.isEmpty else { ++ completion(.success([])) ++ return ++ } ++ + self.upload(treatments) { (result) in + switch result { + case .failure(let error): +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 169fb86..a968a64 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -65,10 +65,12 @@ public final class NightscoutService: Service { + + private let log = OSLog(category: "NightscoutService") + ++ private let notificationExpirationInMinutes = 10.0 ++ + public init() { + self.isOnboarded = false + self.lockedObjectIdCache = Locked(ObjectIdCache()) +- self.otpManager = OTPManager(secretStore: KeychainManager()) ++ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) + self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) + self.commandSourceV1.delegate = self + } +@@ -83,11 +85,11 @@ public final class NightscoutService: Service { + } else { + self.lockedObjectIdCache = Locked(ObjectIdCache()) + } +- +- self.otpManager = OTPManager(secretStore: KeychainManager()) ++ ++ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) + self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) + self.commandSourceV1.delegate = self +- ++ + restoreCredentials() + } + +@@ -238,12 +240,35 @@ extension NightscoutService: RemoteDataService { + public var doseDataLimit: Int? { return 1000 } + + public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry], completion: @escaping (_ result: Result) -> Void) { ++ Task { ++ let notificationHistory = await notificationHistory() ++ uploadDoseData(created: created, deleted: deleted, notificationHistory: notificationHistory, completion: completion) ++ ++ // Upload pending stored notifications ++ await uploadPendingNotifications() ++ } ++ } ++ ++ func uploadPendingNotifications() async { ++ await commandSourceV1.uploadPendingNotifications() ++ } ++ ++ public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry], notificationHistory: [StoredRemoteNotification], completion: @escaping (_ result: Result) -> Void) { + guard hasConfiguration, let uploader = uploader else { + completion(.success(true)) + return + } + +- uploader.createDoses(created, usingObjectIdCache: self.objectIdCache) { (result) in ++ let deviceName = UIDevice.current.name ++ let sourceMessage: (DoseEntry) -> String = { (dose) -> String in ++ if notificationHistory.contains(where: { $0.containsDose(dose) }) { ++ return "Loop (via remote command)" ++ } else { ++ return "loop://\(deviceName)" ++ } ++ } ++ ++ uploader.createDoses(created, sourceMessage: sourceMessage, usingObjectIdCache: objectIdCache) { (result) in + switch (result) { + case .failure(let error): + completion(.failure(error)) +@@ -399,10 +424,34 @@ extension NightscoutService: RemoteDataService { + + + public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { ++ guard let serviceDelegate else { ++ return ++ } ++ ++ // Set the expiration to longer duration. Ideally this would be done in Nightscout instead. ++ var notification = notification ++ let dateFormatter = DateFormatter.iso8601DateDecoder ++ if let sentDateString = notification["sent-at"] as? String, let sentDate = dateFormatter.date(from: sentDateString) { ++ let expirationDate = sentDate.addingTimeInterval(60 * notificationExpirationInMinutes) ++ notification["expiration"] = dateFormatter.string(from: expirationDate) as AnyObject ++ } ++ + let commandSource = try commandSource(notification: notification) +- await commandSource.remoteNotificationWasReceived(notification) ++ await commandSource.remoteNotificationWasReceived(notification, serviceDelegate: serviceDelegate) + } +- ++ ++ public func notificationHistory() async -> [StoredRemoteNotification] { ++ return await commandSourceV1.notificationHistory() ++ } ++ ++ public func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> { ++ return await commandSourceV1.notificationPublisher() ++ } ++ ++ public func deleteNotificationHistory() { ++ commandSourceV1.deleteNotificationHistory() ++ } ++ + private func commandSource(notification: [String: AnyObject]) throws -> RemoteCommandSource { + return commandSourceV1 + } +@@ -410,126 +459,29 @@ extension NightscoutService: RemoteDataService { + } + + extension NightscoutService: RemoteCommandSourceV1Delegate { +- +- func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action, remoteNotification: RemoteNotification) async throws { +- +- let returnInfo = remoteNotification.getReturnNotificationInfo() +- if returnInfo == nil { +- os_log("No return notification info available, response will not be sent", log: .default, type: .info) +- } else { +- os_log("Return notification info available, will send response after command processing", log: .default, type: .info) +- } +- +- var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch +- var success = false +- var message = "" +- var syncIdentifier: String? = nil +- +- do { +- switch action { +- case .temporaryScheduleOverride(let overrideCommand): +- commandType = .override +- try await self.serviceDelegate?.enactRemoteOverride( +- name: overrideCommand.name, +- durationTime: overrideCommand.durationTime, +- remoteAddress: overrideCommand.remoteAddress +- ) +- success = true +- message = "Override '\(overrideCommand.name)' enacted successfully" +- +- case .cancelTemporaryOverride: +- commandType = .cancelOverride +- try await self.serviceDelegate?.cancelRemoteOverride() +- success = true +- message = "Override cancelled successfully" +- +- case .bolusEntry(let bolusCommand): +- commandType = .bolus +- try await self.serviceDelegate?.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits) +- success = true +- message = String(format: "Bolus of %.2f units delivered successfully", bolusCommand.amountInUnits) +- +- case .carbsEntry(let carbCommand): +- commandType = .carbs +- try await self.serviceDelegate?.deliverRemoteCarbs( +- amountInGrams: carbCommand.amountInGrams, +- absorptionTime: carbCommand.absorptionTime, +- foodType: carbCommand.foodType, +- startDate: carbCommand.startDate +- ) +- success = true +- message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) +- +- case .carbsDelete(let deleteCommand): +- commandType = .carbsDelete +- syncIdentifier = deleteCommand.syncIdentifier +- try await self.serviceDelegate?.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) +- success = true +- message = "Carb entry deleted successfully" +- +- case .carbsEdit(let editCommand): +- commandType = .carbsEdit +- syncIdentifier = editCommand.syncIdentifier +- try await self.serviceDelegate?.replaceRemoteCarbs( +- syncIdentifier: editCommand.syncIdentifier, +- amountInGrams: editCommand.amountInGrams, +- absorptionTime: editCommand.absorptionTime, +- foodType: editCommand.foodType, +- startDate: editCommand.startDate +- ) +- success = true +- message = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) +- } +- } catch { +- message = "Command failed: \(error.localizedDescription)" +- // Send failure response before rethrowing +- if let returnInfo = returnInfo { +- await RemoteNotificationResponseManager.shared.sendResponseNotification( +- to: returnInfo, +- commandType: commandType, +- success: false, +- message: message, +- syncIdentifier: syncIdentifier +- ) +- } +- throw error +- } +- +- // Send success response +- if let returnInfo = returnInfo { +- await RemoteNotificationResponseManager.shared.sendResponseNotification( +- to: returnInfo, +- commandType: commandType, +- success: success, +- message: message, +- syncIdentifier: syncIdentifier +- ) +- } +- } +- +- func commandSourceV1(_: RemoteCommandSourceV1, uploadError error: Error, notification: [String: AnyObject]) async throws { +- ++ func commandSourceV1(_: RemoteCommandSourceV1, uploadError errorMessage: String, receivedDate: Date, notification: [String: AnyObject]) async throws { ++ + guard let uploader = self.uploader else {throw NightscoutServiceError.missingCredentials} + var commandDescription = "Loop Remote Action Error" + if let remoteNotification = try? notification.toRemoteNotification() { + commandDescription = remoteNotification.toRemoteAction().description + } +- ++ + let notificationJSON = try JSONSerialization.data(withJSONObject: notification) + let notificationJSONString = String(data: notificationJSON, encoding: .utf8) ?? "" +- ++ + let noteBody = """ +- \(error.localizedDescription) ++ \(errorMessage) + \(notificationJSONString) + """ + + let treatment = NightscoutTreatment( +- timestamp: Date(), ++ timestamp: receivedDate, + enteredBy: commandDescription, + notes: noteBody, + eventType: .note + ) +- ++ + return try await withCheckedThrowingContinuation { continuation in + uploader.upload([treatment], completionHandler: { result in + switch result { +diff --git a/NightscoutService/NightscoutServiceKit/OTPManager.swift b/NightscoutService/NightscoutServiceKit/OTPManager.swift +index 3d51b72..7a572d3 100644 +--- a/NightscoutService/NightscoutServiceKit/OTPManager.swift ++++ b/NightscoutService/NightscoutServiceKit/OTPManager.swift +@@ -77,8 +77,19 @@ public class OTPManager { + let maxOTPsToAccept: Int + + public static var defaultTokenPeriod: TimeInterval = 30 +- public static var defaultMaxOTPsToAccept = 2 +- ++ public static var defaultMaxOTPsToAccept = 30 ++ ++ public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, maxMinutesValid: Double) { ++ self.secretStore = secretStore ++ self.nowDateSource = nowDateSource ++ self.tokenPeriod = OTPManager.defaultTokenPeriod ++ let secondsValid = 60.0 * maxMinutesValid ++ self.maxOTPsToAccept = Int(secondsValid / OTPManager.defaultTokenPeriod) ++ if secretStore.tokenSecretKey() == nil || secretStore.tokenSecretKeyName() == nil { ++ resetSecretKey() ++ } ++ } ++ + public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, tokenPeriod: TimeInterval = OTPManager.defaultTokenPeriod, maxOTPsToAccept: Int = OTPManager.defaultMaxOTPsToAccept) { + self.secretStore = secretStore + self.nowDateSource = nowDateSource +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift +index 3a56d81..6db615c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift +@@ -9,10 +9,12 @@ + import Foundation + + public struct BolusAction: Codable { +- ++ + public let amountInUnits: Double +- +- public init(amountInUnits: Double) { ++ public let userCreatedDate: Date ++ ++ public init(amountInUnits: Double, userCreatedDate: Date) { + self.amountInUnits = amountInUnits ++ self.userCreatedDate = userCreatedDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift +index 3865989..b315b16 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift +@@ -14,11 +14,13 @@ public struct CarbAction: Codable{ + public let absorptionTime: TimeInterval? + public let foodType: String? + public let startDate: Date? +- +- public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil) { ++ public let userCreatedDate: Date ++ ++ public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil, userCreatedDate: Date) { + self.amountInGrams = amountInGrams + self.absorptionTime = absorptionTime + self.foodType = foodType + self.startDate = startDate ++ self.userCreatedDate = userCreatedDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift +index e3cea7f..eb57fac 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift +@@ -9,5 +9,6 @@ + import LoopKit + + protocol RemoteCommandSource { +- func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async ++ func remoteNotificationWasReceived(_ notification: [String: AnyObject], serviceDelegate: ServiceDelegate) async ++ func notificationHistory() async -> [StoredRemoteNotification] + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift +index 1c14cdf..869abc2 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift +@@ -14,11 +14,11 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { + public let amount: Double + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +- ++ + enum CodingKeys: String, CodingKey { + case remoteAddress = "remote-address" + case amount = "bolus-entry" +@@ -28,9 +28,13 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { + case enteredBy = "entered-by" + case encryptedReturnNotification = "encrypted_return_notification" + } +- ++ + func toRemoteAction() -> Action { +- return .bolusEntry(BolusAction(amountInUnits: amount)) ++ return .bolusEntry(toBolusAction()) ++ } ++ ++ func toBolusAction() -> BolusAction { ++ return BolusAction(amountInUnits: amount, userCreatedDate: sentAt) + } + + func otpValidationRequired() -> Bool { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +index 1593a74..eda9134 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +@@ -11,7 +11,7 @@ public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { + public let syncIdentifier: String + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +index fde3e4c..f1ffb51 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +@@ -16,7 +16,7 @@ public struct CarbEditRemoteNotification: RemoteNotification, Codable { + public let startDate: Date + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift +index 6c238ed..c069c34 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift +@@ -17,7 +17,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { + public let startDate: Date? + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +@@ -43,7 +43,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { + } + + func toRemoteAction() -> Action { +- let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) ++ let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate, userCreatedDate: sentAt) + return .carbsEntry(action) + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift +index fdd6d6b..babb71f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift +@@ -13,7 +13,7 @@ public struct OverrideCancelRemoteNotification: RemoteNotification, Codable { + + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let cancelOverride: String + public let enteredBy: String? + public let otp: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift +index 2060a74..426ff89 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift +@@ -15,7 +15,7 @@ public struct OverrideRemoteNotification: RemoteNotification, Codable { + public let durationInMinutes: Double? + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let enteredBy: String? + public let otp: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 6f0524d..be43210 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -14,7 +14,7 @@ protocol RemoteNotification: Codable { + + var id: String {get} + var expiration: Date? {get} +- var sentAt: Date? {get} ++ var sentAt: Date {get} + var otp: String? {get} + var remoteAddress: String {get} + var enteredBy: String? {get} +@@ -40,12 +40,8 @@ extension RemoteNotification { + } + + var id: String { +- //There is no unique identifier so we use the sent date when available +- if let sentAt = sentAt { +- return "\(sentAt.timeIntervalSince1970)" +- } else { +- return UUID().uuidString +- } ++ //There is no unique identifier so we use the sent date ++ return "\(sentAt.timeIntervalSince1970)" + } + + func getReturnNotificationInfo() -> ReturnNotificationInfo? { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +index 0165004..331ed5c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +@@ -7,69 +7,527 @@ + // + + import Foundation ++import LoopKit + import OSLog + + class RemoteCommandSourceV1: RemoteCommandSource { +- ++ + weak var delegate: RemoteCommandSourceV1Delegate? + private let otpManager: OTPManager + private let log = OSLog(category: "Remote Command Source V1") + private var commandValidator: RemoteCommandValidator + private var recentNotifications = RecentNotifications() +- ++ + init(otpManager: OTPManager) { + self.otpManager = otpManager + self.commandValidator = RemoteCommandValidator(otpManager: otpManager) + } +- ++ + //MARK: RemoteCommandSource +- +- func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async { +- +- if let encryptedReturnNotification = notification["encrypted_return_notification"] { +- log.info("Found encrypted_return_notification in notification: %{public}@", String(describing: encryptedReturnNotification)) +- } else { +- log.info("No encrypted_return_notification found in notification. Available keys: %{public}@", Array(notification.keys).joined(separator: ", ")) ++ ++ func remoteNotificationWasReceived(_ notification: [String: AnyObject], serviceDelegate: ServiceDelegate) async { ++ ++ guard let remoteNotification = try? notification.toRemoteNotification() else { ++ log.error("Remote Notification: Malformed notification payload") ++ return ++ } ++ ++ guard await !recentNotifications.contains(pushIdentifier: remoteNotification.id) else { ++ // Duplicate notifications are expected after app is force killed ++ // https://github.com/LoopKit/Loop/issues/2174 ++ return ++ } ++ ++ // Extract APNS return info for sending responses back to the caregiver ++ let returnInfo = remoteNotification.getReturnNotificationInfo() ++ if returnInfo != nil { ++ log.info("Return notification info available, will send APNS response after command processing") + } +- ++ ++ var commandType: RemoteNotificationResponseManager.CommandType = .bolus ++ var successMessage = "" ++ var syncIdentifier: String? = nil ++ + do { +- guard let delegate = delegate else {return} +- let remoteNotification = try notification.toRemoteNotification() +- +- // Log after parsing to see if the field was preserved +- if let encryptedReturnNotification = remoteNotification.encryptedReturnNotification { +- log.info("Parsed encrypted_return_notification successfully, length: %d", encryptedReturnNotification.count) +- } else { +- log.info("encrypted_return_notification is nil after parsing") ++ try await recentNotifications.trackReceivedRemoteNotification(remoteNotification, rawNotification: notification) ++ try commandValidator.validate(remoteNotification: remoteNotification) ++ ++ switch remoteNotification.toRemoteAction() { ++ case .bolusEntry(let bolusCommand): ++ commandType = .bolus ++ let doseEntry = try await serviceDelegate.deliverRemoteBolus( ++ amountInUnits: bolusCommand.amountInUnits, ++ userCreatedDate: bolusCommand.userCreatedDate ++ ) ++ var adjustmentMessage: String? = nil ++ if bolusCommand.amountInUnits > doseEntry.programmedUnits { ++ let quantityFormatter = QuantityFormatter(for: .internationalUnit()) ++ if let bolusAmountDescription = quantityFormatter.numberFormatter.string(from: bolusCommand.amountInUnits as NSNumber), ++ let doseAmountDescription = quantityFormatter.numberFormatter.string(from: doseEntry.programmedUnits as NSNumber){ ++ adjustmentMessage = "Bolus amount was reduced from \(bolusAmountDescription) U to \(doseAmountDescription) U due to other recent treatments." ++ } ++ } ++ successMessage = String(format: "Bolus of %.2f units delivered successfully", doseEntry.programmedUnits) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: doseEntry.syncIdentifier ?? "", completionMessage: adjustmentMessage), ++ notificationJSON: notification ++ ) ++ case .cancelTemporaryOverride: ++ commandType = .cancelOverride ++ let cancelledOverride = try await serviceDelegate.cancelRemoteOverride() ++ successMessage = "Override cancelled successfully" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: cancelledOverride.syncIdentifier.uuidString, completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .carbsEntry(let carbCommand): ++ commandType = .carbs ++ let carbEntry = try await serviceDelegate.deliverRemoteCarbs( ++ amountInGrams: carbCommand.amountInGrams, ++ absorptionTime: carbCommand.absorptionTime, ++ foodType: carbCommand.foodType, ++ startDate: carbCommand.startDate, ++ userCreatedDate: carbCommand.userCreatedDate ++ ) ++ successMessage = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .carbsDelete(let deleteCommand): ++ commandType = .carbsDelete ++ syncIdentifier = deleteCommand.syncIdentifier ++ let deletedEntry = try await serviceDelegate.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) ++ successMessage = "Carb entry deleted successfully" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: deletedEntry.syncIdentifier ?? "", completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .carbsEdit(let editCommand): ++ commandType = .carbsEdit ++ syncIdentifier = editCommand.syncIdentifier ++ let replacementEntry = try await serviceDelegate.replaceRemoteCarbs( ++ syncIdentifier: editCommand.syncIdentifier, ++ amountInGrams: editCommand.amountInGrams, ++ absorptionTime: editCommand.absorptionTime, ++ foodType: editCommand.foodType, ++ startDate: editCommand.startDate ++ ) ++ successMessage = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .temporaryScheduleOverride(let overrideCommand): ++ commandType = .override ++ let override = try await serviceDelegate.enactRemoteOverride( ++ name: overrideCommand.name, ++ durationTime: overrideCommand.durationTime, ++ remoteAddress: overrideCommand.remoteAddress ++ ) ++ successMessage = "Override '\(overrideCommand.name)' enacted successfully" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: override.syncIdentifier.uuidString, completionMessage: nil), ++ notificationJSON: notification ++ ) + } +- +- guard await !recentNotifications.isDuplicate(remoteNotification) else { +- // Duplicate notifications are expected after app is force killed +- // https://github.com/LoopKit/Loop/issues/2174 +- return ++ ++ // Send APNS success response back to caregiver ++ if let returnInfo = returnInfo { ++ await RemoteNotificationResponseManager.shared.sendResponseNotification( ++ to: returnInfo, ++ commandType: commandType, ++ success: true, ++ message: successMessage, ++ syncIdentifier: syncIdentifier ++ ) + } +- try commandValidator.validate(remoteNotification: remoteNotification) +- try await delegate.commandSourceV1(self, handleAction: remoteNotification.toRemoteAction(), remoteNotification: remoteNotification) + } catch { + log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: notification), String(describing: error)) +- try? await self.delegate?.commandSourceV1(self, uploadError: error, notification: notification) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .failure(date: Date(), errorMessage: error.localizedDescription), ++ notificationJSON: notification ++ ) ++ ++ // Send APNS failure response back to caregiver ++ if let returnInfo = returnInfo { ++ await RemoteNotificationResponseManager.shared.sendResponseNotification( ++ to: returnInfo, ++ commandType: commandType, ++ success: false, ++ message: "Command failed: \(error.localizedDescription)", ++ syncIdentifier: syncIdentifier ++ ) ++ } ++ } ++ } ++ ++ func handleEnactmentCompletion( ++ remoteNotification: RemoteNotification, ++ status: RemoteNotificationStatus, ++ notificationJSON: [String: AnyObject] ++ ) async { ++ do { ++ let storedNotification = try await recentNotifications.updateStatus(status, for: remoteNotification.id) ++ await uploadStoredNotification(storedNotification) ++ } catch { ++ log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: notificationJSON), String(describing: error)) ++ } ++ } ++ ++ func uploadStoredNotification(_ storedNotification: StoredRemoteNotification) async { ++ do { ++ switch storedNotification.status { ++ case .success(_, _, let completionMessage): ++ if let completionMessage { ++ // Store adjustments as an error note to Nightscout ++ // try await self.delegate?.commandSourceV1(self, uploadError: completionMessage, receivedDate: storedNotification.receivedDate, notification: storedNotification.notificationJSON()) ++ } ++ case .failure(_, let errorMessage): ++ try await self.delegate?.commandSourceV1(self, uploadError: errorMessage, receivedDate: storedNotification.receivedDate, notification: storedNotification.notificationJSON()) ++ case .none: ++ return ++ } ++ try await recentNotifications.updateUploadStatus(true, for: storedNotification.pushIdentifier) ++ } catch { ++ log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: storedNotification), String(describing: error)) ++ } ++ } ++ ++ func notificationHistory() async -> [StoredRemoteNotification] { ++ return await recentNotifications.notifications ++ } ++ ++ /// Uploads pending notifications. Limited to a few at a time to avoid long background delays. ++ func uploadPendingNotifications() async { ++ guard let mostRecentPendingNotification = await notificationHistory().filter({$0.isPendingUpload}).sorted(by: {$0.receivedDate > $1.receivedDate}).last else { ++ return ++ } ++ await uploadStoredNotification(mostRecentPendingNotification) ++ } ++ ++ func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> { ++ return await recentNotifications.notificationPublisher() ++ } ++ ++ func deleteNotificationHistory() { ++ Task { ++ await recentNotifications.deleteNotificationHistory() + } + } + } + + protocol RemoteCommandSourceV1Delegate: AnyObject { +- func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action, remoteNotification: RemoteNotification) async throws +- func commandSourceV1(_: RemoteCommandSourceV1, uploadError error: Error, notification: [String: AnyObject]) async throws ++ func commandSourceV1(_: RemoteCommandSourceV1, uploadError errorMessage: String, receivedDate: Date, notification: [String: AnyObject]) async throws + } + +-private actor RecentNotifications { +- private var recentNotifications = [RemoteNotification]() +- +- func isDuplicate(_ remoteNotification: RemoteNotification) -> Bool { +- if recentNotifications.contains(where: {remoteNotification.id == $0.id}) { ++// MARK: Notification history ++ ++public class StoredRemoteNotification: NSObject, Codable { ++ public let remoteNotificationType: RemoteNotificationType ++ public let notificationJSONData: Data ++ public var status: RemoteNotificationStatus? = nil ++ public var receivedDate: Date ++ public var uploaded: Bool = false ++ ++ init(notificationType: RemoteNotificationType, notificationJSONData: Data) { ++ self.remoteNotificationType = notificationType ++ self.notificationJSONData = notificationJSONData ++ self.receivedDate = Date() ++ self.uploaded = false ++ } ++ ++ convenience init(bolusNotification: BolusRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .bolus(bolusNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(carbNotification: CarbRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(carbDeleteNotification: CarbDeleteRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .carbsDelete(carbDeleteNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(carbEditNotification: CarbEditRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(overrideCancelNotification: OverrideCancelRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .overrideCancel(overrideCancelNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ public var pushIdentifier: String { ++ return remoteNotification().id ++ } ++ ++ var isPendingUpload: Bool { ++ guard !uploaded else { ++ return false ++ } ++ switch status { ++ case .success, .failure: + return true ++ case nil: ++ return false ++ } ++ } ++ ++ func containsDose(_ dose: DoseEntry) -> Bool { ++ guard case let .bolus(bolusNotification) = remoteNotificationType else { ++ return false ++ } ++ ++ if case let .success(_, syncIdentifier: syncIdentifier, _) = status { ++ return dose.syncIdentifier == syncIdentifier ++ } ++ ++ // If sync identifier not set yet, that could mean either a failure occurred ++ // or we are in the middle of processing this notification. ++ // Doses start uploading during remote bolus action, ++ // before syncIdentifier is set to StoredRemoteAction. ++ // Heuristics are used to match the dose. ++ guard isDateWithinEnactmentPeriod(dose.startDate) else { ++ return false ++ } ++ ++ return dose.programmedUnits <= bolusNotification.amount ++ } ++ ++ func isDateWithinEnactmentPeriod(_ date: Date) -> Bool { ++ guard date.isAfterOrEqual(otherDate: receivedDate) else { ++ return false ++ } ++ ++ guard let completionDate else { ++ // Either enactment is in progress or there was an app crash during enactment ++ // For the corner case of an app crash, we want only want to match if the ++ // received date is within a few minutes. It should be within seconds really ++ // but minutes help when paused in the Xcode debugger. ++ return date.timeIntervalSince(receivedDate) < 60 * 5 ++ } ++ ++ guard date.isBeforeOrEqual(otherDate: completionDate) else { ++ return false + } +- recentNotifications.append(remoteNotification) +- return false ++ ++ return true ++ } ++ ++ var completionDate: Date? { ++ guard let status else { ++ return nil ++ } ++ switch status { ++ case .success(let completionDate, _, _): ++ return completionDate ++ case .failure(let completionDate, _): ++ return completionDate ++ } ++ } ++ ++ func remoteNotification() -> RemoteNotification { ++ switch remoteNotificationType { ++ case let .bolus(bolusNotification): ++ return bolusNotification ++ case let .carbs(carbNotification): ++ return carbNotification ++ case let .carbsDelete(carbDeleteNotification): ++ return carbDeleteNotification ++ case let .carbsEdit(carbEditNotification): ++ return carbEditNotification ++ case let .override(overrideNotification): ++ return overrideNotification ++ case let .overrideCancel(overrideCancelNotification): ++ return overrideCancelNotification ++ } ++ } ++ ++ public func remoteAction() -> Action { ++ return remoteNotification().toRemoteAction() ++ } ++ ++ func notificationJSON() throws -> [String: AnyObject] { ++ let jsonObject = try JSONSerialization.jsonObject(with: notificationJSONData, options: []) ++ guard let notificationJSON = jsonObject as? [String: AnyObject] else { ++ throw StoredRemoteNotificationError.notificationJSONTypeIncorrect ++ } ++ return notificationJSON ++ } ++ ++ enum StoredRemoteNotificationError: Error { ++ case notificationJSONTypeIncorrect ++ } ++ ++ public enum RemoteNotificationType: Codable { ++ case bolus(BolusRemoteNotification) ++ case carbs(CarbRemoteNotification) ++ case carbsDelete(CarbDeleteRemoteNotification) ++ case carbsEdit(CarbEditRemoteNotification) ++ case override(OverrideRemoteNotification) ++ case overrideCancel(OverrideCancelRemoteNotification) ++ } ++} ++ ++public enum RemoteNotificationStatus: Codable, Equatable { ++ case success(date: Date, syncIdentifier: String, completionMessage: String?) ++ case failure(date: Date, errorMessage: String) ++} ++ ++actor RecentNotifications { ++ var notifications: [StoredRemoteNotification] = [] ++ private var continuation: AsyncStream<[StoredRemoteNotification]>.Continuation? ++ ++ init() { ++ Task { ++ await loadNotifications() ++ } ++ } ++ ++ // Publish ++ ++ func notificationPublisher() -> AsyncStream<[StoredRemoteNotification]> { ++ return AsyncStream { continuation in ++ self.continuation = continuation ++ continuation.yield(notifications) ++ } ++ } ++ ++ private func publish(notifications: [StoredRemoteNotification]) { ++ self.notifications = notifications ++ continuation?.yield(notifications) ++ } ++ ++ // Misc ++ ++ func contains(pushIdentifier: String) -> Bool { ++ return storedNotification(for: pushIdentifier) != nil ++ } ++ ++ func storedNotification(for id: String) -> StoredRemoteNotification? { ++ return notifications.first(where: {$0.pushIdentifier == id}) ++ } ++ ++ func trackReceivedRemoteNotification(_ remoteNotification: RemoteNotification, rawNotification: [String : AnyObject]) throws { ++ let data = try JSONSerialization.data(withJSONObject: rawNotification, options: []) ++ if let bolusNotification = remoteNotification as? BolusRemoteNotification { ++ let storedNotification = StoredRemoteNotification(bolusNotification: bolusNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let carbNotification = remoteNotification as? CarbRemoteNotification { ++ let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let carbDeleteNotification = remoteNotification as? CarbDeleteRemoteNotification { ++ let storedNotification = StoredRemoteNotification(carbDeleteNotification: carbDeleteNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { ++ let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { ++ let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let overrideCancelNotification = remoteNotification as? OverrideCancelRemoteNotification { ++ let storedNotification = StoredRemoteNotification(overrideCancelNotification: overrideCancelNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else { ++ fatalError() ++ } ++ } ++ ++ func updateStatus(_ status: RemoteNotificationStatus, for pushIdentifier: String) throws -> StoredRemoteNotification { ++ guard let storedNotification = storedNotification(for: pushIdentifier) else { ++ throw RemoteNotificationError.notificationNotFound(pushIdentifier) ++ } ++ storedNotification.status = status ++ try storeNotification(storedNotification) ++ return storedNotification ++ } ++ ++ func updateUploadStatus(_ uploaded: Bool, for pushIdentifier: String) throws { ++ guard let storedNotification = storedNotification(for: pushIdentifier) else { ++ throw RemoteNotificationError.notificationNotFound(pushIdentifier) ++ } ++ storedNotification.uploaded = uploaded ++ try storeNotification(storedNotification) ++ } ++ ++ // Disk Storage ++ ++ static let maxStoredNotifications = 50 ++ ++ static var storageURL: URL { ++ let appSupportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! ++ return appSupportDir.appendingPathComponent("remote_notifications.json") ++ } ++ ++ private func storeNotification(_ notification: StoredRemoteNotification) throws { ++ if let existingIndex = notifications.firstIndex(where: {$0.pushIdentifier == notification.pushIdentifier}) { ++ notifications[existingIndex] = notification ++ } else { ++ notifications.append(notification) ++ } ++ ++ if notifications.count > Self.maxStoredNotifications { ++ notifications = Array(notifications.suffix(Self.maxStoredNotifications)) ++ } ++ ++ try saveNotifications() ++ publish(notifications: notifications) ++ } ++ ++ private func saveNotifications() throws { ++ let encoder = JSONEncoder() ++ encoder.dateEncodingStrategy = .iso8601 ++ let data = try encoder.encode(notifications) ++ try data.write(to: Self.storageURL) ++ } ++ ++ private func loadNotifications() { ++ let decoder = JSONDecoder() ++ decoder.dateDecodingStrategy = .iso8601 ++ guard let data = try? Data(contentsOf: Self.storageURL) else { ++ return ++ } ++ guard let loaded = try? decoder.decode([StoredRemoteNotification].self, from: data) else { ++ return ++ } ++ self.notifications = loaded ++ } ++ ++ func deleteNotificationHistory() { ++ notifications = [] ++ try? saveNotifications() ++ publish(notifications: notifications) ++ } ++} ++ ++enum RemoteNotificationError: LocalizedError { ++ case notificationNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .notificationNotFound(let id): ++ return "Notification not found: \(id)" ++ } ++ } ++} ++ ++extension Date { ++ func isBeforeOrEqual(otherDate: Date) -> Bool { ++ return self <= otherDate ++ } ++ ++ func isAfterOrEqual(otherDate: Date) -> Bool { ++ return self >= otherDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +index 21d0e0a..5be6e1c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +@@ -20,9 +20,6 @@ struct RemoteCommandValidator { + guard remoteNotification.expiration != nil else { + throw NotificationValidationError.missingExpiration + } +- guard remoteNotification.sentAt != nil else { +- throw NotificationValidationError.missingSentAt +- } + } + try validateExpirationDate(remoteNotification: remoteNotification) + if remoteNotification.otpValidationRequired() { +@@ -31,13 +28,13 @@ struct RemoteCommandValidator { + } + + private func validateExpirationDate(remoteNotification: RemoteNotification) throws { +- ++ + guard let expirationDate = remoteNotification.expiration else { + return //Skip validation if no date included + } +- ++ + if nowDateSource() > expirationDate { +- throw NotificationValidationError.expiredNotification ++ throw NotificationValidationError.expiredNotification(sentDate: remoteNotification.sentAt, receivedDate: nowDateSource()) + } + } + +@@ -52,20 +49,19 @@ struct RemoteCommandValidator { + + enum NotificationValidationError: LocalizedError { + case missingOTP +- case expiredNotification ++ case expiredNotification(sentDate: Date, receivedDate: Date) + case missingExpiration +- case missingSentAt +- ++ + var errorDescription: String? { + switch self { + case .missingOTP: + return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") + case .missingExpiration: + return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") +- case .missingSentAt: +- return LocalizedString("Missing sent-at time", comment: "Remote command error description: missing sent-at.") +- case .expiredNotification: +- return LocalizedString("Expired", comment: "Remote command error description: expired.") ++ case .expiredNotification(let sentDate, let receivedDate): ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "h:mm:ss a" ++ return String(format: LocalizedString("Expired. Sent: %@. Received: %@", comment: "Remote command error description: expired."), dateFormatter.string(from: sentDate), dateFormatter.string(from: receivedDate)) + } + } + } +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +index 6a3aba6..f03768c 100644 +--- a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift ++++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +@@ -48,20 +48,14 @@ final class CarbDeleteRemoteNotificationTestCase: XCTestCase { + } + } + +- func testValidate_MissingSentAt_Throws() throws { ++ func testParseCarbDeleteNotification_MissingSentAt_Throws() throws { + let notification: [String: AnyObject] = [ + "remote-address": "::ffff:11.2.44.155" as AnyObject, + "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, + "carbs-delete": "abc" as AnyObject, + "otp": "12345" as AnyObject + ] +- let remoteNotification = try notification.toRemoteNotification() +- let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) + +- XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in +- guard case RemoteCommandValidator.NotificationValidationError.missingSentAt = error else { +- return XCTFail("Unexpected error \(error)") +- } +- } ++ XCTAssertThrowsError(try notification.toRemoteNotification()) + } + } +diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +index 3d54080..0bfd600 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +@@ -14,6 +14,9 @@ import LoopKit + protocol ServiceStatusViewModelDelegate { + func verifyConfiguration(completion: @escaping (Error?) -> Void) + var siteURL: URL? { get } ++ func notificationHistory() async -> [StoredRemoteNotification] ++ func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> ++ func deleteNotificationHistory() + } + + enum ServiceStatus { +@@ -35,19 +38,96 @@ extension ServiceStatus: CustomStringConvertible { + } + } + ++struct RemoteCommand: Identifiable { ++ let id: String ++ let actionName: String ++ let createdDate: String ++ let details: String ++ let statusMessage: String ++ let isError: Bool ++} ++ ++extension StoredRemoteNotification { ++ var actionName: String { ++ switch remoteNotificationType { ++ case .bolus: ++ return "Bolus" ++ case .carbs: ++ return "Carbs" ++ case .carbsDelete: ++ return "Delete Carbs" ++ case .carbsEdit: ++ return "Edit Carbs" ++ case .override: ++ return "Override" ++ case .overrideCancel: ++ return "Cancel Override" ++ } ++ } ++ ++ var createdDateDescription: String { ++ let formatter = DateFormatter() ++ formatter.dateStyle = .short ++ formatter.timeStyle = .medium ++ return formatter.string(from: receivedDate) ++ } ++ ++ var details: String { ++ switch remoteNotificationType { ++ case .bolus(let notification): ++ return String(format: "%.2f U", notification.amount) ++ case .carbs(let notification): ++ return String(format: "%.0f g", notification.amount) ++ case .carbsDelete(let notification): ++ return String(notification.syncIdentifier.prefix(8)) ++ case .carbsEdit(let notification): ++ return String(format: "%.0f g", notification.amount) ++ case .override(let notification): ++ return notification.name ++ case .overrideCancel: ++ return "" ++ } ++ } ++ ++ var statusMessage: String { ++ guard let status else { ++ return "Pending..." ++ } ++ switch status { ++ case .success(_, _, let completionMessage): ++ return completionMessage ?? "Success" ++ case .failure(_, let errorMessage): ++ return errorMessage ++ } ++ } ++ ++ var isError: Bool { ++ guard let status else { ++ return false ++ } ++ switch status { ++ case .success: ++ return false ++ case .failure: ++ return true ++ } ++ } ++} ++ + class ServiceStatusViewModel: ObservableObject { + @Published var status: ServiceStatus = .checking +- ++ @Published var remoteCommands: [RemoteCommand] = [] ++ + let delegate: ServiceStatusViewModelDelegate + var didLogout: (() -> Void)? +- ++ + var urlString: String { + return delegate.siteURL?.absoluteString ?? LocalizedString("Not Available", comment: "Error when nightscout service url is not set") + } + + init(delegate: ServiceStatusViewModelDelegate) { + self.delegate = delegate +- ++ + delegate.verifyConfiguration { (error) in + DispatchQueue.main.async { + if let error = error { +@@ -57,5 +137,24 @@ class ServiceStatusViewModel: ObservableObject { + } + } + } ++ ++ Task { ++ let stream = await delegate.notificationPublisher() ++ for await notifications in stream { ++ let commands = notifications.reversed().map { notification in ++ RemoteCommand( ++ id: notification.pushIdentifier, ++ actionName: notification.actionName, ++ createdDate: notification.createdDateDescription, ++ details: notification.details, ++ statusMessage: notification.statusMessage, ++ isError: notification.isError ++ ) ++ } ++ await MainActor.run { ++ self.remoteCommands = commands ++ } ++ } ++ } + } + } +diff --git a/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift b/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift +index 4ecbdde..f213ccb 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift +@@ -17,56 +17,86 @@ struct ServiceStatusView: View, HorizontalSizeClassOverride { + @ObservedObject var otpViewModel: OTPViewModel + @State private var selectedItem: String? + var body: some View { +- VStack { +- Text("Nightscout") +- .font(.largeTitle) +- .fontWeight(.semibold) +- Image(frameworkImage: "nightscout", decorative: true) +- .resizable() +- .aspectRatio(contentMode: .fill) +- .frame(width: 150, height: 150) +- ++ List { ++ Section { ++ VStack { ++ Text("Nightscout") ++ .font(.largeTitle) ++ .fontWeight(.semibold) ++ Image(frameworkImage: "nightscout", decorative: true) ++ .resizable() ++ .aspectRatio(contentMode: .fill) ++ .frame(width: 150, height: 150) ++ } ++ .frame(maxWidth: .infinity) ++ .listRowBackground(Color.clear) ++ } + +- VStack(spacing: 0) { ++ Section { + HStack { + Text("URL") + Spacer() + Text(viewModel.urlString) + } +- .padding() +- Divider() + HStack { + Text("Status") + Spacer() + Text(String(describing: viewModel.status)) + } +- .padding() +- Divider() + NavigationLink(destination: OTPSelectionView(otpViewModel: otpViewModel), tag: "otp-view", selection: $selectedItem) { + HStack { + Text("One-Time Password") + Spacer() + Text(otpViewModel.otpCode) +- Image(systemName: "chevron.right") +- .font(.caption) + } +- }.foregroundColor(Color.primary) +- .padding() ++ } + } +- .background(Color(UIColor.secondarySystemBackground)) +- .cornerRadius(10) +- +- Button(action: { +- viewModel.didLogout?() +- } ) { +- Text("Logout").padding(.top, 20) ++ ++ Section(header: Text("Remote Commands")) { ++ if viewModel.remoteCommands.isEmpty { ++ Text("No remote commands received") ++ .foregroundColor(.secondary) ++ } else { ++ ForEach(viewModel.remoteCommands) { command in ++ VStack(alignment: .leading, spacing: 4) { ++ HStack { ++ Text(command.actionName) ++ .fontWeight(.medium) ++ if !command.details.isEmpty { ++ Text(command.details) ++ .foregroundColor(.secondary) ++ } ++ Spacer() ++ Text(command.createdDate) ++ .font(.caption) ++ .foregroundColor(.secondary) ++ } ++ Text(command.statusMessage) ++ .font(.caption) ++ .foregroundColor(command.isError ? .red : .secondary) ++ } ++ } ++ } ++ Button(role: .destructive) { ++ viewModel.delegate.deleteNotificationHistory() ++ } label: { ++ Text("Remove History") ++ } ++ } ++ ++ Section { ++ Button(action: { ++ viewModel.didLogout?() ++ } ) { ++ Text("Logout") ++ .foregroundColor(.red) ++ } + } + } +- .padding([.leading, .trailing]) + .navigationBarTitle("") + .navigationBarItems(trailing: dismissButton) + } +- ++ + private var dismissButton: some View { + Button(action: dismiss) { + Text("Done").bold()