LoopAPNSService.swift 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. // LoopFollow
  2. // LoopAPNSService.swift
  3. import CryptoKit
  4. import Foundation
  5. import HealthKit
  6. import SwiftJWT
  7. class LoopAPNSService {
  8. private let storage = Storage.shared
  9. enum LoopAPNSError: Error, LocalizedError {
  10. case invalidConfiguration
  11. case jwtError
  12. case networkError
  13. case invalidResponse
  14. case noDeviceToken
  15. case noBundleIdentifier
  16. case unauthorized
  17. case deviceTokenNotConfigured
  18. case bundleIdentifierNotConfigured
  19. case rateLimited
  20. var errorDescription: String? {
  21. switch self {
  22. case .invalidConfiguration:
  23. return "Loop APNS Configuration not valid"
  24. case .jwtError:
  25. return "Failed generating JWT token, check APNS Key ID, APNS Key and Team ID"
  26. case .networkError:
  27. return "Network error occurred"
  28. case .invalidResponse:
  29. return "Invalid response from server"
  30. case .noDeviceToken:
  31. return "No device token found in profile"
  32. case .noBundleIdentifier:
  33. return "No bundle identifier found in profile"
  34. case .unauthorized:
  35. return "Unauthorized - check your API secret"
  36. case .deviceTokenNotConfigured:
  37. return "Device token not configured"
  38. case .bundleIdentifierNotConfigured:
  39. return "Bundle identifier not configured"
  40. case .rateLimited:
  41. return "Too many requests - please wait a few minutes before trying again"
  42. }
  43. }
  44. }
  45. private func createReturnNotificationInfo() -> [String: Any]? {
  46. let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value
  47. guard !loopFollowDeviceToken.isEmpty else { return nil }
  48. // Get LoopFollow's own Team ID from BuildDetails.
  49. guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else {
  50. LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.")
  51. return nil
  52. }
  53. // Get the target Loop app's Team ID from storage.
  54. let targetTeamId = storage.teamId.value ?? ""
  55. let teamIdsAreDifferent = loopFollowTeamID != targetTeamId
  56. let keyIdForReturn: String
  57. let apnsKeyForReturn: String
  58. if teamIdsAreDifferent {
  59. // Team IDs differ, use the separate return credentials.
  60. keyIdForReturn = storage.returnKeyId.value
  61. apnsKeyForReturn = storage.returnApnsKey.value
  62. } else {
  63. // Team IDs are the same, use the primary credentials.
  64. keyIdForReturn = storage.keyId.value
  65. apnsKeyForReturn = storage.apnsKey.value
  66. }
  67. // Ensure we have the necessary credentials.
  68. guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else {
  69. LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.")
  70. return nil
  71. }
  72. let returnInfo: [String: Any] = [
  73. "production_environment": BuildDetails.default.isTestFlightBuild(),
  74. "device_token": loopFollowDeviceToken,
  75. "bundle_id": Bundle.main.bundleIdentifier ?? "",
  76. "team_id": loopFollowTeamID,
  77. "key_id": keyIdForReturn,
  78. "apns_key": apnsKeyForReturn,
  79. ]
  80. return returnInfo
  81. }
  82. /// Validates the Loop APNS setup by checking all required fields
  83. /// - Returns: True if setup is valid, false otherwise
  84. func validateSetup() -> Bool {
  85. let hasKeyId = !storage.keyId.value.isEmpty
  86. let hasAPNSKey = !storage.apnsKey.value.isEmpty
  87. let hasQrCode = !storage.loopAPNSQrCodeURL.value.isEmpty
  88. let hasDeviceToken = !Storage.shared.deviceToken.value.isEmpty
  89. let hasBundleIdentifier = !Storage.shared.bundleId.value.isEmpty
  90. // For initial setup, we don't require device token and bundle identifier
  91. // These will be fetched when the user clicks "Refresh Device Token"
  92. let hasBasicSetup = hasKeyId && hasAPNSKey && hasQrCode
  93. // For full validation (after device token is fetched), check everything
  94. let hasFullSetup = hasBasicSetup && hasDeviceToken && hasBundleIdentifier
  95. return hasFullSetup
  96. }
  97. /// Sends carbs via APNS push notification
  98. /// - Parameters:
  99. /// - payload: The carbs payload to send
  100. /// - completion: Completion handler with success status and error message
  101. func sendCarbsViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) {
  102. guard validateSetup() else {
  103. let errorMessage = "Loop APNS Configuration not valid"
  104. LogManager.shared.log(category: .apns, message: errorMessage)
  105. completion(false, errorMessage)
  106. return
  107. }
  108. let deviceToken = Storage.shared.deviceToken.value
  109. let bundleIdentifier = Storage.shared.bundleId.value
  110. let keyId = storage.keyId.value
  111. let apnsKey = storage.apnsKey.value
  112. // Create APNS notification payload (matching Loop's expected format)
  113. let now = Date()
  114. let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now
  115. // Create the complete notification payload (matching Nightscout's exact format)
  116. // Based on Nightscout's loop.js implementation
  117. let carbsAmount = payload.carbsAmount ?? 0.0
  118. let absorptionTime = payload.absorptionTime ?? 3.0
  119. let startTime = payload.consumedDate ?? now
  120. var finalPayload = [
  121. "carbs-entry": carbsAmount,
  122. "absorption-time": absorptionTime,
  123. "otp": String(payload.otp),
  124. "remote-address": "LoopFollow",
  125. "notes": "Sent via LoopFollow APNS",
  126. "entered-by": "LoopFollow",
  127. "sent-at": formatDateForAPNS(now),
  128. "expiration": formatDateForAPNS(expiration),
  129. "start-time": formatDateForAPNS(startTime),
  130. "alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours",
  131. ] as [String: Any]
  132. /* Let's wait with this until we have an encryption solution for LRC
  133. if let returnInfo = createReturnNotificationInfo() {
  134. finalPayload["return_notification"] = returnInfo
  135. }
  136. */
  137. // Log the exact carbs amount for debugging precision issues
  138. LogManager.shared.log(category: .apns, message: "Carbs amount - Raw: \(payload.carbsAmount ?? 0.0), Formatted: \(String(format: "%.1f", carbsAmount)), JSON: \(carbsAmount)")
  139. LogManager.shared.log(category: .apns, message: "Absorption time - Raw: \(payload.absorptionTime ?? 3.0), Formatted: \(String(format: "%.1f", absorptionTime)), JSON: \(absorptionTime)")
  140. // Log carbs entry attempt
  141. LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", carbsAmount))g, absorption: \(String(format: "%.1f", absorptionTime))h")
  142. sendAPNSNotification(
  143. deviceToken: deviceToken,
  144. bundleIdentifier: bundleIdentifier,
  145. keyId: keyId,
  146. apnsKey: apnsKey,
  147. payload: finalPayload,
  148. completion: completion
  149. )
  150. }
  151. /// Sends bolus via APNS push notification
  152. /// - Parameters:
  153. /// - payload: The bolus payload to send
  154. /// - completion: Completion handler with success status and error message
  155. func sendBolusViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) {
  156. guard validateSetup() else {
  157. let errorMessage = "Loop APNS Configuration not valid"
  158. LogManager.shared.log(category: .apns, message: errorMessage)
  159. completion(false, errorMessage)
  160. return
  161. }
  162. let deviceToken = Storage.shared.deviceToken.value
  163. let bundleIdentifier = Storage.shared.bundleId.value
  164. let keyId = storage.keyId.value
  165. let apnsKey = storage.apnsKey.value
  166. // Create APNS notification payload (matching Loop's expected format)
  167. let now = Date()
  168. let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now
  169. // Create the complete notification payload (matching Nightscout's exact format)
  170. // Based on Nightscout's loop.js implementation
  171. let bolusAmount = payload.bolusAmount ?? 0.0
  172. var finalPayload = [
  173. "bolus-entry": bolusAmount,
  174. "otp": String(payload.otp),
  175. "remote-address": "LoopFollow",
  176. "notes": "Sent via LoopFollow APNS",
  177. "entered-by": "LoopFollow",
  178. "sent-at": formatDateForAPNS(now),
  179. "expiration": formatDateForAPNS(expiration),
  180. "alert": "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U",
  181. ] as [String: Any]
  182. /* Let's wait with this until we have an encryption solution for LRC
  183. if let returnInfo = createReturnNotificationInfo() {
  184. finalPayload["return_notification"] = returnInfo
  185. }
  186. */
  187. // Log the exact carbs amount for debugging precision issues
  188. LogManager.shared.log(category: .apns, message: "Carbs amount - Raw: \(payload.carbsAmount ?? 0.0), Formatted: \(String(format: "%.1f", carbsAmount)), JSON: \(carbsAmount)")
  189. LogManager.shared.log(category: .apns, message: "Absorption time - Raw: \(payload.absorptionTime ?? 3.0), Formatted: \(String(format: "%.1f", absorptionTime)), JSON: \(absorptionTime)")
  190. // Log the exact bolus amount for debugging precision issues
  191. LogManager.shared.log(category: .apns, message: "Bolus amount - Raw: \(payload.bolusAmount ?? 0.0), Formatted: \(String(format: "%.2f", bolusAmount)), JSON: \(bolusAmount)")
  192. // Log bolus entry attempt
  193. LogManager.shared.log(category: .apns, message: "Sending bolus: \(String(format: "%.2f", bolusAmount))U")
  194. sendAPNSNotification(
  195. deviceToken: deviceToken,
  196. bundleIdentifier: bundleIdentifier,
  197. keyId: keyId,
  198. apnsKey: apnsKey,
  199. payload: finalPayload,
  200. completion: completion
  201. )
  202. }
  203. /// Validates APNS credentials similar to PushNotificationManager
  204. /// - Returns: Array of validation error messages, or nil if valid
  205. private func validateCredentials() -> [String]? {
  206. var errors = [String]()
  207. let keyId = storage.keyId.value
  208. let teamId = Storage.shared.teamId.value ?? ""
  209. let apnsKey = storage.apnsKey.value
  210. // Validate keyId (should be 10 alphanumeric characters)
  211. let keyIdPattern = "^[A-Z0-9]{10}$"
  212. if !matchesRegex(keyId, pattern: keyIdPattern) {
  213. errors.append("APNS Key ID (\(keyId)) must be 10 uppercase alphanumeric characters.")
  214. }
  215. // Validate teamId (should be 10 alphanumeric characters)
  216. let teamIdPattern = "^[A-Z0-9]{10}$"
  217. if !matchesRegex(teamId, pattern: teamIdPattern) {
  218. errors.append("Team ID (\(teamId)) must be 10 uppercase alphanumeric characters.")
  219. }
  220. // Validate apnsKey (should contain the BEGIN and END PRIVATE KEY markers)
  221. if !apnsKey.contains("-----BEGIN PRIVATE KEY-----") || !apnsKey.contains("-----END PRIVATE KEY-----") {
  222. errors.append("APNS Key must be a valid PEM-formatted private key.")
  223. } else {
  224. // Validate that the key data between the markers is valid Base64
  225. if let keyData = extractKeyData(from: apnsKey) {
  226. if Data(base64Encoded: keyData) == nil {
  227. errors.append("APNS Key contains invalid Base64 key data.")
  228. }
  229. } else {
  230. errors.append("APNS Key has invalid formatting.")
  231. }
  232. }
  233. return errors.isEmpty ? nil : errors
  234. }
  235. /// Helper method to match regex patterns
  236. /// - Parameters:
  237. /// - text: Text to match
  238. /// - pattern: Regex pattern
  239. /// - Returns: True if pattern matches
  240. private func matchesRegex(_ text: String, pattern: String) -> Bool {
  241. let regex = try? NSRegularExpression(pattern: pattern)
  242. let range = NSRange(location: 0, length: text.utf16.count)
  243. return regex?.firstMatch(in: text, options: [], range: range) != nil
  244. }
  245. /// Provides simple environment guidance for APNS configuration
  246. /// - Returns: String with simple guidance to try opposite setting
  247. private func getEnvironmentGuidance() -> String {
  248. let currentSetting = storage.productionEnvironment.value ? "ON" : "OFF"
  249. let trySetting = storage.productionEnvironment.value ? "OFF" : "ON"
  250. return "Try changing Production Environment from \(currentSetting) to \(trySetting) in your Loop APNS settings."
  251. }
  252. /// Sends an APNS notification
  253. /// - Parameters:
  254. /// - deviceToken: The device token to send to
  255. /// - bundleIdentifier: The bundle identifier
  256. /// - keyId: The APNS key ID
  257. /// - apnsKey: The APNS key
  258. /// - payload: The notification payload
  259. /// - completion: Completion handler with success status and error message
  260. private func sendAPNSNotification(
  261. deviceToken: String,
  262. bundleIdentifier: String,
  263. keyId: String,
  264. apnsKey: String,
  265. payload: [String: Any],
  266. completion: @escaping (Bool, String?) -> Void
  267. ) {
  268. // Validate credentials first
  269. if let validationErrors = validateCredentials() {
  270. let errorMessage = "Credential validation failed: \(validationErrors.joined(separator: ", "))"
  271. LogManager.shared.log(category: .apns, message: errorMessage)
  272. completion(false, errorMessage)
  273. return
  274. }
  275. // Create JWT token for APNS authentication
  276. guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: Storage.shared.teamId.value ?? "", apnsKey: apnsKey) else {
  277. let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct."
  278. LogManager.shared.log(category: .apns, message: errorMessage)
  279. completion(false, errorMessage)
  280. return
  281. }
  282. // Determine APNS environment
  283. let isProduction = storage.productionEnvironment.value
  284. let apnsURL = isProduction ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"
  285. guard let requestURL = URL(string: "\(apnsURL)/3/device/\(deviceToken)") else {
  286. let errorMessage = "Failed to construct APNs URL"
  287. LogManager.shared.log(category: .apns, message: errorMessage)
  288. completion(false, errorMessage)
  289. return
  290. }
  291. var request = URLRequest(url: requestURL)
  292. request.httpMethod = "POST"
  293. request.setValue("application/json", forHTTPHeaderField: "content-type")
  294. request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
  295. request.setValue(bundleIdentifier, forHTTPHeaderField: "apns-topic")
  296. request.setValue("alert", forHTTPHeaderField: "apns-push-type")
  297. request.setValue("10", forHTTPHeaderField: "apns-priority") // High priority
  298. // Validate bundle identifier format
  299. if !bundleIdentifier.contains(".") {
  300. LogManager.shared.log(category: .apns, message: "Warning: Bundle identifier may be in wrong format: \(bundleIdentifier)")
  301. }
  302. // Create the proper APNS payload structure (matching @parse/node-apn format)
  303. var apnsPayload: [String: Any] = [
  304. "aps": [
  305. "alert": payload["alert"] as? String ?? "",
  306. "content-available": 1,
  307. "interruption-level": "time-sensitive",
  308. ],
  309. ]
  310. // Add all the custom payload fields (excluding APNS-specific fields)
  311. for (key, value) in payload {
  312. if key != "alert", key != "content-available", key != "interruption-level" {
  313. apnsPayload[key] = value
  314. }
  315. }
  316. // Remove nil values to clean up the payload
  317. let cleanPayload = apnsPayload.compactMapValues { $0 }
  318. do {
  319. let jsonData = try JSONSerialization.data(withJSONObject: cleanPayload)
  320. request.httpBody = jsonData
  321. let task = URLSession.shared.dataTask(with: request) { data, response, error in
  322. if let error = error {
  323. let errorMessage = "Failed to send push notification: \(error.localizedDescription)"
  324. LogManager.shared.log(category: .apns, message: errorMessage)
  325. completion(false, errorMessage)
  326. return
  327. }
  328. if let httpResponse = response as? HTTPURLResponse {
  329. var responseBodyMessage = ""
  330. if let data = data, let responseBody = String(data: data, encoding: .utf8) {
  331. if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
  332. let reason = json["reason"] as? String
  333. {
  334. responseBodyMessage = reason
  335. }
  336. }
  337. switch httpResponse.statusCode {
  338. case 200:
  339. LogManager.shared.log(category: .apns, message: "APNS notification sent successfully")
  340. completion(true, nil)
  341. case 400:
  342. let environmentGuidance = self.getEnvironmentGuidance()
  343. let errorMessage = "Bad request. The request was invalid or malformed. \(responseBodyMessage)\n\n\(environmentGuidance)"
  344. LogManager.shared.log(category: .apns, message: "APNS error 400: \(responseBodyMessage) - Check device token and environment settings")
  345. completion(false, errorMessage)
  346. case 403:
  347. let errorMessage = "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)"
  348. LogManager.shared.log(category: .apns, message: "APNS error 403: \(responseBodyMessage) - Check APNS key permissions for bundle ID")
  349. completion(false, errorMessage)
  350. case 404:
  351. let errorMessage = "Invalid request: The :path value was incorrect. \(responseBodyMessage)"
  352. LogManager.shared.log(category: .apns, message: "APNS error 404: \(responseBodyMessage)")
  353. completion(false, errorMessage)
  354. case 405:
  355. let errorMessage = "Invalid request: Only POST requests are supported. \(responseBodyMessage)"
  356. LogManager.shared.log(category: .apns, message: "APNS error 405: \(responseBodyMessage)")
  357. completion(false, errorMessage)
  358. case 410:
  359. let errorMessage = "The device token is no longer active for the topic. \(responseBodyMessage)"
  360. LogManager.shared.log(category: .apns, message: "APNS error 410: Device token is invalid or expired")
  361. completion(false, errorMessage)
  362. case 413:
  363. let errorMessage = "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)"
  364. LogManager.shared.log(category: .apns, message: "APNS error 413: \(responseBodyMessage)")
  365. completion(false, errorMessage)
  366. case 429:
  367. let errorMessage = "Too many requests. \(responseBodyMessage)"
  368. LogManager.shared.log(category: .apns, message: "APNS error 429: Rate limited - wait before retrying")
  369. completion(false, errorMessage)
  370. case 500:
  371. let errorMessage = "Internal server error at APNs. \(responseBodyMessage)"
  372. LogManager.shared.log(category: .apns, message: "APNS error 500: \(responseBodyMessage)")
  373. completion(false, errorMessage)
  374. case 503:
  375. let errorMessage = "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)"
  376. LogManager.shared.log(category: .apns, message: "APNS error 503: \(responseBodyMessage)")
  377. completion(false, errorMessage)
  378. default:
  379. let errorMessage = "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)"
  380. LogManager.shared.log(category: .apns, message: "APNS error \(httpResponse.statusCode): \(responseBodyMessage)")
  381. completion(false, errorMessage)
  382. }
  383. } else {
  384. let errorMessage = "Failed to get a valid HTTP response."
  385. LogManager.shared.log(category: .apns, message: errorMessage)
  386. completion(false, errorMessage)
  387. }
  388. }
  389. task.resume()
  390. } catch {
  391. let errorMessage = "Failed to serialize APNS payload: \(error.localizedDescription)"
  392. LogManager.shared.log(category: .apns, message: errorMessage)
  393. completion(false, errorMessage)
  394. }
  395. }
  396. /// Validates and fixes APNS key format if needed
  397. /// - Parameter key: The APNS key to validate and fix
  398. /// - Returns: The fixed APNS key
  399. func validateAndFixAPNSKey(_ key: String) -> String {
  400. // Normalize: replace all literal \n with real newlines
  401. var fixedKey = key.replacingOccurrences(of: "\\n", with: "\n")
  402. // Strip leading/trailing quotes
  403. fixedKey = fixedKey.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
  404. // Check if the key has proper line breaks
  405. if !fixedKey.contains("\n") {
  406. LogManager.shared.log(category: .apns, message: "APNS Key missing line breaks, attempting to fix format")
  407. // Try to add line breaks if the key is all on one line
  408. if fixedKey.contains("-----BEGIN PRIVATE KEY-----") && fixedKey.contains("-----END PRIVATE KEY-----") {
  409. // Find the positions of the headers
  410. if let beginRange = fixedKey.range(of: "-----BEGIN PRIVATE KEY-----"),
  411. let endRange = fixedKey.range(of: "-----END PRIVATE KEY-----")
  412. {
  413. let beginIndex = fixedKey.index(beginRange.upperBound, offsetBy: 0)
  414. let endIndex = endRange.lowerBound
  415. if beginIndex < endIndex {
  416. let header = String(fixedKey[..<beginIndex])
  417. let keyData = String(fixedKey[beginIndex ..< endIndex])
  418. let footer = String(fixedKey[endIndex...])
  419. // Clean up the key data - remove any whitespace and split into 64-character lines
  420. let cleanKeyData = keyData.replacingOccurrences(of: " ", with: "")
  421. .replacingOccurrences(of: "\t", with: "")
  422. .replacingOccurrences(of: "\n", with: "")
  423. .replacingOccurrences(of: "\r", with: "")
  424. // Validate the key data length (should be 44 characters for P-256)
  425. LogManager.shared.log(category: .apns, message: "Key data validation - Length: \(cleanKeyData.count) characters")
  426. if cleanKeyData.count != 44 {
  427. LogManager.shared.log(category: .apns, message: "WARNING: Key data length is \(cleanKeyData.count), expected 44 for P-256 private key")
  428. }
  429. // Validate base64 format
  430. if Data(base64Encoded: cleanKeyData) == nil {
  431. LogManager.shared.log(category: .apns, message: "WARNING: Key data is not valid base64")
  432. }
  433. // Split into 64-character lines (standard PEM format)
  434. var formattedKeyData = ""
  435. var currentLine = ""
  436. for char in cleanKeyData {
  437. currentLine.append(char)
  438. if currentLine.count == 64 {
  439. formattedKeyData += currentLine + "\n"
  440. currentLine = ""
  441. }
  442. }
  443. // Add any remaining characters
  444. if !currentLine.isEmpty {
  445. formattedKeyData += currentLine
  446. }
  447. fixedKey = "\(header)\n\(formattedKeyData)\n\(footer)"
  448. LogManager.shared.log(category: .apns, message: "APNS Key format fixed - added proper line breaks")
  449. LogManager.shared.log(category: .apns, message: "Key data length: \(cleanKeyData.count) characters")
  450. LogManager.shared.log(category: .apns, message: "Formatted key lines: \(formattedKeyData.components(separatedBy: "\n").count)")
  451. }
  452. }
  453. }
  454. } else {
  455. // Key already has line breaks, but let's ensure proper formatting
  456. let lines = fixedKey.components(separatedBy: .newlines)
  457. var cleanedLines: [String] = []
  458. for line in lines {
  459. let cleanedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
  460. if !cleanedLine.isEmpty {
  461. cleanedLines.append(cleanedLine)
  462. }
  463. }
  464. // Reconstruct with proper formatting
  465. if cleanedLines.count > 2 {
  466. let header = cleanedLines[0]
  467. let footer = cleanedLines[cleanedLines.count - 1]
  468. let keyLines = Array(cleanedLines[1 ..< (cleanedLines.count - 1)])
  469. // Combine all key data lines and validate
  470. let combinedKeyData = keyLines.joined()
  471. LogManager.shared.log(category: .apns, message: "Combined key data length: \(combinedKeyData.count) characters")
  472. // Validate the key data length (should be 44 characters for P-256)
  473. if combinedKeyData.count != 44 {
  474. LogManager.shared.log(category: .apns, message: "WARNING: Combined key data length is \(combinedKeyData.count), expected 44 for P-256 private key")
  475. }
  476. // Validate base64 format
  477. if Data(base64Encoded: combinedKeyData) == nil {
  478. LogManager.shared.log(category: .apns, message: "WARNING: Combined key data is not valid base64")
  479. }
  480. // Ensure key lines are properly formatted (64 characters each)
  481. var formattedKeyLines: [String] = []
  482. var currentLine = ""
  483. for line in keyLines {
  484. let cleanLine = line.replacingOccurrences(of: " ", with: "")
  485. .replacingOccurrences(of: "\t", with: "")
  486. for char in cleanLine {
  487. currentLine.append(char)
  488. if currentLine.count == 64 {
  489. formattedKeyLines.append(currentLine)
  490. currentLine = ""
  491. }
  492. }
  493. }
  494. // Add any remaining characters
  495. if !currentLine.isEmpty {
  496. formattedKeyLines.append(currentLine)
  497. }
  498. fixedKey = "\(header)\n\(formattedKeyLines.joined(separator: "\n"))\n\(footer)"
  499. LogManager.shared.log(category: .apns, message: "APNS Key reformatted - cleaned up existing line breaks")
  500. }
  501. }
  502. return fixedKey
  503. }
  504. /// Extracts key data from PEM format
  505. /// - Parameter pemString: The PEM formatted private key
  506. /// - Returns: The extracted key data string
  507. private func extractKeyData(from pemString: String) -> String? {
  508. let lines = pemString.components(separatedBy: "\n")
  509. guard let startIndex = lines.firstIndex(of: "-----BEGIN PRIVATE KEY-----"),
  510. let endIndex = lines.firstIndex(of: "-----END PRIVATE KEY-----"),
  511. startIndex < endIndex
  512. else {
  513. return nil
  514. }
  515. let keyLines = lines[(startIndex + 1) ..< endIndex]
  516. return keyLines.joined()
  517. }
  518. // MARK: - Date Formatting Helper
  519. /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format)
  520. /// - Parameter date: The date to format
  521. /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z"
  522. private func formatDateForAPNS(_ date: Date) -> String {
  523. let dateFormatter = ISO8601DateFormatter()
  524. dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  525. return dateFormatter.string(from: date)
  526. }
  527. // MARK: - Override Methods
  528. func sendOverrideNotification(presetName: String, duration: TimeInterval? = nil, completion: @escaping (Bool, String?) -> Void) {
  529. let deviceToken = Storage.shared.deviceToken.value
  530. guard !deviceToken.isEmpty else {
  531. let errorMessage = "Device token not configured"
  532. LogManager.shared.log(category: .apns, message: errorMessage)
  533. completion(false, errorMessage)
  534. return
  535. }
  536. let bundleIdentifier = Storage.shared.bundleId.value
  537. guard !bundleIdentifier.isEmpty else {
  538. let errorMessage = "Bundle identifier not configured"
  539. LogManager.shared.log(category: .apns, message: errorMessage)
  540. completion(false, errorMessage)
  541. return
  542. }
  543. // Create APNS notification payload (matching Loop's expected format)
  544. let now = Date()
  545. let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now
  546. // Create alert text (matching Nightscout's format)
  547. var alertText = "\(presetName) Temporary Override"
  548. if let duration = duration, duration > 0 {
  549. let hours = Int(duration / 3600)
  550. let minutes = Int((duration.truncatingRemainder(dividingBy: 3600)) / 60)
  551. if hours > 0 {
  552. alertText += " (\(hours)h \(minutes)m)"
  553. } else {
  554. alertText += " (\(minutes)m)"
  555. }
  556. }
  557. var payload: [String: Any] = [
  558. "override-name": presetName,
  559. "remote-address": "LoopFollow",
  560. "entered-by": "LoopFollow",
  561. "sent-at": formatDateForAPNS(now),
  562. "expiration": formatDateForAPNS(expiration),
  563. "alert": alertText,
  564. ]
  565. if let duration = duration, duration > 0 {
  566. payload["override-duration-minutes"] = Int(duration / 60)
  567. }
  568. if let returnInfo = createReturnNotificationInfo() {
  569. payload["return_notification"] = returnInfo
  570. }
  571. // Send the notification using the existing APNS infrastructure
  572. sendAPNSNotification(
  573. deviceToken: deviceToken,
  574. bundleIdentifier: bundleIdentifier,
  575. keyId: storage.keyId.value,
  576. apnsKey: storage.apnsKey.value,
  577. payload: payload,
  578. completion: completion
  579. )
  580. }
  581. func sendCancelOverrideNotification(completion: @escaping (Bool, String?) -> Void) {
  582. let deviceToken = Storage.shared.deviceToken.value
  583. guard !deviceToken.isEmpty else {
  584. let errorMessage = "Device token not configured"
  585. LogManager.shared.log(category: .apns, message: errorMessage)
  586. completion(false, errorMessage)
  587. return
  588. }
  589. let bundleIdentifier = Storage.shared.bundleId.value
  590. guard !bundleIdentifier.isEmpty else {
  591. let errorMessage = "Bundle identifier not configured"
  592. LogManager.shared.log(category: .apns, message: errorMessage)
  593. completion(false, errorMessage)
  594. return
  595. }
  596. // Create APNS notification payload (matching Loop's expected format)
  597. let now = Date()
  598. let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now
  599. var payload: [String: Any] = [
  600. "cancel-temporary-override": "true",
  601. "remote-address": "LoopFollow",
  602. "entered-by": "LoopFollow",
  603. "sent-at": formatDateForAPNS(now),
  604. "expiration": formatDateForAPNS(expiration),
  605. "alert": "Cancel Temporary Override",
  606. ]
  607. if let returnInfo = createReturnNotificationInfo() {
  608. payload["return_notification"] = returnInfo
  609. }
  610. // Send the notification using the existing APNS infrastructure
  611. sendAPNSNotification(
  612. deviceToken: deviceToken,
  613. bundleIdentifier: bundleIdentifier,
  614. keyId: storage.keyId.value,
  615. apnsKey: storage.apnsKey.value,
  616. payload: payload,
  617. completion: completion
  618. )
  619. }
  620. }