| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771 |
- // 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[..<beginIndex])
- let keyData = String(fixedKey[beginIndex ..< endIndex])
- let footer = String(fixedKey[endIndex...])
- // Clean up the key data - remove any whitespace and split into 64-character lines
- let cleanKeyData = keyData.replacingOccurrences(of: " ", with: "")
- .replacingOccurrences(of: "\t", with: "")
- .replacingOccurrences(of: "\n", with: "")
- .replacingOccurrences(of: "\r", with: "")
- // Validate the key data length (should be 44 characters for P-256)
- LogManager.shared.log(category: .apns, message: "Key data validation - Length: \(cleanKeyData.count) characters")
- if cleanKeyData.count != 44 {
- LogManager.shared.log(category: .apns, message: "WARNING: Key data length is \(cleanKeyData.count), expected 44 for P-256 private key")
- }
- // Validate base64 format
- if Data(base64Encoded: cleanKeyData) == nil {
- LogManager.shared.log(category: .apns, message: "WARNING: Key data is not valid base64")
- }
- // Split into 64-character lines (standard PEM format)
- var formattedKeyData = ""
- var currentLine = ""
- for char in cleanKeyData {
- currentLine.append(char)
- if currentLine.count == 64 {
- formattedKeyData += currentLine + "\n"
- currentLine = ""
- }
- }
- // Add any remaining characters
- if !currentLine.isEmpty {
- formattedKeyData += currentLine
- }
- fixedKey = "\(header)\n\(formattedKeyData)\n\(footer)"
- LogManager.shared.log(category: .apns, message: "APNS Key format fixed - added proper line breaks")
- LogManager.shared.log(category: .apns, message: "Key data length: \(cleanKeyData.count) characters")
- LogManager.shared.log(category: .apns, message: "Formatted key lines: \(formattedKeyData.components(separatedBy: "\n").count)")
- }
- }
- }
- } else {
- // Key already has line breaks, but let's ensure proper formatting
- let lines = fixedKey.components(separatedBy: .newlines)
- var cleanedLines: [String] = []
- for line in lines {
- let cleanedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
- if !cleanedLine.isEmpty {
- cleanedLines.append(cleanedLine)
- }
- }
- // Reconstruct with proper formatting
- if cleanedLines.count > 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
- )
- }
- }
|