// LoopFollow // LoopAPNSService.swift import Foundation import HealthKit class LoopAPNSService { private let storage = Storage.shared /// Returns the effective APNs credentials for sending commands to the remote app. /// Same team → use LoopFollow's own key. Different team → use remote-specific key. private func effectiveCredentials() -> (apnsKey: String, keyId: String, teamId: String) { let lfTeamId = BuildDetails.default.teamID ?? "" let remoteTeamId = storage.teamId.value ?? "" let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId if sameTeam || remoteTeamId.isEmpty { return (storage.lfApnsKey.value, storage.lfKeyId.value, lfTeamId) } else { return (storage.remoteApnsKey.value, storage.remoteKeyId.value, remoteTeamId) } } enum LoopAPNSError: Error, LocalizedError { case invalidConfiguration case jwtError case networkError case invalidResponse case noDeviceToken case noBundleIdentifier case unauthorized case deviceTokenNotConfigured case bundleIdentifierNotConfigured case rateLimited var errorDescription: String? { switch self { case .invalidConfiguration: return String(localized: "Loop APNS Configuration not valid") case .jwtError: return String(localized: "Failed generating JWT token, check APNS Key ID, APNS Key and Team ID") case .networkError: return String(localized: "Network error occurred") case .invalidResponse: return String(localized: "Invalid response from server") case .noDeviceToken: return String(localized: "No device token found in profile") case .noBundleIdentifier: return "No bundle identifier found in profile" case .unauthorized: return String(localized: "Unauthorized - check your API secret") case .deviceTokenNotConfigured: return String(localized: "Device token not configured") case .bundleIdentifierNotConfigured: return "Bundle identifier not configured" case .rateLimited: return String(localized: "Too many requests - please wait a few minutes before trying again") } } } private func createReturnNotificationInfo() -> ReturnNotificationInfo? { 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 } let lfKeyId = storage.lfKeyId.value let lfApnsKey = storage.lfApnsKey.value guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") return nil } return ReturnNotificationInfo( productionEnvironment: BuildDetails.default.isTestFlightBuild(), deviceToken: loopFollowDeviceToken, bundleId: Bundle.main.bundleIdentifier ?? "", teamId: loopFollowTeamID, keyId: lfKeyId, apnsKey: lfApnsKey ) } /// Encrypts return notification info using OTP code private func encryptReturnNotificationInfo(returnInfo: ReturnNotificationInfo, otpCode: String) -> String? { guard let messenger = OTPSecureMessenger(otpCode: otpCode) else { LogManager.shared.log(category: .apns, message: "Failed to create OTP secure messenger") return nil } do { return try messenger.encrypt(returnInfo) } catch { LogManager.shared.log(category: .apns, message: "Failed to encrypt return notification info: \(error.localizedDescription)") return nil } } /// Validates the Loop APNS setup by checking all required fields /// - Returns: True if setup is valid, false otherwise func validateSetup() -> Bool { let creds = effectiveCredentials() let hasKeyId = !creds.keyId.isEmpty let hasAPNSKey = !creds.apnsKey.isEmpty let hasQrCode = !storage.loopAPNSQrCodeURL.value.isEmpty let hasDeviceToken = !Storage.shared.deviceToken.value.isEmpty let hasBundleIdentifier = !Storage.shared.bundleId.value.isEmpty // For initial setup, we don't require device token and bundle identifier // These will be fetched when the user clicks "Refresh Device Token" let hasBasicSetup = hasKeyId && hasAPNSKey && hasQrCode // For full validation (after device token is fetched), check everything let hasFullSetup = hasBasicSetup && hasDeviceToken && hasBundleIdentifier return hasFullSetup } /// Sends carbs via APNS push notification /// - Parameters: /// - payload: The carbs payload to send /// - completion: Completion handler with success status and error message func sendCarbsViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { guard validateSetup() else { let errorMessage = "Loop APNS Configuration not valid" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } let deviceToken = Storage.shared.deviceToken.value let bundleIdentifier = Storage.shared.bundleId.value let creds = effectiveCredentials() // Create APNS notification payload (matching Loop's expected format) let now = Date() let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now // Create the complete notification payload (matching Nightscout's exact format) // Based on Nightscout's loop.js implementation let carbsAmount = payload.carbsAmount ?? 0.0 let absorptionTime = payload.absorptionTime ?? 3.0 let startTime = payload.consumedDate ?? now var finalPayload = [ "carbs-entry": carbsAmount, "absorption-time": absorptionTime, "otp": String(payload.otp), "remote-address": "LoopFollow", "notes": "Sent via LoopFollow APNS", "entered-by": "LoopFollow", "sent-at": formatDateForAPNS(now), "expiration": formatDateForAPNS(expiration), "start-time": formatDateForAPNS(startTime), "alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", ] as [String: Any] // Encrypt and include return notification info using OTP if let returnInfo = createReturnNotificationInfo() { LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))") if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) { finalPayload["encrypted_return_notification"] = encryptedReturnInfo LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)") } else { LogManager.shared.log(category: .apns, message: "Failed to encrypt return notification info for carbs command") } } else { LogManager.shared.log(category: .apns, message: "Failed to create return notification info for carbs command") } // 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") sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, keyId: creds.keyId, apnsKey: creds.apnsKey, teamId: creds.teamId, payload: finalPayload, completion: completion ) } /// Sends bolus via APNS push notification /// - Parameters: /// - payload: The bolus payload to send /// - completion: Completion handler with success status and error message func sendBolusViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { guard validateSetup() else { let errorMessage = "Loop APNS Configuration not valid" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } let deviceToken = Storage.shared.deviceToken.value let bundleIdentifier = Storage.shared.bundleId.value let creds = effectiveCredentials() // Create APNS notification payload (matching Loop's expected format) let now = Date() let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now // Create the complete notification payload (matching Nightscout's exact format) // Based on Nightscout's loop.js implementation let bolusAmount = payload.bolusAmount ?? 0.0 var finalPayload = [ "bolus-entry": bolusAmount, "otp": String(payload.otp), "remote-address": "LoopFollow", "notes": "Sent via LoopFollow APNS", "entered-by": "LoopFollow", "sent-at": formatDateForAPNS(now), "expiration": formatDateForAPNS(expiration), "alert": "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", ] as [String: Any] // Encrypt and include return notification info using OTP if let returnInfo = createReturnNotificationInfo() { LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))") if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) { finalPayload["encrypted_return_notification"] = encryptedReturnInfo LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)") } else { LogManager.shared.log(category: .apns, message: "Failed to encrypt return notification info for carbs command") } } else { LogManager.shared.log(category: .apns, message: "Failed to create return notification info for carbs command") } // 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, keyId: creds.keyId, apnsKey: creds.apnsKey, teamId: creds.teamId, payload: finalPayload, completion: completion ) } /// Validates APNS credentials similar to PushNotificationManager /// - Returns: Array of validation error messages, or nil if valid private func validateCredentials() -> [String]? { var errors = [String]() let creds = effectiveCredentials() let keyId = creds.keyId let teamId = creds.teamId let apnsKey = creds.apnsKey // 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.") } } else { errors.append("APNS Key has invalid formatting.") } } return errors.isEmpty ? nil : errors } /// Helper method to match regex patterns /// - Parameters: /// - text: Text to match /// - pattern: Regex pattern /// - Returns: True if pattern matches private func matchesRegex(_ text: String, pattern: String) -> Bool { let regex = try? NSRegularExpression(pattern: pattern) let range = NSRange(location: 0, length: text.utf16.count) return regex?.firstMatch(in: text, options: [], range: range) != nil } /// Provides simple environment guidance for APNS configuration /// - Returns: String with simple guidance to try opposite setting private func getEnvironmentGuidance() -> String { let currentSetting = storage.productionEnvironment.value ? "ON" : "OFF" let trySetting = storage.productionEnvironment.value ? "OFF" : "ON" return "Try changing Production Environment from \(currentSetting) to \(trySetting) in your Loop APNS settings." } /// Sends an APNS notification /// - Parameters: /// - deviceToken: The device token to send to /// - bundleIdentifier: The bundle identifier /// - keyId: The APNS key ID /// - apnsKey: The APNS key /// - payload: The notification payload /// - completion: Completion handler with success status and error message private func sendAPNSNotification( deviceToken: String, bundleIdentifier: String, keyId: String, apnsKey: String, teamId: String, payload: [String: Any], completion: @escaping (Bool, String?) -> Void ) { // Validate credentials first if let validationErrors = validateCredentials() { let errorMessage = "Credential validation failed: \(validationErrors.joined(separator: ", "))" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } // Create JWT token for APNS authentication guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct." LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } // Determine APNS environment let isProduction = storage.productionEnvironment.value let apnsURL = isProduction ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com" guard let requestURL = URL(string: "\(apnsURL)/3/device/\(deviceToken)") else { let errorMessage = "Failed to construct APNs URL" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } var request = URLRequest(url: requestURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "content-type") request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") request.setValue(bundleIdentifier, forHTTPHeaderField: "apns-topic") request.setValue("alert", forHTTPHeaderField: "apns-push-type") request.setValue("10", forHTTPHeaderField: "apns-priority") // High priority // Validate bundle identifier format if !bundleIdentifier.contains(".") { LogManager.shared.log(category: .apns, message: "Warning: Bundle identifier may be in wrong format: \(bundleIdentifier)") } // Create the proper APNS payload structure (matching @parse/node-apn format) var apnsPayload: [String: Any] = [ "aps": [ "alert": payload["alert"] as? String ?? "", "content-available": 1, "interruption-level": "time-sensitive", ], ] // Add all the custom payload fields (excluding APNS-specific fields) for (key, value) in payload { if key != "alert", key != "content-available", key != "interruption-level" { apnsPayload[key] = value } } // Remove nil values to clean up the payload let cleanPayload = apnsPayload.compactMapValues { $0 } // Log if encrypted_return_notification is in the payload if cleanPayload["encrypted_return_notification"] != nil { LogManager.shared.log(category: .apns, message: "encrypted_return_notification is present in final APNS payload") } else { LogManager.shared.log(category: .apns, message: "WARNING: encrypted_return_notification is NOT in final APNS payload. Available keys: \(Array(cleanPayload.keys).joined(separator: ", "))") } do { let jsonData = try JSONSerialization.data(withJSONObject: cleanPayload) request.httpBody = jsonData let task = URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { let errorMessage = "Failed to send push notification: \(error.localizedDescription)" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } if let httpResponse = response as? HTTPURLResponse { var responseBodyMessage = "" if let data = data, let responseBody = String(data: data, encoding: .utf8) { if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], let reason = json["reason"] as? String { responseBodyMessage = reason } } switch httpResponse.statusCode { case 200: LogManager.shared.log(category: .apns, message: "APNS notification sent successfully") completion(true, nil) case 400: let environmentGuidance = self.getEnvironmentGuidance() let errorMessage = "Bad request. The request was invalid or malformed. \(responseBodyMessage)\n\n\(environmentGuidance)" LogManager.shared.log(category: .apns, message: "APNS error 400: \(responseBodyMessage) - Check device token and environment settings") completion(false, errorMessage) case 403: JWTManager.shared.invalidateCache() let errorMessage = "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error 403: \(responseBodyMessage) - Check APNS key permissions for bundle ID") completion(false, errorMessage) case 404: let errorMessage = "Invalid request: The :path value was incorrect. \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error 404: \(responseBodyMessage)") completion(false, errorMessage) case 405: let errorMessage = "Invalid request: Only POST requests are supported. \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error 405: \(responseBodyMessage)") completion(false, errorMessage) case 410: let errorMessage = "The device token is no longer active for the topic. \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error 410: Device token is invalid or expired") completion(false, errorMessage) case 413: let errorMessage = "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error 413: \(responseBodyMessage)") completion(false, errorMessage) case 429: let errorMessage = "Too many requests. \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error 429: Rate limited - wait before retrying") completion(false, errorMessage) case 500: let errorMessage = "Internal server error at APNs. \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error 500: \(responseBodyMessage)") completion(false, errorMessage) case 503: let errorMessage = "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error 503: \(responseBodyMessage)") completion(false, errorMessage) default: let errorMessage = "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error \(httpResponse.statusCode): \(responseBodyMessage)") completion(false, errorMessage) } } else { let errorMessage = "Failed to get a valid HTTP response." LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) } } task.resume() } catch { let errorMessage = "Failed to serialize APNS payload: \(error.localizedDescription)" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) } } /// Validates and fixes APNS key format if needed /// - Parameter key: The APNS key to validate and fix /// - Returns: The fixed APNS key func validateAndFixAPNSKey(_ key: String) -> String { // Normalize: replace all literal \n with real newlines var fixedKey = key.replacingOccurrences(of: "\\n", with: "\n") // Strip leading/trailing quotes fixedKey = fixedKey.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) // Check if the key has proper line breaks if !fixedKey.contains("\n") { LogManager.shared.log(category: .apns, message: "APNS Key missing line breaks, attempting to fix format") // Try to add line breaks if the key is all on one line if fixedKey.contains("-----BEGIN PRIVATE KEY-----") && fixedKey.contains("-----END PRIVATE KEY-----") { // Find the positions of the headers if let beginRange = fixedKey.range(of: "-----BEGIN PRIVATE KEY-----"), let endRange = fixedKey.range(of: "-----END PRIVATE KEY-----") { let beginIndex = fixedKey.index(beginRange.upperBound, offsetBy: 0) let endIndex = endRange.lowerBound if beginIndex < endIndex { let header = String(fixedKey[.. 2 { let header = cleanedLines[0] let footer = cleanedLines[cleanedLines.count - 1] let keyLines = Array(cleanedLines[1 ..< (cleanedLines.count - 1)]) // Combine all key data lines and validate let combinedKeyData = keyLines.joined() LogManager.shared.log(category: .apns, message: "Combined key data length: \(combinedKeyData.count) characters") // Validate the key data length (should be 44 characters for P-256) if combinedKeyData.count != 44 { LogManager.shared.log(category: .apns, message: "WARNING: Combined key data length is \(combinedKeyData.count), expected 44 for P-256 private key") } // Validate base64 format if Data(base64Encoded: combinedKeyData) == nil { LogManager.shared.log(category: .apns, message: "WARNING: Combined key data is not valid base64") } // Ensure key lines are properly formatted (64 characters each) var formattedKeyLines: [String] = [] var currentLine = "" for line in keyLines { let cleanLine = line.replacingOccurrences(of: " ", with: "") .replacingOccurrences(of: "\t", with: "") for char in cleanLine { currentLine.append(char) if currentLine.count == 64 { formattedKeyLines.append(currentLine) currentLine = "" } } } // Add any remaining characters if !currentLine.isEmpty { formattedKeyLines.append(currentLine) } fixedKey = "\(header)\n\(formattedKeyLines.joined(separator: "\n"))\n\(footer)" LogManager.shared.log(category: .apns, message: "APNS Key reformatted - cleaned up existing line breaks") } } return fixedKey } /// Extracts key data from PEM format /// - Parameter pemString: The PEM formatted private key /// - Returns: The extracted key data string private func extractKeyData(from pemString: String) -> String? { let lines = pemString.components(separatedBy: "\n") guard let startIndex = lines.firstIndex(of: "-----BEGIN PRIVATE KEY-----"), let endIndex = lines.firstIndex(of: "-----END PRIVATE KEY-----"), startIndex < endIndex else { return nil } let keyLines = lines[(startIndex + 1) ..< endIndex] return keyLines.joined() } // MARK: - Date Formatting Helper /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format) /// - Parameter date: The date to format /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z" private func formatDateForAPNS(_ date: Date) -> String { let dateFormatter = ISO8601DateFormatter() dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return dateFormatter.string(from: date) } // MARK: - Override Methods func sendOverrideNotification(presetName: String, duration: TimeInterval? = nil, completion: @escaping (Bool, String?) -> Void) { let deviceToken = Storage.shared.deviceToken.value guard !deviceToken.isEmpty else { let errorMessage = "Device token not configured" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } let bundleIdentifier = Storage.shared.bundleId.value guard !bundleIdentifier.isEmpty else { let errorMessage = "Bundle identifier not configured" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } // Create APNS notification payload (matching Loop's expected format) let now = Date() let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now // Create alert text (matching Nightscout's format) var alertText = "\(presetName) Temporary Override" if let duration = duration, duration > 0 { let hours = Int(duration / 3600) let minutes = Int((duration.truncatingRemainder(dividingBy: 3600)) / 60) if hours > 0 { alertText += " (\(hours)h \(minutes)m)" } else { alertText += " (\(minutes)m)" } } var payload: [String: Any] = [ "override-name": presetName, "remote-address": "LoopFollow", "entered-by": "LoopFollow", "sent-at": formatDateForAPNS(now), "expiration": formatDateForAPNS(expiration), "alert": alertText, ] if let duration = duration, duration > 0 { payload["override-duration-minutes"] = Int(duration / 60) } // For override commands, we can include return notification info unencrypted // since override commands don't require OTP validation in Loop if let returnInfo = createReturnNotificationInfo() { payload["return_notification"] = [ "production_environment": returnInfo.productionEnvironment, "device_token": returnInfo.deviceToken, "bundle_id": returnInfo.bundleId, "team_id": returnInfo.teamId, "key_id": returnInfo.keyId, "apns_key": returnInfo.apnsKey, ] } // Send the notification using the existing APNS infrastructure let creds = effectiveCredentials() sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, keyId: creds.keyId, apnsKey: creds.apnsKey, teamId: creds.teamId, payload: payload, completion: completion ) } func sendCancelOverrideNotification(completion: @escaping (Bool, String?) -> Void) { let deviceToken = Storage.shared.deviceToken.value guard !deviceToken.isEmpty else { let errorMessage = "Device token not configured" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } let bundleIdentifier = Storage.shared.bundleId.value guard !bundleIdentifier.isEmpty else { let errorMessage = "Bundle identifier not configured" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } // Create APNS notification payload (matching Loop's expected format) let now = Date() let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now var payload: [String: Any] = [ "cancel-temporary-override": "true", "remote-address": "LoopFollow", "entered-by": "LoopFollow", "sent-at": formatDateForAPNS(now), "expiration": formatDateForAPNS(expiration), "alert": "Cancel Temporary Override", ] // For override commands, we can include return notification info unencrypted // since override commands don't require OTP validation in Loop if let returnInfo = createReturnNotificationInfo() { payload["return_notification"] = [ "production_environment": returnInfo.productionEnvironment, "device_token": returnInfo.deviceToken, "bundle_id": returnInfo.bundleId, "team_id": returnInfo.teamId, "key_id": returnInfo.keyId, "apns_key": returnInfo.apnsKey, ] } // Send the notification using the existing APNS infrastructure let creds = effectiveCredentials() sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, keyId: creds.keyId, apnsKey: creds.apnsKey, teamId: creds.teamId, payload: payload, completion: completion ) } }