Parcourir la source

Remote control capabilities using push messages

Jonas Björkert il y a 1 an
Parent
commit
8edc92e453

+ 48 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -451,6 +451,12 @@
 		DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88C8E12C50420800F2D558 /* DefinitionRow.swift */; };
 		DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88C8E12C50420800F2D558 /* DefinitionRow.swift */; };
 		DD940BAA2CA7585D000830A5 /* GlucoseColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */; };
 		DD940BAA2CA7585D000830A5 /* GlucoseColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */; };
 		DD940BAC2CA75889000830A5 /* DynamicGlucoseColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */; };
 		DD940BAC2CA75889000830A5 /* DynamicGlucoseColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */; };
+		DD9ECB682CA99F4500AA7C45 /* TrioRemoteControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB672CA99F4500AA7C45 /* TrioRemoteControl.swift */; };
+		DD9ECB6A2CA99F6C00AA7C45 /* PushMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB692CA99F6C00AA7C45 /* PushMessage.swift */; };
+		DD9ECB702CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6D2CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift */; };
+		DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */; };
+		DD9ECB722CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */; };
+		DD9ECB742CA9A0C300AA7C45 /* RemoteControlConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */; };
 		DDD163122C4C689900CD525A /* OverrideStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163112C4C689900CD525A /* OverrideStateModel.swift */; };
 		DDD163122C4C689900CD525A /* OverrideStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163112C4C689900CD525A /* OverrideStateModel.swift */; };
 		DDD163142C4C68D300CD525A /* OverrideProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163132C4C68D300CD525A /* OverrideProvider.swift */; };
 		DDD163142C4C68D300CD525A /* OverrideProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163132C4C68D300CD525A /* OverrideProvider.swift */; };
 		DDD163162C4C690300CD525A /* OverrideDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163152C4C690300CD525A /* OverrideDataFlow.swift */; };
 		DDD163162C4C690300CD525A /* OverrideDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163152C4C690300CD525A /* OverrideDataFlow.swift */; };
@@ -1116,6 +1122,12 @@
 		DD88C8E12C50420800F2D558 /* DefinitionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinitionRow.swift; sourceTree = "<group>"; };
 		DD88C8E12C50420800F2D558 /* DefinitionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinitionRow.swift; sourceTree = "<group>"; };
 		DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseColorScheme.swift; sourceTree = "<group>"; };
 		DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseColorScheme.swift; sourceTree = "<group>"; };
 		DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicGlucoseColor.swift; sourceTree = "<group>"; };
 		DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicGlucoseColor.swift; sourceTree = "<group>"; };
+		DD9ECB672CA99F4500AA7C45 /* TrioRemoteControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControl.swift; sourceTree = "<group>"; };
+		DD9ECB692CA99F6C00AA7C45 /* PushMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessage.swift; sourceTree = "<group>"; };
+		DD9ECB6D2CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigStateModel.swift; sourceTree = "<group>"; };
+		DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigProvider.swift; sourceTree = "<group>"; };
+		DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigDataFlow.swift; sourceTree = "<group>"; };
+		DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfig.swift; sourceTree = "<group>"; };
 		DDD163112C4C689900CD525A /* OverrideStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStateModel.swift; sourceTree = "<group>"; };
 		DDD163112C4C689900CD525A /* OverrideStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStateModel.swift; sourceTree = "<group>"; };
 		DDD163132C4C68D300CD525A /* OverrideProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideProvider.swift; sourceTree = "<group>"; };
 		DDD163132C4C68D300CD525A /* OverrideProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideProvider.swift; sourceTree = "<group>"; };
 		DDD163152C4C690300CD525A /* OverrideDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideDataFlow.swift; sourceTree = "<group>"; };
 		DDD163152C4C690300CD525A /* OverrideDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideDataFlow.swift; sourceTree = "<group>"; };
@@ -1469,6 +1481,8 @@
 		3811DE0325C9D31700A708ED /* Modules */ = {
 		3811DE0325C9D31700A708ED /* Modules */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
+				DD9ECB6B2CA99FA400AA7C45 /* RemoteControlConfig */,
+				DD9ECB662CA99EFE00AA7C45 /* RemoteControl */,
 				DD1745382C55BF8B00211FAC /* AlgorithmAdvancedSettings */,
 				DD1745382C55BF8B00211FAC /* AlgorithmAdvancedSettings */,
 				DD1745422C55C5C400211FAC /* AutosensSettings */,
 				DD1745422C55C5C400211FAC /* AutosensSettings */,
 				672F63EEAE27400625E14BAD /* AutotuneConfig */,
 				672F63EEAE27400625E14BAD /* AutotuneConfig */,
@@ -2695,6 +2709,34 @@
 			path = ProfileImport;
 			path = ProfileImport;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
 		};
 		};
