From 9853d3058ece85df2570616d23c6d4de55c6ecc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 7 Aug 2025 21:19:02 +0200 Subject: [PATCH 1/4] APNS Push notification feedback for remote commands --- LoopFollow/Application/AppDelegate.swift | 61 +++++++++++++++- LoopFollow/Helpers/BuildDetails.swift | 4 ++ LoopFollow/Helpers/DateExtensions.swift | 2 +- LoopFollow/Helpers/JWTManager.swift | 2 +- LoopFollow/Helpers/TOTPGenerator.swift | 2 +- .../Views/SimpleQRCodeScannerView.swift | 2 +- LoopFollow/Info.plist | 5 +- LoopFollow/Loop Follow.entitlements | 4 ++ .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 2 +- .../Remote/LoopAPNS/LoopAPNSCarbsView.swift | 2 +- .../Remote/LoopAPNS/LoopAPNSRemoteView.swift | 2 +- .../Remote/LoopAPNS/LoopAPNSService.swift | 69 +++++++++++++++++-- .../Remote/LoopAPNS/OverridePresetData.swift | 2 +- .../Remote/LoopAPNS/OverridePresetsView.swift | 2 +- .../Remote/Settings/RemoteSettingsView.swift | 23 +++++++ .../Settings/RemoteSettingsViewModel.swift | 48 +++++++++++++ LoopFollow/Remote/TRC/PushMessage.swift | 39 ++++++++--- .../Remote/TRC/PushNotificationManager.swift | 68 +++++++++++++++--- LoopFollow/Storage/Observable.swift | 2 + LoopFollow/Storage/Storage.swift | 3 + Scripts/capture-build-details.sh | 3 + 21 files changed, 309 insertions(+), 38 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 32db917a7..149c36ba1 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -40,11 +40,63 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ = BLEManager.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 { @@ -140,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 473819218..afb089214 100644 --- a/LoopFollow/Helpers/BuildDetails.swift +++ b/LoopFollow/Helpers/BuildDetails.swift @@ -20,6 +20,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/Helpers/DateExtensions.swift b/LoopFollow/Helpers/DateExtensions.swift index 6b421f18b..f68645b4e 100644 --- a/LoopFollow/Helpers/DateExtensions.swift +++ b/LoopFollow/Helpers/DateExtensions.swift @@ -1,6 +1,6 @@ // LoopFollow // DateExtensions.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import Foundation diff --git a/LoopFollow/Helpers/JWTManager.swift b/LoopFollow/Helpers/JWTManager.swift index b3a55b35f..043b2593a 100644 --- a/LoopFollow/Helpers/JWTManager.swift +++ b/LoopFollow/Helpers/JWTManager.swift @@ -1,6 +1,6 @@ // LoopFollow // JWTManager.swift -// Created by Jonas Björkert. +// Created by Daniel Mini Johansson. import Foundation import SwiftJWT diff --git a/LoopFollow/Helpers/TOTPGenerator.swift b/LoopFollow/Helpers/TOTPGenerator.swift index 2e4fa8c89..00737325f 100644 --- a/LoopFollow/Helpers/TOTPGenerator.swift +++ b/LoopFollow/Helpers/TOTPGenerator.swift @@ -1,6 +1,6 @@ // LoopFollow // TOTPGenerator.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import CommonCrypto import Foundation diff --git a/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift b/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift index 1ae542e04..2da725738 100644 --- a/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift +++ b/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift @@ -1,6 +1,6 @@ // LoopFollow // SimpleQRCodeScannerView.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import AVFoundation import SwiftUI 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/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 4377d9ec8..70ca9dc51 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -1,6 +1,6 @@ // LoopFollow // LoopAPNSBolusView.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import HealthKit import LocalAuthentication diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index bb1259484..6d15252d4 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -1,6 +1,6 @@ // LoopFollow // LoopAPNSCarbsView.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import HealthKit import SwiftUI diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift index 133250122..15c660f07 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift @@ -1,6 +1,6 @@ // LoopFollow // LoopAPNSRemoteView.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import SwiftUI diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index f3d353610..46828a20a 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -1,6 +1,6 @@ // LoopFollow // LoopAPNSService.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import CryptoKit import Foundation @@ -48,6 +48,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 { @@ -88,7 +133,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), @@ -101,6 +146,10 @@ class LoopAPNSService { "alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", ] as [String: Any] + 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)") @@ -139,7 +188,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", @@ -150,6 +199,10 @@ class LoopAPNSService { "alert": "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", ] as [String: Any] + 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)") @@ -505,6 +558,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 try await sendAPNSNotification( deviceToken: deviceToken, @@ -530,7 +587,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", @@ -539,6 +596,10 @@ class LoopAPNSService { "alert": "Cancel Temporary Override", ] + if let returnInfo = createReturnNotificationInfo() { + payload["return_notification"] = returnInfo + } + // Send the notification using the existing APNS infrastructure try await sendAPNSNotification( deviceToken: deviceToken, diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetData.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetData.swift index 5deb62962..fe58ce259 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetData.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetData.swift @@ -1,6 +1,6 @@ // LoopFollow // OverridePresetData.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import Foundation diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift index 2273eda2a..3fd675694 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift @@ -1,6 +1,6 @@ // LoopFollow // OverridePresetsView.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import SwiftUI diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index f3be9bf51..aeba26d05 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -228,6 +228,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 e47b353ba..32b967eab 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -22,6 +22,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 @@ -30,6 +35,35 @@ class RemoteSettingsViewModel: ObservableObject { @Published var isShowingLoopAPNSScanner: Bool = false @Published var loopAPNSErrorMessage: String? + 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 { @@ -62,6 +96,9 @@ class RemoteSettingsViewModel: ObservableObject { loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value productionEnvironment = storage.productionEnvironment.value + returnApnsKey = storage.returnApnsKey.value + returnKeyId = storage.returnKeyId.value + setupBindings() } @@ -151,6 +188,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 8470a5b92..94ae3249e 100644 --- a/LoopFollow/Remote/TRC/PushMessage.swift +++ b/LoopFollow/Remote/TRC/PushMessage.swift @@ -18,6 +18,25 @@ struct PushMessage: Encodable { 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 @@ -33,6 +52,7 @@ struct PushMessage: Encodable { case timestamp case overrideName case scheduledTime = "scheduled_time" + case returnNotification = "return_notification" } func encode(to encoder: Encoder) throws { @@ -40,17 +60,16 @@ struct PushMessage: Encodable { 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.encodeIfPresent(bolusAmount, forKey: .bolusAmount) + try container.encodeIfPresent(target, forKey: .target) + try container.encodeIfPresent(duration, forKey: .duration) + try container.encodeIfPresent(carbs, forKey: .carbs) + try container.encodeIfPresent(protein, forKey: .protein) + try container.encodeIfPresent(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) - } + try container.encodeIfPresent(overrideName, forKey: .overrideName) + try container.encodeIfPresent(scheduledTime, forKey: .scheduledTime) + try container.encodeIfPresent(returnNotification, forKey: .returnNotification) } } diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 5f3634033..681f68935 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -6,11 +6,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 @@ -32,13 +27,59 @@ class PushNotificationManager { bundleId = Storage.shared.bundleId.value } + private func createReturnNotificationInfo() -> PushMessage.ReturnNotificationInfo? { + let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value + + guard !loopFollowDeviceToken.isEmpty else { + return nil + } + + // Get the LoopFollow 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 + } + + let teamIdsAreDifferent = loopFollowTeamID != teamId + + let keyIdForReturn: String + let apnsKeyForReturn: String + + if teamIdsAreDifferent { + // Team IDs differ, so we MUST use the separate return credentials from storage. + keyIdForReturn = Storage.shared.returnKeyId.value + apnsKeyForReturn = Storage.shared.returnApnsKey.value + } else { + // Team IDs are the same, so we can use the primary credentials. + keyIdForReturn = keyId + apnsKeyForReturn = apnsKey + } + + // Ensure we have the necessary credentials before proceeding + // Note: The teamId for the return message is ALWAYS the loopFollowTeamID. + guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.") + return nil + } + + return PushMessage.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( user: user, commandType: .startOverride, sharedSecret: sharedSecret, timestamp: Date().timeIntervalSince1970, - overrideName: override.name + overrideName: override.name, + returnNotification: createReturnNotificationInfo() ) sendPushNotification(message: message, completion: completion) @@ -50,7 +91,8 @@ class PushNotificationManager { commandType: .cancelOverride, sharedSecret: sharedSecret, timestamp: Date().timeIntervalSince1970, - overrideName: nil + overrideName: nil, + returnNotification: createReturnNotificationInfo() ) sendPushNotification(message: message, completion: completion) @@ -64,7 +106,8 @@ class PushNotificationManager { commandType: .bolus, bolusAmount: bolusAmount, sharedSecret: sharedSecret, - timestamp: Date().timeIntervalSince1970 + timestamp: Date().timeIntervalSince1970, + returnNotification: createReturnNotificationInfo() ) sendPushNotification(message: message, completion: completion) @@ -81,7 +124,8 @@ class PushNotificationManager { target: targetValue, duration: durationValue, sharedSecret: sharedSecret, - timestamp: Date().timeIntervalSince1970 + timestamp: Date().timeIntervalSince1970, + returnNotification: createReturnNotificationInfo() ) sendPushNotification(message: message, completion: completion) @@ -92,7 +136,8 @@ class PushNotificationManager { user: user, commandType: .cancelTempTarget, sharedSecret: sharedSecret, - timestamp: Date().timeIntervalSince1970 + timestamp: Date().timeIntervalSince1970, + returnNotification: createReturnNotificationInfo() ) sendPushNotification(message: message, completion: completion) @@ -137,7 +182,8 @@ class PushNotificationManager { fat: fatValue, sharedSecret: sharedSecret, timestamp: Date().timeIntervalSince1970, - scheduledTime: scheduledTimeInterval + scheduledTime: scheduledTimeInterval, + returnNotification: createReturnNotificationInfo() ) sendPushNotification(message: message, completion: completion) diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index fe6d39a03..85336c4c5 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -36,5 +36,7 @@ class Observable { var settingsPath = ObservableValue(default: NavigationPath()) + var loopFollowDeviceToken = ObservableValue(default: "") + private init() {} } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 2f5ca2828..a3d43c826 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -168,6 +168,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}" From 330e3abe5d17b7476cc9edc9b6533ba38549e8dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 8 Aug 2025 21:47:09 +0200 Subject: [PATCH 2/4] Encrypted payload --- LoopFollow.xcodeproj/project.pbxproj | 24 +++- .../xcshareddata/swiftpm/Package.resolved | 11 +- LoopFollow/Remote/TRC/PushMessage.swift | 38 ++---- .../Remote/TRC/PushNotificationManager.swift | 128 ++++++------------ LoopFollow/Remote/TRC/SecureMessenger.swift | 39 ++++++ LoopFollow/Remote/TRC/TRCCommandType.swift | 2 +- 6 files changed, 129 insertions(+), 113 deletions(-) create mode 100644 LoopFollow/Remote/TRC/SecureMessenger.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index cb8e910d8..8c82f8a9d 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -10,9 +10,9 @@ 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; - 6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */; }; 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; 6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134192E1DC27900BDBE08 /* OverridePresetData.swift */; }; + 6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; DD0650A92DCA8A10004D3B41 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */; }; @@ -52,6 +52,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 */; }; @@ -393,9 +395,9 @@ 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; - 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; 654134192E1DC27900BDBE08 /* OverridePresetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetData.swift; sourceTree = ""; }; + 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; @@ -436,6 +438,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 = ""; }; @@ -787,6 +790,7 @@ buildActionMask = 2147483647; files = ( FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, + DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */, 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */, ); @@ -919,6 +923,7 @@ DD4878112C7B74F90048F05C /* TRC */ = { isa = PBXGroup; children = ( + DD485F132E454B2600CE8CBF /* SecureMessenger.swift */, DD48781F2C7DAF890048F05C /* PushMessage.swift */, DD4878122C7B750D0048F05C /* TempTargetView.swift */, DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */, @@ -1569,6 +1574,7 @@ name = LoopFollow; packageProductDependencies = ( DD48781B2C7DAF140048F05C /* SwiftJWT */, + DD485F152E46631000CE8CBF /* CryptoSwift */, ); productName = LoopFollow; productReference = FC9788142485969B00A7906C /* Loop Follow.app */; @@ -1605,6 +1611,7 @@ packageReferences = ( DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */, 654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */, + DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */, ); productRefGroup = FC9788152485969B00A7906C /* Products */; projectDirPath = ""; @@ -2092,6 +2099,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 */, @@ -2388,6 +2396,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"; @@ -2399,6 +2415,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/Remote/TRC/PushMessage.swift b/LoopFollow/Remote/TRC/PushMessage.swift index 94ae3249e..975f7f3f0 100644 --- a/LoopFollow/Remote/TRC/PushMessage.swift +++ b/LoopFollow/Remote/TRC/PushMessage.swift @@ -4,18 +4,28 @@ 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? @@ -39,37 +49,17 @@ struct PushMessage: Encodable { } 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" case returnNotification = "return_notification" } - - 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.encodeIfPresent(bolusAmount, forKey: .bolusAmount) - try container.encodeIfPresent(target, forKey: .target) - try container.encodeIfPresent(duration, forKey: .duration) - try container.encodeIfPresent(carbs, forKey: .carbs) - try container.encodeIfPresent(protein, forKey: .protein) - try container.encodeIfPresent(fat, forKey: .fat) - try container.encode(sharedSecret, forKey: .sharedSecret) - try container.encode(timestamp, forKey: .timestamp) - try container.encodeIfPresent(overrideName, forKey: .overrideName) - try container.encodeIfPresent(scheduledTime, forKey: .scheduledTime) - try container.encodeIfPresent(returnNotification, forKey: .returnNotification) - } } diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 681f68935..7ab774395 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -27,42 +27,36 @@ class PushNotificationManager { bundleId = Storage.shared.bundleId.value } - private func createReturnNotificationInfo() -> PushMessage.ReturnNotificationInfo? { + private func createReturnNotificationInfo() -> CommandPayload.ReturnNotificationInfo? { let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value guard !loopFollowDeviceToken.isEmpty else { return nil } - // Get the LoopFollow 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 } let teamIdsAreDifferent = loopFollowTeamID != teamId - let keyIdForReturn: String let apnsKeyForReturn: String if teamIdsAreDifferent { - // Team IDs differ, so we MUST use the separate return credentials from storage. keyIdForReturn = Storage.shared.returnKeyId.value apnsKeyForReturn = Storage.shared.returnApnsKey.value } else { - // Team IDs are the same, so we can use the primary credentials. keyIdForReturn = keyId apnsKeyForReturn = apnsKey } - // Ensure we have the necessary credentials before proceeding - // Note: The teamId for the return message is ALWAYS the loopFollowTeamID. guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else { LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.") return nil } - return PushMessage.ReturnNotificationInfo( + return CommandPayload.ReturnNotificationInfo( productionEnvironment: BuildDetails.default.isTestFlightBuild(), deviceToken: loopFollowDeviceToken, bundleId: Bundle.main.bundleIdentifier ?? "", @@ -73,74 +67,61 @@ class PushNotificationManager { } 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, 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, 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, + 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, returnNotification: createReturnNotificationInfo() ) - - sendPushNotification(message: message, completion: completion) + sendEncryptedCommand(payload: payload, completion: completion) } func sendMealPushNotification( @@ -155,60 +136,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, 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.") @@ -217,7 +185,6 @@ class PushNotificationManager { errors.append("APNS Key has invalid formatting.") } } - return errors.isEmpty ? nil : errors } @@ -239,28 +206,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 @@ -287,18 +244,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 { @@ -312,22 +278,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 { @@ -361,8 +319,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..d75472587 --- /dev/null +++ b/LoopFollow/Remote/TRC/SecureMessenger.swift @@ -0,0 +1,39 @@ +// LoopFollow +// SecureMessenger.swift +// Created by Jonas Björkert. + +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 4e32d1d5c..f7b4f28fc 100644 --- a/LoopFollow/Remote/TRC/TRCCommandType.swift +++ b/LoopFollow/Remote/TRC/TRCCommandType.swift @@ -4,7 +4,7 @@ import Foundation -enum TRCCommandType: String { +enum TRCCommandType: String, Encodable { case bolus case tempTarget = "temp_target" case cancelTempTarget = "cancel_temp_target" From 8d15b98eb0a1bb85760ae87c2c6b43fa11cce908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 9 Aug 2025 09:48:51 +0200 Subject: [PATCH 3/4] No return info for LRC --- LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 46828a20a..822b0c98c 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -146,9 +146,11 @@ 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)") From a0fde63c04db667b3e75e9c4743cf0d88baabef7 Mon Sep 17 00:00:00 2001 From: codebymini Date: Thu, 2 Oct 2025 05:55:57 +0200 Subject: [PATCH 4/4] Fix merge errors for LOOP Apns service --- LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift | 10 +--------- .../Remote/Settings/RemoteSettingsViewModel.swift | 4 ++-- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index e772b5093..c768497ba 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -150,7 +150,6 @@ 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 @@ -164,7 +163,6 @@ class LoopAPNSService { // Log carbs entry attempt LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", carbsAmount))g, absorption: \(String(format: "%.1f", absorptionTime))h") - sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, @@ -210,24 +208,18 @@ 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 + /* 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 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") - sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 431856e28..37b3ef151 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -33,8 +33,8 @@ class RemoteSettingsViewModel: ObservableObject { @Published var productionEnvironment: Bool @Published var isShowingLoopAPNSScanner: Bool = false @Published var loopAPNSErrorMessage: String? - - // MARK: - QR Code Sharing Properties + + // MARK: - QR Code Sharing Properties @Published var isShowingQRCodeScanner: Bool = false @Published var isShowingQRCodeDisplay: Bool = false