LoopAPNSService.swift 29 KB

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