فهرست منبع

APNS Push notification feedback for remote commands

Jonas Björkert 11 ماه پیش
والد
کامیت
9853d3058e

+ 59 - 2
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])
     }
 }

+ 4 - 0
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
     }

+ 1 - 1
LoopFollow/Helpers/DateExtensions.swift

@@ -1,6 +1,6 @@
 // LoopFollow
 // DateExtensions.swift
-// Created by codebymini.
+// Created by Daniel Mini Johansson.
 
 import Foundation
 

+ 1 - 1
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

+ 1 - 1
LoopFollow/Helpers/TOTPGenerator.swift

@@ -1,6 +1,6 @@
 // LoopFollow
 // TOTPGenerator.swift
-// Created by codebymini.
+// Created by Daniel Mini Johansson.
 
 import CommonCrypto
 import Foundation

+ 1 - 1
LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift

@@ -1,6 +1,6 @@
 // LoopFollow
 // SimpleQRCodeScannerView.swift
-// Created by codebymini.
+// Created by Daniel Mini Johansson.
 
 import AVFoundation
 import SwiftUI

+ 3 - 2
LoopFollow/Info.plist

@@ -53,12 +53,12 @@
 	<string>Loop Follow would like to access your calendar to update BG readings</string>
 	<key>NSCalendarsUsageDescription</key>
 	<string>Loop Follow would like to access your calendar to save BG readings</string>
+	<key>NSCameraUsageDescription</key>
+	<string>Used for scanning QR codes for remote authentication</string>
 	<key>NSContactsUsageDescription</key>
 	<string>This app requires access to contacts to update a contact image with real-time blood glucose information.</string>
 	<key>NSFaceIDUsageDescription</key>
 	<string>This app requires Face ID for secure authentication.</string>
-	<key>NSCameraUsageDescription</key>
-	<string>Used for scanning QR codes for remote authentication</string>
 	<key>NSHumanReadableCopyright</key>
 	<string></string>
 	<key>UIApplicationSceneManifest</key>
@@ -85,6 +85,7 @@
 		<string>audio</string>
 		<string>processing</string>
 		<string>bluetooth-central</string>
+		<string>remote-notification</string>
 	</array>
 	<key>UIFileSharingEnabled</key>
 	<true/>

+ 4 - 0
LoopFollow/Loop Follow.entitlements

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

+ 1 - 1
LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift

@@ -1,6 +1,6 @@
 // LoopFollow
 // LoopAPNSBolusView.swift
-// Created by codebymini.
+// Created by Daniel Mini Johansson.
 
 import HealthKit
 import LocalAuthentication

+ 1 - 1
LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift

@@ -1,6 +1,6 @@
 // LoopFollow
 // LoopAPNSCarbsView.swift
-// Created by codebymini.
+// Created by Daniel Mini Johansson.
 
 import HealthKit
 import SwiftUI

+ 1 - 1
LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift

@@ -1,6 +1,6 @@
 // LoopFollow
 // LoopAPNSRemoteView.swift
-// Created by codebymini.
+// Created by Daniel Mini Johansson.
 
 import SwiftUI
 

+ 65 - 4
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,

+ 1 - 1
LoopFollow/Remote/LoopAPNS/OverridePresetData.swift

@@ -1,6 +1,6 @@
 // LoopFollow
 // OverridePresetData.swift
-// Created by codebymini.
+// Created by Daniel Mini Johansson.
 
 import Foundation
 

+ 1 - 1
LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift

@@ -1,6 +1,6 @@
 // LoopFollow
 // OverridePresetsView.swift
-// Created by codebymini.
+// Created by Daniel Mini Johansson.
 
 import SwiftUI
 

+ 23 - 0
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) {

+ 48 - 0
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<String, Error>) {

+ 29 - 10
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)
     }
 }

+ 57 - 11
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)

+ 2 - 0
LoopFollow/Storage/Observable.swift

@@ -36,5 +36,7 @@ class Observable {
 
     var settingsPath = ObservableValue<NavigationPath>(default: NavigationPath())
 
+    var loopFollowDeviceToken = ObservableValue<String>(default: "")
+
     private init() {}
 }

+ 3 - 0
LoopFollow/Storage/Storage.swift

@@ -168,6 +168,9 @@ class Storage {
 
     var loopAPNSQrCodeURL = StorageValue<String>(key: "loopAPNSQrCodeURL", defaultValue: "")
 
+    var returnApnsKey = StorageValue<String>(key: "returnApnsKey", defaultValue: "")
+    var returnKeyId = StorageValue<String>(key: "returnKeyId", defaultValue: "")
+
     static let shared = Storage()
     private init() {}
 }

+ 3 - 0
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}"