LoopAPNSService.swift 36 KB

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