+		DD9ECB662CA99EFE00AA7C45 /* RemoteControl */ = {
+			isa = PBXGroup;
+			children = (
+				DD9ECB672CA99F4500AA7C45 /* TrioRemoteControl.swift */,
+				DD9ECB692CA99F6C00AA7C45 /* PushMessage.swift */,
+			);
+			path = RemoteControl;
+			sourceTree = "<group>";
+		};
+		DD9ECB6B2CA99FA400AA7C45 /* RemoteControlConfig */ = {
+			isa = PBXGroup;
+			children = (
+				DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */,
+				DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */,
+				DD9ECB6D2CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift */,
+				DD9ECB6C2CA99FAE00AA7C45 /* View */,
+			);
+			path = RemoteControlConfig;
+			sourceTree = "<group>";
+		};
+		DD9ECB6C2CA99FAE00AA7C45 /* View */ = {
+			isa = PBXGroup;
+			children = (
+				DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
 		DDD163032C4C67B400CD525A /* OverrideConfig */ = {
 		DDD163032C4C67B400CD525A /* OverrideConfig */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
@@ -3187,6 +3229,7 @@
 				3811DEEB25CA063400A708ED /* PersistedProperty.swift in Sources */,
 				3811DEEB25CA063400A708ED /* PersistedProperty.swift in Sources */,
 				38E44537274E411700EC9A94 /* Disk+Helpers.swift in Sources */,
 				38E44537274E411700EC9A94 /* Disk+Helpers.swift in Sources */,
 				388E5A6025B6F2310019842D /* Autosens.swift in Sources */,
 				388E5A6025B6F2310019842D /* Autosens.swift in Sources */,
+				DD9ECB6A2CA99F6C00AA7C45 /* PushMessage.swift in Sources */,
 				3811DE8F25C9D80400A708ED /* User.swift in Sources */,
 				3811DE8F25C9D80400A708ED /* User.swift in Sources */,
 				19D466A329AA2B80004D5F33 /* MealSettingsDataFlow.swift in Sources */,
 				19D466A329AA2B80004D5F33 /* MealSettingsDataFlow.swift in Sources */,
 				3811DEB225C9D88300A708ED /* KeychainItemAccessibility.swift in Sources */,
 				3811DEB225C9D88300A708ED /* KeychainItemAccessibility.swift in Sources */,
@@ -3221,6 +3264,7 @@
 				DD1745402C55BFC100211FAC /* AlgorithmAdvancedSettingsRootView.swift in Sources */,
 				DD1745402C55BFC100211FAC /* AlgorithmAdvancedSettingsRootView.swift in Sources */,
 				58645BA52CA2D347008AFCE7 /* ForecastSetup.swift in Sources */,
 				58645BA52CA2D347008AFCE7 /* ForecastSetup.swift in Sources */,
 				110AEDEE2C51A0AE00615CC9 /* ShortcutsConfigStateModel.swift in Sources */,
 				110AEDEE2C51A0AE00615CC9 /* ShortcutsConfigStateModel.swift in Sources */,
+				DD9ECB722CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift in Sources */,
 				DD1745262C55526F00211FAC /* SMBSettingsRootView.swift in Sources */,
 				DD1745262C55526F00211FAC /* SMBSettingsRootView.swift in Sources */,
 				3811DE3025C9D49500A708ED /* HomeStateModel.swift in Sources */,
 				3811DE3025C9D49500A708ED /* HomeStateModel.swift in Sources */,
 				38BF021725E7CBBC00579895 /* PumpManagerExtensions.swift in Sources */,
 				38BF021725E7CBBC00579895 /* PumpManagerExtensions.swift in Sources */,
@@ -3278,6 +3322,7 @@
 				3811DF0225CA9FEA00A708ED /* Credentials.swift in Sources */,
 				3811DF0225CA9FEA00A708ED /* Credentials.swift in Sources */,
 				5837A5302BD2E3C700A5DC04 /* CarbEntryStored+helper.swift in Sources */,
 				5837A5302BD2E3C700A5DC04 /* CarbEntryStored+helper.swift in Sources */,
 				389A572026079BAA00BC102F /* Interpolation.swift in Sources */,
 				389A572026079BAA00BC102F /* Interpolation.swift in Sources */,
+				DD9ECB702CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift in Sources */,
 				19A910382A24EF3200C8951B /* ChartsView.swift in Sources */,
 				19A910382A24EF3200C8951B /* ChartsView.swift in Sources */,
 				BDF34F832C10C5B600D51995 /* DataManager.swift in Sources */,
 				BDF34F832C10C5B600D51995 /* DataManager.swift in Sources */,
 				38B4F3C625E5017E00E76A18 /* NotificationCenter.swift in Sources */,
 				38B4F3C625E5017E00E76A18 /* NotificationCenter.swift in Sources */,
@@ -3466,6 +3511,7 @@
 				CE7CA3512A064973004BE681 /* ApplyTempPresetIntent.swift in Sources */,
 				CE7CA3512A064973004BE681 /* ApplyTempPresetIntent.swift in Sources */,
 				FA630397F76B582C8D8681A7 /* BasalProfileEditorProvider.swift in Sources */,
 				FA630397F76B582C8D8681A7 /* BasalProfileEditorProvider.swift in Sources */,
 				DD1745172C54389F00211FAC /* FeatureSettingsView.swift in Sources */,
 				DD1745172C54389F00211FAC /* FeatureSettingsView.swift in Sources */,
+				DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */,
 				63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */,
 				63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */,
 				38FEF3FA2737E42000574A46 /* BaseStateModel.swift in Sources */,
 				38FEF3FA2737E42000574A46 /* BaseStateModel.swift in Sources */,
 				CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */,
 				CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */,
@@ -3539,6 +3585,7 @@
 				23888883D4EA091C88480FF2 /* BolusProvider.swift in Sources */,
 				23888883D4EA091C88480FF2 /* BolusProvider.swift in Sources */,
 				38E98A2D25F52DC400C0CED0 /* NSLocking+Extensions.swift in Sources */,
 				38E98A2D25F52DC400C0CED0 /* NSLocking+Extensions.swift in Sources */,
 				BDBAACFA2C2D439700370AAE /* OverrideData.swift in Sources */,
 				BDBAACFA2C2D439700370AAE /* OverrideData.swift in Sources */,
+				DD9ECB682CA99F4500AA7C45 /* TrioRemoteControl.swift in Sources */,
 				38569353270B5E350002C50D /* CGMRootView.swift in Sources */,
 				38569353270B5E350002C50D /* CGMRootView.swift in Sources */,
 				69A31254F2451C20361D172F /* BolusStateModel.swift in Sources */,
 				69A31254F2451C20361D172F /* BolusStateModel.swift in Sources */,
 				1967DFC029D053AC00759F30 /* IconSelection.swift in Sources */,
 				1967DFC029D053AC00759F30 /* IconSelection.swift in Sources */,
@@ -3626,6 +3673,7 @@
 				DDE1796E2C910127003CDDB7 /* OrefDetermination+CoreDataClass.swift in Sources */,
 				DDE1796E2C910127003CDDB7 /* OrefDetermination+CoreDataClass.swift in Sources */,
 				DDE1796F2C910127003CDDB7 /* OrefDetermination+CoreDataProperties.swift in Sources */,
 				DDE1796F2C910127003CDDB7 /* OrefDetermination+CoreDataProperties.swift in Sources */,
 				DDE179702C910127003CDDB7 /* OverrideStored+CoreDataClass.swift in Sources */,
 				DDE179702C910127003CDDB7 /* OverrideStored+CoreDataClass.swift in Sources */,
+				DD9ECB742CA9A0C300AA7C45 /* RemoteControlConfig.swift in Sources */,
 				DDE179712C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift in Sources */,
 				DDE179712C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift in Sources */,
 				CD78BB94E43B249D60CC1A1B /* GlucoseNotificationSettingsRootView.swift in Sources */,
 				CD78BB94E43B249D60CC1A1B /* GlucoseNotificationSettingsRootView.swift in Sources */,
 				CE7CA3502A064973004BE681 /* CancelTempPresetIntent.swift in Sources */,
 				CE7CA3502A064973004BE681 /* CancelTempPresetIntent.swift in Sources */,

+ 2 - 0
FreeAPS/Resources/FreeAPS.entitlements

@@ -2,6 +2,8 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <plist version="1.0">
 <dict>
 <dict>
+	<key>aps-environment</key>
+	<string>development</string>
 	<key>com.apple.developer.healthkit</key>
 	<key>com.apple.developer.healthkit</key>
 	<true/>
 	<true/>
 	<key>com.apple.developer.healthkit.access</key>
 	<key>com.apple.developer.healthkit.access</key>

+ 1 - 0
FreeAPS/Sources/APS/OpenAPS/Constants.swift

@@ -87,6 +87,7 @@ extension OpenAPS {
         static let uploadedPreferences = "upload/uploaded-preferences.json"
         static let uploadedPreferences = "upload/uploaded-preferences.json"
         static let uploadedSettings = "upload/uploaded-settings.json"
         static let uploadedSettings = "upload/uploaded-settings.json"
         static let uploadedManualGlucose = "upload/uploaded-manual-readings.json"
         static let uploadedManualGlucose = "upload/uploaded-manual-readings.json"
+        static let uploadedNotes = "upload/uploaded-notes.json"
     }
     }
 
 
     enum FreeAPS {
     enum FreeAPS {

+ 43 - 1
FreeAPS/Sources/Application/AppDelegate.swift

@@ -1,4 +1,46 @@
 import SwiftUI
 import SwiftUI
 import UIKit
 import UIKit
+import UserNotifications
 
 
-class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject {}
+class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNotificationCenterDelegate {
+    func application(
+        _ application: UIApplication,
+        didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?
+    ) -> Bool {
+        UNUserNotificationCenter.current().delegate = self
+        application.registerForRemoteNotifications()
+        return true
+    }
+
+    func application(
+        _: UIApplication,
+        didReceiveRemoteNotification userInfo: [AnyHashable: Any],
+        fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
+    ) {
+        debug(.remoteControl, "Received notification")
+
+        Task {
+            await TrioRemoteControl.shared.handleRemoteNotification(userInfo: userInfo)
+            completionHandler(.newData)
+        }
+    }
+
+    func application(
+        _: UIApplication,
+        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
+    ) {
+        let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) }
+        let token = tokenParts.joined()
+
+        Task {
+            await TrioRemoteControl.shared.handleAPNSChanges(deviceToken: token)
+        }
+    }
+
+    func application(
+        _: UIApplication,
+        didFailToRegisterForRemoteNotificationsWithError error: Error
+    ) {
+        debug(.remoteControl, "Failed to register for remote notifications: \(error.localizedDescription)")
+    }
+}

+ 4 - 0
FreeAPS/Sources/Logger/Logger.swift

@@ -112,6 +112,7 @@ final class Logger {
     static let deviceManager = Logger(category: .deviceManager, reporter: baseReporter)
     static let deviceManager = Logger(category: .deviceManager, reporter: baseReporter)
     static let apsManager = Logger(category: .apsManager, reporter: baseReporter)
     static let apsManager = Logger(category: .apsManager, reporter: baseReporter)
     static let nightscout = Logger(category: .nightscout, reporter: baseReporter)
     static let nightscout = Logger(category: .nightscout, reporter: baseReporter)
+    static let remoteControl = Logger(category: .remoteControl, reporter: baseReporter)
 
 
     enum Category: String {
     enum Category: String {
         case `default`
         case `default`
@@ -121,6 +122,7 @@ final class Logger {
         case deviceManager
         case deviceManager
         case apsManager
         case apsManager
         case nightscout
         case nightscout
+        case remoteControl
 
 
         var name: String {
         var name: String {
             rawValue.capitalizingFirstLetter()
             rawValue.capitalizingFirstLetter()
@@ -135,6 +137,7 @@ final class Logger {
             case .deviceManager: return .deviceManager
             case .deviceManager: return .deviceManager
             case .apsManager: return .apsManager
             case .apsManager: return .apsManager
             case .nightscout: return .nightscout
             case .nightscout: return .nightscout
+            case .remoteControl: return .remoteControl
             }
             }
         }
         }
 
 
@@ -147,6 +150,7 @@ final class Logger {
                  .deviceManager,
                  .deviceManager,
                  .nightscout,
                  .nightscout,
                  .openAPS,
                  .openAPS,
+                 .remoteControl,
                  .service:
                  .service:
                 return OSLog(subsystem: subsystem, category: name)
                 return OSLog(subsystem: subsystem, category: name)
             }
             }

+ 3 - 0
FreeAPS/Sources/Models/NightscoutStatus.swift

@@ -52,4 +52,7 @@ struct NightscoutProfileStore: JSON {
     let units: String
     let units: String
     let enteredBy: String
     let enteredBy: String
     let store: [String: ScheduledNightscoutProfile]
     let store: [String: ScheduledNightscoutProfile]
+    let bundleIdentifier: String
+    let deviceToken: String
+    let isAPNSProduction: Bool
 }
 }

+ 1 - 0
FreeAPS/Sources/Models/PumpHistoryEvent.swift

@@ -77,6 +77,7 @@ enum EventType: String, JSON {
     case nsAnnouncement = "Announcement"
     case nsAnnouncement = "Announcement"
     case nsSensorChange = "Sensor Start"
     case nsSensorChange = "Sensor Start"
     case capillaryGlucose = "BG Check"
     case capillaryGlucose = "BG Check"
+    case note = "Note"
 }
 }
 
 
 enum TempType: String, JSON {
 enum TempType: String, JSON {

+ 55 - 0
FreeAPS/Sources/Modules/RemoteControl/PushMessage.swift

@@ -0,0 +1,55 @@
+import Foundation
+
+struct PushMessage: Decodable {
+    var user: String
+    var commandType: String
+    var bolusAmount: Decimal?
+    var target: Int?
+    var duration: Int?
+    var carbs: Int?
+    var protein: Int?
+    var fat: Int?
+    var sharedSecret: String
+    var timestamp: TimeInterval
+
+    enum CodingKeys: String, CodingKey {
+        case user
+        case commandType = "command_type"
+        case bolusAmount = "bolus_amount"
+        case target
+        case duration
+        case carbs
+        case protein
+        case fat
+        case sharedSecret = "shared_secret"
+        case timestamp
+    }
+}
+
+extension PushMessage {
+    func humanReadableDescription() -> String {
+        var description = "User: \(user). Command Type: \(commandType). "
+        switch commandType {
+        case "bolus":
+            if let amount = bolusAmount {
+                description += "Bolus Amount: \(amount) units."
+            } else {
+                description += "Bolus Amount: unknown."
+            }
+        case "temp_target":
+            let targetDescription = target != nil ? "\(target!) mg/dL" : "unknown target"
+            let durationDescription = duration != nil ? "\(duration!) minutes" : "unknown duration"
+            description += "Temp Target: \(targetDescription), Duration: \(durationDescription)."
+        case "cancel_temp_target":
+            description += "Cancel Temp Target command."
+        case "meal":
+            let carbsDescription = carbs != nil ? "\(carbs!)g carbs" : "unknown carbs"
+            let fatDescription = fat != nil ? "\(fat!)g fat" : "unknown fat"
+            let proteinDescription = protein != nil ? "\(protein!)g protein" : "unknown protein"
+            description += "Meal with \(carbsDescription), \(fatDescription), \(proteinDescription)."
+        default:
+            description += "Unsupported command type."
+        }
+        return description
+    }
+}

+ 274 - 0
FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl.swift

@@ -0,0 +1,274 @@
+import Foundation
+import Swinject
+
+class TrioRemoteControl: Injectable {
+    static let shared = TrioRemoteControl()
+
+    @Injected() private var tempTargetsStorage: TempTargetsStorage!
+    @Injected() private var carbsStorage: CarbsStorage!
+    @Injected() private var nightscoutManager: NightscoutManager!
+    @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
+
+    private let timeWindow: TimeInterval = 600 // Defines how old messages that are accepted, 10 minutes
+
+    private init() {
+        injectServices(FreeAPSApp.resolver)
+    }
+
+    private func logError(_ errorMessage: String, pushMessage: PushMessage? = nil) async {
+        var note = errorMessage
+        if let pushMessage = pushMessage {
+            note += " Details: \(pushMessage.humanReadableDescription())"
+        }
+        debug(.remoteControl, note)
+        await nightscoutManager.uploadNoteTreatment(note: note)
+    }
+
+    func handleRemoteNotification(userInfo: [AnyHashable: Any]) async {
+        let enabled = UserDefaults.standard.bool(forKey: "TRCenabled")
+        guard enabled else {
+            await logError("Remote command received, but remote control is disabled in settings. Ignoring the command.")
+            return
+        }
+
+        do {
+            let jsonData = try JSONSerialization.data(withJSONObject: userInfo)
+            let pushMessage = try JSONDecoder().decode(PushMessage.self, from: jsonData)
+            let currentTime = Date().timeIntervalSince1970
+            let timeDifference = currentTime - pushMessage.timestamp
+
+            if timeDifference > timeWindow {
+                await logError(
+                    "Command rejected: the message is too old (sent \(Int(timeDifference)) seconds ago, which exceeds the allowed limit).",
+                    pushMessage: pushMessage
+                )
+                return
+            } else if timeDifference < -timeWindow {
+                await logError(
+                    "Command rejected: the message has an invalid future timestamp (timestamp is \(Int(-timeDifference)) seconds ahead of the current time).",
+                    pushMessage: pushMessage
+                )
+                return
+            }
+
+            debug(.remoteControl, "Command received with acceptable time difference: \(Int(timeDifference)) seconds.")
+
+            let storedSecret = UserDefaults.standard.string(forKey: "TRCsharedSecret") ?? ""
+            guard !storedSecret.isEmpty else {
+                await logError(
+                    "Command rejected: shared secret is missing in settings. Cannot authenticate the command.",
+                    pushMessage: pushMessage
+                )
+                return
+            }
+
+            guard pushMessage.sharedSecret == storedSecret else {
+                await logError(
+                    "Command rejected: shared secret does not match. Cannot authenticate the command.",
+                    pushMessage: pushMessage
+                )
+                return
+            }
+
+            switch pushMessage.commandType {
+            case "bolus":
+                await handleBolusCommand(pushMessage)
+            case "temp_target":
+                await handleTempTargetCommand(pushMessage)
+            case "cancel_temp_target":
+                await cancelTempTarget()
+            case "meal":
+                await handleMealCommand(pushMessage)
+            default:
+                await logError(
+                    "Command rejected: unsupported command type '\(pushMessage.commandType)'.",
+                    pushMessage: pushMessage
+                )
+            }
+
+        } catch {
+            await logError("Error: unable to process the command due to decoding failure (\(error.localizedDescription)).")
+        }
+    }
+
+    private func handleMealCommand(_ pushMessage: PushMessage) async {
+        guard
+            let carbs = pushMessage.carbs,
+            let fat = pushMessage.fat,
+            let protein = pushMessage.protein
+        else {
+            await logError("Command rejected: meal data is incomplete or invalid.", pushMessage: pushMessage)
+            return
+        }
+
+        let settings = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.settings
+        let maxCarbs = settings?.maxCarbs ?? Decimal(0)
+        let maxFat = settings?.maxFat ?? Decimal(0)
+        let maxProtein = settings?.maxProtein ?? Decimal(0)
+
+        if Decimal(carbs) > maxCarbs {
+            await logError(
+                "Command rejected: carbs amount (\(carbs)g) exceeds the maximum allowed (\(maxCarbs)g).",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        if Decimal(fat) > maxFat {
+            await logError(
+                "Command rejected: fat amount (\(fat)g) exceeds the maximum allowed (\(maxFat)g).",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        if Decimal(protein) > maxProtein {
+            await logError(
+                "Command rejected: protein amount (\(protein)g) exceeds the maximum allowed (\(maxProtein)g).",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
+        let recentCarbEntries = carbsStorage.recent()
+        let carbsAfterPushMessage = recentCarbEntries.filter { $0.createdAt > pushMessageDate }
+
+        if !carbsAfterPushMessage.isEmpty {
+            await logError(
+                "Command rejected: newer carb entries have been logged since the command was sent.",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        let mealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: Date(),
+            actualDate: nil,
+            carbs: Decimal(carbs),
+            fat: Decimal(fat),
+            protein: Decimal(protein),
+            note: "Remote meal command",
+            enteredBy: CarbsEntry.manual,
+            isFPU: false,
+            fpuID: nil
+        )
+
+        await carbsStorage.storeCarbs([mealEntry], areFetchedFromRemote: false)
+        debug(.remoteControl, "Meal command processed successfully with carbs: \(carbs)g, fat: \(fat)g, protein: \(protein)g.")
+    }
+
+    private func handleBolusCommand(_ pushMessage: PushMessage) async {
+        guard let bolusAmount = pushMessage.bolusAmount else {
+            await logError("Command rejected: bolus amount is missing or invalid.", pushMessage: pushMessage)
+            return
+        }
+
+        let maxBolus = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.pumpSettings.maxBolus ?? Decimal(0)
+
+        if bolusAmount > maxBolus {
+            await logError(
+                "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units).",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        let recentPumpEvents = pumpHistoryStorage.recent()
+        let recentBoluses = recentPumpEvents.filter { event in
+            event.type == .bolus && event.timestamp > Date(timeIntervalSince1970: pushMessage.timestamp)
+        }
+
+        let totalRecentBolusAmount = recentBoluses.reduce(Decimal(0)) { $0 + ($1.amount ?? 0) }
+
+        if totalRecentBolusAmount >= bolusAmount * 0.2 {
+            await logError(
+                "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent.",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        debug(.remoteControl, "Enacting bolus command with amount: \(bolusAmount) units.")
+
+        guard let apsManager = await FreeAPSApp.resolver.resolve(APSManager.self) else {
+            await logError(
+                "Error: unable to process bolus command because the APS Manager is not available.",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        await apsManager.enactBolus(amount: Double(truncating: bolusAmount as NSNumber), isSMB: false)
+    }
+
+    private func handleTempTargetCommand(_ pushMessage: PushMessage) async {
+        guard let targetValue = pushMessage.target,
+              let durationValue = pushMessage.duration
+        else {
+            await logError("Command rejected: temp target data is incomplete or invalid.", pushMessage: pushMessage)
+            return
+        }
+
+        let durationInMinutes = Int(durationValue)
+        let tempTarget = TempTarget(
+            name: "Remote Control",
+            createdAt: Date(),
+            targetTop: Decimal(targetValue),
+            targetBottom: Decimal(targetValue),
+            duration: Decimal(durationInMinutes),
+            enteredBy: pushMessage.user,
+            reason: "Remote temp target command"
+        )
+
+        tempTargetsStorage.storeTempTargets([tempTarget])
+        debug(.remoteControl, "Temp target set with target: \(targetValue), duration: \(durationInMinutes) minutes.")
+    }
+
+    func cancelTempTarget() async {
+        debug(.remoteControl, "Cancelling temp target.")
+
+        guard tempTargetsStorage.current() != nil else {
+            await logError("Command rejected: no active temp target to cancel.")
+            return
+        }
+
+        let cancelEntry = TempTarget.cancel(at: Date())
+        tempTargetsStorage.storeTempTargets([cancelEntry])
+        debug(.remoteControl, "Temp target cancelled successfully.")
+    }
+
+    func handleAPNSChanges(deviceToken: String?) async {
+        let previousDeviceToken = UserDefaults.standard.string(forKey: "deviceToken")
+        let previousIsAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
+
+        let isAPNSProduction = isRunningInAPNSProductionEnvironment()
+        var shouldUploadProfiles = false
+
+        if let token = deviceToken, token != previousDeviceToken {
+            UserDefaults.standard.set(token, forKey: "deviceToken")
+            debug(.remoteControl, "Device token updated: \(token)")
+            shouldUploadProfiles = true
+        }
+
+        if previousIsAPNSProduction != isAPNSProduction {
+            UserDefaults.standard.set(isAPNSProduction, forKey: "isAPNSProduction")
+            debug(.remoteControl, "APNS environment changed to: \(isAPNSProduction ? "Production" : "Sandbox")")
+            shouldUploadProfiles = true
+        }
+
+        if shouldUploadProfiles {
+            await nightscoutManager.uploadProfiles()
+        } else {
+            debug(.remoteControl, "No changes detected in device token or APNS environment.")
+        }
+    }
+
+    private func isRunningInAPNSProductionEnvironment() -> Bool {
+        if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL {
+            return appStoreReceiptURL.lastPathComponent != "sandboxReceipt"
+        }
+        return false
+    }
+}

+ 6 - 0
FreeAPS/Sources/Modules/RemoteControlConfig/RemoteControlConfigDataFlow.swift

@@ -0,0 +1,6 @@
+import Foundation
+enum RemoteControlConfig {
+    enum Config {}
+}
+
+protocol RemoteControlConfigProvider {}

+ 4 - 0
FreeAPS/Sources/Modules/RemoteControlConfig/RemoteControlConfigProvider.swift

@@ -0,0 +1,4 @@
+import Foundation
+extension RemoteControlConfig {
+    final class Provider: BaseProvider, RemoteControlConfigProvider {}
+}

+ 47 - 0
FreeAPS/Sources/Modules/RemoteControlConfig/RemoteControlConfigStateModel.swift

@@ -0,0 +1,47 @@
+import SwiftUI
+
+extension RemoteControlConfig {
+    final class StateModel: BaseStateModel<Provider> {
+        @Published var units: GlucoseUnits = .mgdL
+        @Published var isTRCEnabled: Bool = false
+        @Published var sharedSecret: String = ""
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+            isTRCEnabled = UserDefaults.standard.bool(forKey: "TRCenabled")
+            sharedSecret = UserDefaults.standard.string(forKey: "TRCsharedSecret") ?? generateInitialSharedSecret()
+
+            $isTRCEnabled
+                .receive(on: DispatchQueue.main)
+                .sink { value in
+                    UserDefaults.standard.set(value, forKey: "TRCenabled")
+                }
+                .store(in: &lifetime)
+
+            $sharedSecret
+                .receive(on: DispatchQueue.main)
+                .sink { value in
+                    UserDefaults.standard.set(value, forKey: "TRCsharedSecret")
+                }
+                .store(in: &lifetime)
+        }
+
+        func generateNewSharedSecret() {
+            let newSecret = UUID().uuidString.replacingOccurrences(of: "-", with: "")
+            sharedSecret = newSecret
+            UserDefaults.standard.set(newSecret, forKey: "TRCsharedSecret")
+        }
+
+        private func generateInitialSharedSecret() -> String {
+            let secret = UUID().uuidString.replacingOccurrences(of: "-", with: "")
+            UserDefaults.standard.set(secret, forKey: "TRCsharedSecret")
+            return secret
+        }
+    }
+}
+
+extension RemoteControlConfig.StateModel: SettingsObserver {
+    func settingsDidChange(_: FreeAPSSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 112 - 0
FreeAPS/Sources/Modules/RemoteControlConfig/View/RemoteControlConfig.swift

@@ -0,0 +1,112 @@
+import Combine
+import SwiftUI
+import Swinject
+import UIKit
+
+extension RemoteControlConfig {
+    struct RootView: BaseView {
+        let resolver: Resolver
+
+        @StateObject var state = StateModel()
+
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: String?
+        @State var hintLabel: String?
+        @State private var decimalPlaceholder: Decimal = 0.0
+        @State private var isCopied: Bool = false
+
+        @Environment(\.colorScheme) var colorScheme
+
+        private var color: LinearGradient {
+            colorScheme == .dark ? LinearGradient(
+                gradient: Gradient(colors: [
+                    Color.bgDarkBlue,
+                    Color.bgDarkerDarkBlue
+                ]),
+                startPoint: .top,
+                endPoint: .bottom
+            )
+                :
+                LinearGradient(
+                    gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
+                    startPoint: .top,
+                    endPoint: .bottom
+                )
+        }
+
+        var body: some View {
+            Form {
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.isTRCEnabled,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = "Enable Remote Command"
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: "Enable Remote Command",
+                    miniHint: "Remote commands allow Trio to receive instructions, such as boluses and temp targets, from LoopFollow.",
+                    verboseHint: "When Remote Commands are enabled, you can send boluses, temporary targets, carbs, and other commands to Trio via push notifications. To ensure security, these commands are protected by a shared secret, which must be entered in LoopFollow."
+                )
+
+                Section(
+                    header: Text("Shared Secret"),
+                    content: {
+                        TextField("Enter Shared Secret", text: $state.sharedSecret)
+                            .disableAutocorrection(true)
+                            .autocapitalization(.none)
+                            .padding(8)
+                            .background(Color(UIColor.systemGray6))
+                            .cornerRadius(8)
+
+                        Button(action: {
+                            UIPasteboard.general.string = state.sharedSecret
+                            isCopied = true
+                        }) {
+                            Label("Copy Secret", systemImage: "doc.on.doc")
+                                .frame(maxWidth: .infinity)
+                        }
+                        .buttonStyle(.bordered)
+                        .frame(maxWidth: .infinity)
+                        .alert(isPresented: $isCopied) {
+                            Alert(
+                                title: Text("Copied"),
+                                message: Text("Shared Secret copied to clipboard"),
+                                dismissButton: .default(Text("OK"))
+                            )
+                        }
+
+                        Button(action: {
+                            state.generateNewSharedSecret()
+                        }) {
+                            Label("Generate Secret", systemImage: "arrow.clockwise")
+                                .frame(maxWidth: .infinity)
+                        }
+                        .buttonStyle(.borderedProminent)
+                        .foregroundColor(.white)
+                        .frame(maxWidth: .infinity)
+                    }
+                ).listRowBackground(Color.chart)
+            }
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? "",
+                    sheetTitle: "Help"
+                )
+            }
+            .scrollContentBackground(.hidden).background(color)
+            .onAppear(perform: configureView)
+            .navigationTitle("Remote Control")
+            .navigationBarTitleDisplayMode(.automatic)
+        }
+    }
+}

+ 6 - 0
FreeAPS/Sources/Modules/Settings/SettingItems.swift

@@ -175,6 +175,12 @@ enum SettingItems {
             path: ["Features", "Shortcuts"]
             path: ["Features", "Shortcuts"]
         ),
         ),
         SettingItem(
         SettingItem(
+            title: "Remote Control",
+            view: .remoteControlConfig,
+            searchContents: ["Remote Control"],
+            path: ["Features", "Remote Control"]
+        ),
+        SettingItem(
             title: "User Interface",
             title: "User Interface",
             view: .userInterfaceSettings,
             view: .userInterfaceSettings,
             searchContents: [
             searchContents: [

+ 1 - 0
FreeAPS/Sources/Modules/Settings/View/Subviews/FeatureSettingsView.swift

@@ -39,6 +39,7 @@ struct FeatureSettingsView: BaseView {
                     Text("Bolus Calculator").navigationLink(to: .bolusCalculatorConfig, from: self)
                     Text("Bolus Calculator").navigationLink(to: .bolusCalculatorConfig, from: self)
                     Text("Meal Settings").navigationLink(to: .mealSettings, from: self)
                     Text("Meal Settings").navigationLink(to: .mealSettings, from: self)
                     Text("Shortcuts").navigationLink(to: .shortcutsConfig, from: self)
                     Text("Shortcuts").navigationLink(to: .shortcutsConfig, from: self)
+                    Text("Remote Control").navigationLink(to: .remoteControlConfig, from: self)
                 }
                 }
             )
             )
             .listRowBackground(Color.chart)
             .listRowBackground(Color.chart)

+ 3 - 0
FreeAPS/Sources/Router/Screen.swift

@@ -42,6 +42,7 @@ enum Screen: Identifiable, Hashable {
     case liveActivitySettings
     case liveActivitySettings
     case calendarEventSettings
     case calendarEventSettings
     case serviceSettings
     case serviceSettings
+    case remoteControlConfig
     case autosensSettings
     case autosensSettings
     case smbSettings
     case smbSettings
     case targetBehavior
     case targetBehavior
@@ -118,6 +119,8 @@ extension Screen {
             Calibrations.RootView(resolver: resolver)
             Calibrations.RootView(resolver: resolver)
         case .shortcutsConfig:
         case .shortcutsConfig:
             ShortcutsConfig.RootView(resolver: resolver)
             ShortcutsConfig.RootView(resolver: resolver)
+        case .remoteControlConfig:
+            RemoteControlConfig.RootView(resolver: resolver)
         case .devices:
         case .devices:
             DevicesView(resolver: resolver, state: Settings.StateModel())
             DevicesView(resolver: resolver, state: Settings.StateModel())
         case .therapySettings:
         case .therapySettings:

+ 27 - 1
FreeAPS/Sources/Services/Network/NightscoutManager.swift

@@ -19,6 +19,7 @@ protocol NightscoutManager: GlucoseSource {
     func uploadProfiles() async
     func uploadProfiles() async
     func importSettings() async -> ScheduledNightscoutProfile?
     func importSettings() async -> ScheduledNightscoutProfile?
     var cgmURL: URL? { get }
     var cgmURL: URL? { get }
+    func uploadNoteTreatment(note: String) async
 }
 }
 
 
 final class BaseNightscoutManager: NightscoutManager, Injectable {
 final class BaseNightscoutManager: NightscoutManager, Injectable {
@@ -619,13 +620,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
                 let defaultProfile = "default"
                 let defaultProfile = "default"
 
 
                 let now = Date()
                 let now = Date()
+
+                let bundleIdentifier = Bundle.main.bundleIdentifier ?? ""
+                let deviceToken = UserDefaults.standard.string(forKey: "deviceToken") ?? ""
+                let isAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
+
                 let profileStore = NightscoutProfileStore(
                 let profileStore = NightscoutProfileStore(
                     defaultProfile: defaultProfile,
                     defaultProfile: defaultProfile,
                     startDate: now,
                     startDate: now,
                     mills: Int(now.timeIntervalSince1970) * 1000,
                     mills: Int(now.timeIntervalSince1970) * 1000,
                     units: nsUnits,
                     units: nsUnits,
                     enteredBy: NightscoutTreatment.local,
                     enteredBy: NightscoutTreatment.local,
-                    store: [defaultProfile: scheduledProfile]
+                    store: [defaultProfile: scheduledProfile],
+                    bundleIdentifier: bundleIdentifier,
+                    deviceToken: deviceToken,
+                    isAPNSProduction: isAPNSProduction
                 )
                 )
 
 
                 guard let nightscout = nightscoutAPI, isNetworkReachable else {
                 guard let nightscout = nightscoutAPI, isNetworkReachable else {
@@ -944,6 +953,23 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
             }
             }
         }
         }
     }
     }
+
+    func uploadNoteTreatment(note: String) async {
+        let uploadedNotes = storage.retrieve(OpenAPS.Nightscout.uploadedNotes, as: [NightscoutTreatment].self) ?? []
+        let now = Date()
+
+        if uploadedNotes.last?.notes != note || (uploadedNotes.last?.createdAt ?? .distantPast) != now {
+            let noteTreatment = NightscoutTreatment(
+                eventType: .nsNote,
+                createdAt: now,
+                enteredBy: NightscoutTreatment.local,
+                notes: note,
+                targetTop: nil,
+                targetBottom: nil
+            )
+            await uploadTreatments([noteTreatment], fileToSave: OpenAPS.Nightscout.uploadedNotes)
+        }
+    }
 }
 }
 
 
 extension Array {
 extension Array {