diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index ad8b18333..2871a59de 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -58,6 +58,8 @@ DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; }; DD2C2E542D3C37DC006413A5 /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */; }; DD2C2E562D3C3917006413A5 /* DexcomSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */; }; + DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; }; + DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DD485F152E46631000CE8CBF /* CryptoSwift */; }; DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; }; DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; }; DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; }; @@ -449,6 +451,7 @@ DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = ""; }; DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = ""; }; DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = ""; }; + DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = ""; }; DD4878022C7B297E0048F05C /* StorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageValue.swift; sourceTree = ""; }; DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = ""; }; @@ -801,6 +804,7 @@ buildActionMask = 2147483647; files = ( FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, + DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */, 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */, ); @@ -935,6 +939,7 @@ DD4878112C7B74F90048F05C /* TRC */ = { isa = PBXGroup; children = ( + DD485F132E454B2600CE8CBF /* SecureMessenger.swift */, DD48781F2C7DAF890048F05C /* PushMessage.swift */, DD4878122C7B750D0048F05C /* TempTargetView.swift */, DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */, @@ -1590,6 +1595,7 @@ name = LoopFollow; packageProductDependencies = ( DD48781B2C7DAF140048F05C /* SwiftJWT */, + DD485F152E46631000CE8CBF /* CryptoSwift */, ); productName = LoopFollow; productReference = FC9788142485969B00A7906C /* Loop Follow.app */; @@ -1626,6 +1632,7 @@ packageReferences = ( DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */, 654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */, + DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */, ); productRefGroup = FC9788152485969B00A7906C /* Products */; projectDirPath = ""; @@ -2120,6 +2127,7 @@ FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */, DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */, DDEF50402D479B8A00884336 /* LoopAPNSService.swift in Sources */, + DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */, DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */, DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */, DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */, @@ -2416,6 +2424,14 @@ minimumVersion = 3.12.3; }; }; + DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/krzyzanowskim/CryptoSwift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.9.0; + }; + }; DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Kitura/Swift-JWT.git"; @@ -2427,6 +2443,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + DD485F152E46631000CE8CBF /* CryptoSwift */ = { + isa = XCSwiftPackageProductDependency; + productName = CryptoSwift; + }; DD48781B2C7DAF140048F05C /* SwiftJWT */ = { isa = XCSwiftPackageProductDependency; package = DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */; diff --git a/LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved index 184f6cfe4..b9c71e85e 100644 --- a/LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "7da8de315eadc567d44eb98fc0ead2ee437ac62830b8c28200a84636eb14e2f3", + "originHash" : "94a024be279d128a7e82f3c76785db1e4cf7c9380d0c4aa59dfdf54952403b8d", "pins" : [ { "identity" : "bluecryptor", @@ -28,6 +28,15 @@ "version" : "1.0.201" } }, + { + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", + "state" : { + "revision" : "e45a26384239e028ec87fbcc788f513b67e10d8f", + "version" : "1.9.0" + } + }, { "identity" : "kituracontracts", "kind" : "remoteSourceControl", diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index a459c2f25..f3cc4c91c 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -38,15 +38,65 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UNUserNotificationCenter.current().delegate = self _ = BLEManager.shared - // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications _ = VolumeButtonHandler.shared + // Register for remote notifications + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } return true } func applicationWillTerminate(_: UIApplication) {} + // MARK: - Remote Notifications + + // Called when successfully registered for remote notifications + func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + + Observable.shared.loopFollowDeviceToken.value = tokenString + + LogManager.shared.log(category: .general, message: "Successfully registered for remote notifications with token: \(tokenString)") + } + + // Called when failed to register for remote notifications + func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + LogManager.shared.log(category: .general, message: "Failed to register for remote notifications: \(error.localizedDescription)") + } + + // Called when a remote notification is received + func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + LogManager.shared.log(category: .general, message: "Received remote notification: \(userInfo)") + + // Check if this is a notification from Trio with status update + if let aps = userInfo["aps"] as? [String: Any] { + // Handle visible notification (alert, sound, badge) + if let alert = aps["alert"] as? [String: Any] { + let title = alert["title"] as? String ?? "" + let body = alert["body"] as? String ?? "" + LogManager.shared.log(category: .general, message: "Notification - Title: \(title), Body: \(body)") + } + + // Handle silent notification (content-available) + if let contentAvailable = aps["content-available"] as? Int, contentAvailable == 1 { + // This is a silent push, nothing implemented but logging for now + + if let commandStatus = userInfo["command_status"] as? String { + LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)") + } + + if let commandType = userInfo["command_type"] as? String { + LogManager.shared.log(category: .general, message: "Command type: \(commandType)") + } + } + } + + // Call completion handler + completionHandler(.newData) + } + // MARK: UISceneSession Lifecycle func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { @@ -142,9 +192,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_: UNUserNotificationCenter, - willPresent _: UNNotification, + willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - completionHandler(.alert) + // Log the notification + let userInfo = notification.request.content.userInfo + LogManager.shared.log(category: .general, message: "Will present notification: \(userInfo)") + + // Show the notification even when app is in foreground + completionHandler([.banner, .sound, .badge]) } } diff --git a/LoopFollow/Helpers/BuildDetails.swift b/LoopFollow/Helpers/BuildDetails.swift index fbb592430..053466dba 100644 --- a/LoopFollow/Helpers/BuildDetails.swift +++ b/LoopFollow/Helpers/BuildDetails.swift @@ -19,6 +19,10 @@ class BuildDetails { dict = parsed } + var teamID: String? { + dict["com-LoopFollow-development-team"] as? String + } + var buildDateString: String? { return dict["com-LoopFollow-build-date"] as? String } diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 15965bf65..e76068f9a 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -53,12 +53,12 @@ Loop Follow would like to access your calendar to update BG readings NSCalendarsUsageDescription Loop Follow would like to access your calendar to save BG readings + NSCameraUsageDescription + Used for scanning QR codes for remote authentication NSContactsUsageDescription This app requires access to contacts to update a contact image with real-time blood glucose information. NSFaceIDUsageDescription This app requires Face ID for secure authentication. - NSCameraUsageDescription - Used for scanning QR codes for remote authentication NSHumanReadableCopyright UIApplicationSceneManifest @@ -85,6 +85,7 @@ audio processing bluetooth-central + remote-notification UIFileSharingEnabled diff --git a/LoopFollow/Loop Follow.entitlements b/LoopFollow/Loop Follow.entitlements index a11ae82ef..ec1156a01 100644 --- a/LoopFollow/Loop Follow.entitlements +++ b/LoopFollow/Loop Follow.entitlements @@ -2,6 +2,10 @@ + aps-environment + development + com.apple.developer.aps-environment + development com.apple.security.app-sandbox com.apple.security.device.bluetooth diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index eae7bcc01..c768497ba 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -47,6 +47,51 @@ class LoopAPNSService { } } + private func createReturnNotificationInfo() -> [String: Any]? { + let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value + guard !loopFollowDeviceToken.isEmpty else { return nil } + + // Get LoopFollow's own Team ID from BuildDetails. + guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else { + LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.") + return nil + } + + // Get the target Loop app's Team ID from storage. + let targetTeamId = storage.teamId.value ?? "" + let teamIdsAreDifferent = loopFollowTeamID != targetTeamId + + let keyIdForReturn: String + let apnsKeyForReturn: String + + if teamIdsAreDifferent { + // Team IDs differ, use the separate return credentials. + keyIdForReturn = storage.returnKeyId.value + apnsKeyForReturn = storage.returnApnsKey.value + } else { + // Team IDs are the same, use the primary credentials. + keyIdForReturn = storage.keyId.value + apnsKeyForReturn = storage.apnsKey.value + } + + // Ensure we have the necessary credentials. + guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.") + return nil + } + + let returnInfo: [String: Any] = [ + "production_environment": BuildDetails.default.isTestFlightBuild(), + "device_token": loopFollowDeviceToken, + "bundle_id": Bundle.main.bundleIdentifier ?? "", + "team_id": loopFollowTeamID, + "key_id": keyIdForReturn, + "apns_key": apnsKeyForReturn, + ] + + return returnInfo + } + /// Validates the Loop APNS setup by checking all required fields /// - Returns: True if setup is valid, false otherwise func validateSetup() -> Bool { @@ -92,7 +137,7 @@ class LoopAPNSService { let carbsAmount = payload.carbsAmount ?? 0.0 let absorptionTime = payload.absorptionTime ?? 3.0 let startTime = payload.consumedDate ?? now - let finalPayload = [ + var finalPayload = [ "carbs-entry": carbsAmount, "absorption-time": absorptionTime, "otp": String(payload.otp), @@ -105,6 +150,16 @@ class LoopAPNSService { "alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", ] as [String: Any] + /* Let's wait with this until we have an encryption solution for LRC + if let returnInfo = createReturnNotificationInfo() { + finalPayload["return_notification"] = returnInfo + } + */ + + // Log the exact carbs amount for debugging precision issues + LogManager.shared.log(category: .apns, message: "Carbs amount - Raw: \(payload.carbsAmount ?? 0.0), Formatted: \(String(format: "%.1f", carbsAmount)), JSON: \(carbsAmount)") + LogManager.shared.log(category: .apns, message: "Absorption time - Raw: \(payload.absorptionTime ?? 3.0), Formatted: \(String(format: "%.1f", absorptionTime)), JSON: \(absorptionTime)") + // Log carbs entry attempt LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", carbsAmount))g, absorption: \(String(format: "%.1f", absorptionTime))h") @@ -142,7 +197,7 @@ class LoopAPNSService { // Create the complete notification payload (matching Nightscout's exact format) // Based on Nightscout's loop.js implementation let bolusAmount = payload.bolusAmount ?? 0.0 - let finalPayload = [ + var finalPayload = [ "bolus-entry": bolusAmount, "otp": String(payload.otp), "remote-address": "LoopFollow", @@ -153,6 +208,15 @@ class LoopAPNSService { "alert": "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", ] as [String: Any] + /* Let's wait with this until we have an encryption solution for LRC + if let returnInfo = createReturnNotificationInfo() { + finalPayload["return_notification"] = returnInfo + } + */ + + // Log the exact bolus amount for debugging precision issues + LogManager.shared.log(category: .apns, message: "Bolus amount - Raw: \(payload.bolusAmount ?? 0.0), Formatted: \(String(format: "%.2f", bolusAmount)), JSON: \(bolusAmount)") + // Log bolus entry attempt LogManager.shared.log(category: .apns, message: "Sending bolus: \(String(format: "%.2f", bolusAmount))U") @@ -587,6 +651,10 @@ class LoopAPNSService { payload["override-duration-minutes"] = Int(duration / 60) } + if let returnInfo = createReturnNotificationInfo() { + payload["return_notification"] = returnInfo + } + // Send the notification using the existing APNS infrastructure sendAPNSNotification( deviceToken: deviceToken, @@ -619,7 +687,7 @@ class LoopAPNSService { let now = Date() let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now - let payload: [String: Any] = [ + var payload: [String: Any] = [ "cancel-temporary-override": "true", "remote-address": "LoopFollow", "entered-by": "LoopFollow", @@ -628,6 +696,10 @@ class LoopAPNSService { "alert": "Cancel Temporary Override", ] + if let returnInfo = createReturnNotificationInfo() { + payload["return_notification"] = returnInfo + } + // Send the notification using the existing APNS infrastructure sendAPNSNotification( deviceToken: deviceToken, diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 6d79106fe..1214dcbcf 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -261,6 +261,29 @@ struct RemoteSettingsView: View { .foregroundColor(.red) } } + + if viewModel.areTeamIdsDifferent { + Section(header: Text("Return Notification Settings"), footer: Text("Because LoopFollow and the target app were built with different Team IDs, you must provide the APNS credentials for LoopFollow below.").font(.caption)) { + HStack { + Text("Return APNS Key ID") + TogglableSecureInput( + placeholder: "Enter Key ID for LoopFollow", + text: $viewModel.returnKeyId, + style: .singleLine + ) + } + + VStack(alignment: .leading) { + Text("Return APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key for LoopFollow", + text: $viewModel.returnApnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + } + } + } } } .alert(isPresented: $showAlert) { diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index d719c7258..37b3ef151 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -21,6 +21,11 @@ class RemoteSettingsViewModel: ObservableObject { @Published var isTrioDevice: Bool = (Storage.shared.device.value == "Trio") @Published var isLoopDevice: Bool = (Storage.shared.device.value == "Loop") + // MARK: - Return Notification Properties + + @Published var returnApnsKey: String + @Published var returnKeyId: String + // MARK: - Loop APNS Setup Properties @Published var loopDeveloperTeamId: String @@ -43,6 +48,35 @@ class RemoteSettingsViewModel: ObservableObject { @Published var shouldPromptForURL: Bool = false @Published var shouldPromptForToken: Bool = false + let loopFollowTeamId: String = BuildDetails.default.teamID ?? "Unknown" + + /// Determines if the target app's Team ID is different from this app's build Team ID. + var areTeamIdsDifferent: Bool { + // Get LoopFollow's own Team ID from the build details. + guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty, loopFollowTeamID != "Unknown" else { + return false + } + + // The property `loopDeveloperTeamId` holds the value from `Storage.shared.teamId` + let targetTeamId = loopDeveloperTeamId + + // Determine if a comparison is needed and perform it. + switch remoteType { + case .loopAPNS, .trc: + // For both Loop and TRC, the target Team ID is in the same storage location. + // If the target ID is empty, there's nothing to compare. + guard !targetTeamId.isEmpty else { + return false + } + // Return true if the IDs are different. + return loopFollowTeamID != targetTeamId + + case .none, .nightscout: + // For other remote types, this check is not applicable. + return false + } + } + // MARK: - Computed property for Loop APNS Setup validation var loopAPNSSetup: Bool { @@ -75,6 +109,9 @@ class RemoteSettingsViewModel: ObservableObject { loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value productionEnvironment = storage.productionEnvironment.value + returnApnsKey = storage.returnApnsKey.value + returnKeyId = storage.returnKeyId.value + setupBindings() } @@ -164,6 +201,17 @@ class RemoteSettingsViewModel: ObservableObject { .dropFirst() .sink { [weak self] in self?.storage.productionEnvironment.value = $0 } .store(in: &cancellables) + + // Return notification bindings + $returnApnsKey + .dropFirst() + .sink { [weak self] in self?.storage.returnApnsKey.value = $0 } + .store(in: &cancellables) + + $returnKeyId + .dropFirst() + .sink { [weak self] in self?.storage.returnKeyId.value = $0 } + .store(in: &cancellables) } func handleLoopAPNSQRCodeScanResult(_ result: Result) { diff --git a/LoopFollow/Remote/TRC/PushMessage.swift b/LoopFollow/Remote/TRC/PushMessage.swift index b7cd727d4..9ca077626 100644 --- a/LoopFollow/Remote/TRC/PushMessage.swift +++ b/LoopFollow/Remote/TRC/PushMessage.swift @@ -3,53 +3,62 @@ import Foundation -struct PushMessage: Encodable { +struct EncryptedPushMessage: Encodable { let aps: [String: Int] = ["content-available": 1] + + let encryptedData: String + + enum CodingKeys: String, CodingKey { + case aps + case encryptedData = "encrypted_data" + } +} + +struct CommandPayload: Encodable { var user: String var commandType: TRCCommandType + var timestamp: TimeInterval + var bolusAmount: Decimal? var target: Int? var duration: Int? var carbs: Int? var protein: Int? var fat: Int? - var sharedSecret: String - var timestamp: TimeInterval var overrideName: String? var scheduledTime: TimeInterval? + var returnNotification: ReturnNotificationInfo? + + struct ReturnNotificationInfo: Encodable { + let productionEnvironment: Bool + let deviceToken: String + let bundleId: String + let teamId: String + let keyId: String + let apnsKey: String + + enum CodingKeys: String, CodingKey { + case productionEnvironment = "production_environment" + case deviceToken = "device_token" + case bundleId = "bundle_id" + case teamId = "team_id" + case keyId = "key_id" + case apnsKey = "apns_key" + } + } enum CodingKeys: String, CodingKey { - case aps case user case commandType = "command_type" + case timestamp case bolusAmount = "bolus_amount" case target case duration case carbs case protein case fat - case sharedSecret = "shared_secret" - case timestamp case overrideName case scheduledTime = "scheduled_time" - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(aps, forKey: .aps) - try container.encode(user, forKey: .user) - try container.encode(commandType.rawValue, forKey: .commandType) - try container.encode(bolusAmount, forKey: .bolusAmount) - try container.encode(target, forKey: .target) - try container.encode(duration, forKey: .duration) - try container.encode(carbs, forKey: .carbs) - try container.encode(protein, forKey: .protein) - try container.encode(fat, forKey: .fat) - try container.encode(sharedSecret, forKey: .sharedSecret) - try container.encode(timestamp, forKey: .timestamp) - try container.encode(overrideName, forKey: .overrideName) - if let scheduledTime = scheduledTime { - try container.encode(scheduledTime, forKey: .scheduledTime) - } + case returnNotification = "return_notification" } } diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index ac586b772..d8090e03a 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -5,11 +5,6 @@ import Foundation import HealthKit import SwiftJWT -struct APNsJWTClaims: Claims { - let iss: String - let iat: Date -} - class PushNotificationManager { private var deviceToken: String private var sharedSecret: String @@ -31,70 +26,101 @@ class PushNotificationManager { bundleId = Storage.shared.bundleId.value } + private func createReturnNotificationInfo() -> CommandPayload.ReturnNotificationInfo? { + let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value + + guard !loopFollowDeviceToken.isEmpty else { + return nil + } + + guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else { + LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.") + return nil + } + + let teamIdsAreDifferent = loopFollowTeamID != teamId + let keyIdForReturn: String + let apnsKeyForReturn: String + + if teamIdsAreDifferent { + keyIdForReturn = Storage.shared.returnKeyId.value + apnsKeyForReturn = Storage.shared.returnApnsKey.value + } else { + keyIdForReturn = keyId + apnsKeyForReturn = apnsKey + } + + guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.") + return nil + } + + return CommandPayload.ReturnNotificationInfo( + productionEnvironment: BuildDetails.default.isTestFlightBuild(), + deviceToken: loopFollowDeviceToken, + bundleId: Bundle.main.bundleIdentifier ?? "", + teamId: loopFollowTeamID, + keyId: keyIdForReturn, + apnsKey: apnsKeyForReturn + ) + } + func sendOverridePushNotification(override: ProfileManager.TrioOverride, completion: @escaping (Bool, String?) -> Void) { - let message = PushMessage( + let payload = CommandPayload( user: user, commandType: .startOverride, - sharedSecret: sharedSecret, timestamp: Date().timeIntervalSince1970, - overrideName: override.name + overrideName: override.name, + returnNotification: createReturnNotificationInfo() ) - - sendPushNotification(message: message, completion: completion) + sendEncryptedCommand(payload: payload, completion: completion) } func sendCancelOverridePushNotification(completion: @escaping (Bool, String?) -> Void) { - let message = PushMessage( + let payload = CommandPayload( user: user, commandType: .cancelOverride, - sharedSecret: sharedSecret, timestamp: Date().timeIntervalSince1970, - overrideName: nil + overrideName: nil, + returnNotification: createReturnNotificationInfo() ) - - sendPushNotification(message: message, completion: completion) + sendEncryptedCommand(payload: payload, completion: completion) } func sendBolusPushNotification(bolusAmount: HKQuantity, completion: @escaping (Bool, String?) -> Void) { - let bolusAmount = Decimal(bolusAmount.doubleValue(for: .internationalUnit())) - - let message = PushMessage( + let bolusAmountDecimal = Decimal(bolusAmount.doubleValue(for: .internationalUnit())) + let payload = CommandPayload( user: user, commandType: .bolus, - bolusAmount: bolusAmount, - sharedSecret: sharedSecret, - timestamp: Date().timeIntervalSince1970 + timestamp: Date().timeIntervalSince1970, + bolusAmount: bolusAmountDecimal, + returnNotification: createReturnNotificationInfo() ) - - sendPushNotification(message: message, completion: completion) + sendEncryptedCommand(payload: payload, completion: completion) } func sendTempTargetPushNotification(target: HKQuantity, duration: HKQuantity, completion: @escaping (Bool, String?) -> Void) { let targetValue = Int(target.doubleValue(for: HKUnit.milligramsPerDeciliter)) let durationValue = Int(duration.doubleValue(for: HKUnit.minute())) - - let message = PushMessage( + let payload = CommandPayload( user: user, commandType: .tempTarget, - bolusAmount: nil, + timestamp: Date().timeIntervalSince1970, target: targetValue, duration: durationValue, - sharedSecret: sharedSecret, - timestamp: Date().timeIntervalSince1970 + returnNotification: createReturnNotificationInfo() ) - - sendPushNotification(message: message, completion: completion) + sendEncryptedCommand(payload: payload, completion: completion) } func sendCancelTempTargetPushNotification(completion: @escaping (Bool, String?) -> Void) { - let message = PushMessage( + let payload = CommandPayload( user: user, commandType: .cancelTempTarget, - sharedSecret: sharedSecret, - timestamp: Date().timeIntervalSince1970 + timestamp: Date().timeIntervalSince1970, + returnNotification: createReturnNotificationInfo() ) - - sendPushNotification(message: message, completion: completion) + sendEncryptedCommand(payload: payload, completion: completion) } func sendMealPushNotification( @@ -109,59 +135,47 @@ class PushNotificationManager { let valueInGrams = quantity.doubleValue(for: .gram()) return valueInGrams > 0 ? Int(valueInGrams) : nil } - func convertToOptionalDecimal(_ quantity: HKQuantity?) -> Decimal? { guard let quantity = quantity else { return nil } let value = quantity.doubleValue(for: .internationalUnit()) return value > 0 ? Decimal(value) : nil } - let carbsValue = convertToOptionalInt(carbs) let proteinValue = convertToOptionalInt(protein) let fatValue = convertToOptionalInt(fat) let scheduledTimeInterval: TimeInterval? = scheduledTime?.timeIntervalSince1970 let bolusAmountValue = convertToOptionalDecimal(bolusAmount) - guard carbsValue != nil || proteinValue != nil || fatValue != nil else { completion(false, "No nutrient data provided. At least one of carbs, fat, or protein must be greater than 0.") return } - - let message = PushMessage( + let payload = CommandPayload( user: user, commandType: .meal, + timestamp: Date().timeIntervalSince1970, bolusAmount: bolusAmountValue, carbs: carbsValue, protein: proteinValue, fat: fatValue, - sharedSecret: sharedSecret, - timestamp: Date().timeIntervalSince1970, - scheduledTime: scheduledTimeInterval + scheduledTime: scheduledTimeInterval, + returnNotification: createReturnNotificationInfo() ) - - sendPushNotification(message: message, completion: completion) + sendEncryptedCommand(payload: payload, completion: completion) } private func validateCredentials() -> [String]? { var errors = [String]() - - // Validate keyId (should be 10 alphanumeric characters) let keyIdPattern = "^[A-Z0-9]{10}$" if !matchesRegex(keyId, pattern: keyIdPattern) { errors.append("APNS Key ID (\(keyId)) must be 10 uppercase alphanumeric characters.") } - - // Validate teamId (should be 10 alphanumeric characters) let teamIdPattern = "^[A-Z0-9]{10}$" if !matchesRegex(teamId, pattern: teamIdPattern) { errors.append("Team ID (\(teamId)) must be 10 uppercase alphanumeric characters.") } - - // Validate apnsKey (should contain the BEGIN and END PRIVATE KEY markers) if !apnsKey.contains("-----BEGIN PRIVATE KEY-----") || !apnsKey.contains("-----END PRIVATE KEY-----") { errors.append("APNS Key must be a valid PEM-formatted private key.") } else { - // Validate that the key data between the markers is valid Base64 if let keyData = extractKeyData(from: apnsKey) { if Data(base64Encoded: keyData) == nil { errors.append("APNS Key contains invalid Base64 key data.") @@ -170,7 +184,6 @@ class PushNotificationManager { errors.append("APNS Key has invalid formatting.") } } - return errors.isEmpty ? nil : errors } @@ -192,28 +205,18 @@ class PushNotificationManager { return keyLines.joined() } - private func sendPushNotification(message: PushMessage, completion: @escaping (Bool, String?) -> Void) { - print("Push message to send: \(message)") - + private func sendEncryptedCommand(payload: CommandPayload, completion: @escaping (Bool, String?) -> Void) { var missingFields = [String]() if sharedSecret.isEmpty { missingFields.append("sharedSecret") } - if apnsKey.isEmpty { missingFields.append("token") } + if apnsKey.isEmpty { missingFields.append("apnsKey") } if keyId.isEmpty { missingFields.append("keyId") } if user.isEmpty { missingFields.append("user") } - - if !missingFields.isEmpty { - let errorMessage = "Missing required fields, check your remote settings: \(missingFields.joined(separator: ", "))" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - if deviceToken.isEmpty { missingFields.append("deviceToken") } if bundleId.isEmpty { missingFields.append("bundleId") } if teamId.isEmpty { missingFields.append("teamId") } if !missingFields.isEmpty { - let errorMessage = "Missing required data, verify that you are using the latest version of Trio: \(missingFields.joined(separator: ", "))" + let errorMessage = "Missing required fields for command: \(missingFields.joined(separator: ", "))" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return @@ -240,18 +243,27 @@ class PushNotificationManager { return } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") - request.setValue("application/json", forHTTPHeaderField: "content-type") - request.setValue("10", forHTTPHeaderField: "apns-priority") - request.setValue("0", forHTTPHeaderField: "apns-expiration") - request.setValue(bundleId, forHTTPHeaderField: "apns-topic") - request.setValue("background", forHTTPHeaderField: "apns-push-type") - do { - let jsonData = try JSONEncoder().encode(message) - request.httpBody = jsonData + guard let messenger = SecureMessenger(sharedSecret: sharedSecret) else { + let errorMessage = "Failed to initialize security module. Check shared secret." + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + + let encryptedDataString = try messenger.encrypt(payload) + let finalMessage = EncryptedPushMessage(encryptedData: encryptedDataString) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("10", forHTTPHeaderField: "apns-priority") + request.setValue("0", forHTTPHeaderField: "apns-expiration") + request.setValue(bundleId, forHTTPHeaderField: "apns-topic") + request.setValue("background", forHTTPHeaderField: "apns-push-type") + + request.httpBody = try JSONEncoder().encode(finalMessage) let task = URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { @@ -265,22 +277,14 @@ class PushNotificationManager { print("Push notification sent.") print("Status code: \(httpResponse.statusCode)") - print("Response headers:") - for (key, value) in httpResponse.allHeaderFields { - print("\(key): \(value)") - } - var responseBodyMessage = "" if let data = data, let responseBody = String(data: data, encoding: .utf8) { print("Response body: \(responseBody)") - if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], let reason = json["reason"] as? String { responseBodyMessage = reason } - } else { - print("No response body") } switch httpResponse.statusCode { @@ -314,8 +318,8 @@ class PushNotificationManager { task.resume() } catch { - let errorMessage = "Failed to encode push message: \(error.localizedDescription)" - print(errorMessage) + let errorMessage = "Failed to encode or encrypt push message: \(error.localizedDescription)" + LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) } } diff --git a/LoopFollow/Remote/TRC/SecureMessenger.swift b/LoopFollow/Remote/TRC/SecureMessenger.swift new file mode 100644 index 000000000..c159a94ea --- /dev/null +++ b/LoopFollow/Remote/TRC/SecureMessenger.swift @@ -0,0 +1,38 @@ +// LoopFollow +// SecureMessenger.swift + +import CryptoSwift +import Foundation +import Security + +struct SecureMessenger { + private let sharedKey: [UInt8] + + init?(sharedSecret: String) { + guard let secretData = sharedSecret.data(using: .utf8) else { + return nil + } + sharedKey = Array(secretData.sha256()) + } + + private func generateSecureRandomBytes(count: Int) -> [UInt8]? { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes) + return status == errSecSuccess ? bytes : nil + } + + func encrypt(_ object: T) throws -> String { + let dataToEncrypt = try JSONEncoder().encode(object) + + guard let nonce = generateSecureRandomBytes(count: 12) else { + throw NSError(domain: "SecureMessenger", code: 500, userInfo: [NSLocalizedDescriptionKey: "Failed to generate secure random nonce."]) + } + + let gcm = GCM(iv: nonce, mode: .combined) + let aes = try AES(key: sharedKey, blockMode: gcm, padding: .noPadding) + let encryptedBytes = try aes.encrypt(Array(dataToEncrypt)) + let finalData = Data(nonce + encryptedBytes) + + return finalData.base64EncodedString() + } +} diff --git a/LoopFollow/Remote/TRC/TRCCommandType.swift b/LoopFollow/Remote/TRC/TRCCommandType.swift index b5bc6b424..c7f66b082 100644 --- a/LoopFollow/Remote/TRC/TRCCommandType.swift +++ b/LoopFollow/Remote/TRC/TRCCommandType.swift @@ -3,7 +3,7 @@ import Foundation -enum TRCCommandType: String { +enum TRCCommandType: String, Encodable { case bolus case tempTarget = "temp_target" case cancelTempTarget = "cancel_temp_target" diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 03576644f..992dcc1ff 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -36,9 +36,9 @@ class Observable { var settingsPath = ObservableValue(default: NavigationPath()) - // MARK: - Loop APNS TOTP Tracking - var lastSentTOTP = ObservableValue(default: nil) + var loopFollowDeviceToken = ObservableValue(default: "") + private init() {} } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 8eab97e72..64b82bb18 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -167,6 +167,9 @@ class Storage { var loopAPNSQrCodeURL = StorageValue(key: "loopAPNSQrCodeURL", defaultValue: "") + var returnApnsKey = StorageValue(key: "returnApnsKey", defaultValue: "") + var returnKeyId = StorageValue(key: "returnKeyId", defaultValue: "") + static let shared = Storage() private init() {} } diff --git a/Scripts/capture-build-details.sh b/Scripts/capture-build-details.sh index d455933c4..0590d1025 100755 --- a/Scripts/capture-build-details.sh +++ b/Scripts/capture-build-details.sh @@ -26,6 +26,9 @@ if [ "${info_plist_path}" == "/" -o ! -e "${info_plist_path}" ]; then else echo "Gathering build details..." + # Capture the Development Team ID and write it to BuildDetails.plist + plutil -replace com-LoopFollow-development-team -string "${DEVELOPMENT_TEAM}" "${info_plist_path}" + # Capture the current date and write it to BuildDetails.plist formatted_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ") plutil -replace com-LoopFollow-build-date -string "$formatted_date" "${info_plist_path}"