PushNotificationManager.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. // LoopFollow
  2. // PushNotificationManager.swift
  3. import Foundation
  4. import HealthKit
  5. class PushNotificationManager {
  6. private var deviceToken: String
  7. private var sharedSecret: String
  8. private var productionEnvironment: Bool
  9. private var apnsKey: String
  10. private var teamId: String
  11. private var keyId: String
  12. private var user: String
  13. private var bundleId: String
  14. init() {
  15. deviceToken = Storage.shared.deviceToken.value
  16. sharedSecret = Storage.shared.sharedSecret.value
  17. productionEnvironment = Storage.shared.productionEnvironment.value
  18. user = Storage.shared.user.value
  19. bundleId = Storage.shared.bundleId.value
  20. let lfTeamId = BuildDetails.default.teamID ?? ""
  21. let remoteTeamId = Storage.shared.teamId.value ?? ""
  22. let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId
  23. if sameTeam || remoteTeamId.isEmpty {
  24. apnsKey = Storage.shared.lfApnsKey.value
  25. keyId = Storage.shared.lfKeyId.value
  26. teamId = lfTeamId
  27. } else {
  28. apnsKey = Storage.shared.remoteApnsKey.value
  29. keyId = Storage.shared.remoteKeyId.value
  30. teamId = remoteTeamId
  31. }
  32. }
  33. private func createReturnNotificationInfo() -> CommandPayload.ReturnNotificationInfo? {
  34. let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value
  35. guard !loopFollowDeviceToken.isEmpty else {
  36. return nil
  37. }
  38. guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else {
  39. LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.")
  40. return nil
  41. }
  42. let lfKeyId = Storage.shared.lfKeyId.value
  43. let lfApnsKey = Storage.shared.lfApnsKey.value
  44. guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else {
  45. LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.")
  46. return nil
  47. }
  48. return CommandPayload.ReturnNotificationInfo(
  49. productionEnvironment: BuildDetails.default.isTestFlightBuild(),
  50. deviceToken: loopFollowDeviceToken,
  51. bundleId: Bundle.main.bundleIdentifier ?? "",
  52. teamId: loopFollowTeamID,
  53. keyId: lfKeyId,
  54. apnsKey: lfApnsKey
  55. )
  56. }
  57. func sendOverridePushNotification(override: ProfileManager.TrioOverride, completion: @escaping (Bool, String?) -> Void) {
  58. let payload = CommandPayload(
  59. user: user,
  60. commandType: .startOverride,
  61. timestamp: Date().timeIntervalSince1970,
  62. overrideName: override.name,
  63. returnNotification: createReturnNotificationInfo()
  64. )
  65. sendEncryptedCommand(payload: payload, completion: completion)
  66. }
  67. func sendCancelOverridePushNotification(completion: @escaping (Bool, String?) -> Void) {
  68. let payload = CommandPayload(
  69. user: user,
  70. commandType: .cancelOverride,
  71. timestamp: Date().timeIntervalSince1970,
  72. overrideName: nil,
  73. returnNotification: createReturnNotificationInfo()
  74. )
  75. sendEncryptedCommand(payload: payload, completion: completion)
  76. }
  77. func sendBolusPushNotification(bolusAmount: HKQuantity, completion: @escaping (Bool, String?) -> Void) {
  78. let bolusAmountDecimal = Decimal(bolusAmount.doubleValue(for: .internationalUnit()))
  79. let payload = CommandPayload(
  80. user: user,
  81. commandType: .bolus,
  82. timestamp: Date().timeIntervalSince1970,
  83. bolusAmount: bolusAmountDecimal,
  84. returnNotification: createReturnNotificationInfo()
  85. )
  86. sendEncryptedCommand(payload: payload, completion: completion)
  87. }
  88. func sendTempTargetPushNotification(target: HKQuantity, duration: HKQuantity, completion: @escaping (Bool, String?) -> Void) {
  89. let targetValue = Int(target.doubleValue(for: HKUnit.milligramsPerDeciliter))
  90. let durationValue = Int(duration.doubleValue(for: HKUnit.minute()))
  91. let payload = CommandPayload(
  92. user: user,
  93. commandType: .tempTarget,
  94. timestamp: Date().timeIntervalSince1970,
  95. target: targetValue,
  96. duration: durationValue,
  97. returnNotification: createReturnNotificationInfo()
  98. )
  99. sendEncryptedCommand(payload: payload, completion: completion)
  100. }
  101. func sendCancelTempTargetPushNotification(completion: @escaping (Bool, String?) -> Void) {
  102. let payload = CommandPayload(
  103. user: user,
  104. commandType: .cancelTempTarget,
  105. timestamp: Date().timeIntervalSince1970,
  106. returnNotification: createReturnNotificationInfo()
  107. )
  108. sendEncryptedCommand(payload: payload, completion: completion)
  109. }
  110. func sendMealPushNotification(
  111. carbs: HKQuantity,
  112. protein: HKQuantity,
  113. fat: HKQuantity,
  114. bolusAmount: HKQuantity,
  115. scheduledTime: Date?,
  116. completion: @escaping (Bool, String?) -> Void
  117. ) {
  118. func convertToOptionalInt(_ quantity: HKQuantity) -> Int? {
  119. let valueInGrams = quantity.doubleValue(for: .gram())
  120. return valueInGrams > 0 ? Int(valueInGrams) : nil
  121. }
  122. func convertToOptionalDecimal(_ quantity: HKQuantity?) -> Decimal? {
  123. guard let quantity = quantity else { return nil }
  124. let value = quantity.doubleValue(for: .internationalUnit())
  125. return value > 0 ? Decimal(value) : nil
  126. }
  127. let carbsValue = convertToOptionalInt(carbs)
  128. let proteinValue = convertToOptionalInt(protein)
  129. let fatValue = convertToOptionalInt(fat)
  130. let scheduledTimeInterval: TimeInterval? = scheduledTime?.timeIntervalSince1970
  131. let bolusAmountValue = convertToOptionalDecimal(bolusAmount)
  132. guard carbsValue != nil || proteinValue != nil || fatValue != nil else {
  133. completion(false, "No nutrient data provided. At least one of carbs, fat, or protein must be greater than 0.")
  134. return
  135. }
  136. let payload = CommandPayload(
  137. user: user,
  138. commandType: .meal,
  139. timestamp: Date().timeIntervalSince1970,
  140. bolusAmount: bolusAmountValue,
  141. carbs: carbsValue,
  142. protein: proteinValue,
  143. fat: fatValue,
  144. scheduledTime: scheduledTimeInterval,
  145. returnNotification: createReturnNotificationInfo()
  146. )
  147. sendEncryptedCommand(payload: payload, completion: completion)
  148. }
  149. private func validateCredentials() -> [String]? {
  150. var errors = [String]()
  151. let keyIdPattern = "^[A-Z0-9]{10}$"
  152. if !matchesRegex(keyId, pattern: keyIdPattern) {
  153. errors.append("APNS Key ID (\(keyId)) must be 10 uppercase alphanumeric characters.")
  154. }
  155. let teamIdPattern = "^[A-Z0-9]{10}$"
  156. if !matchesRegex(teamId, pattern: teamIdPattern) {
  157. errors.append("Team ID (\(teamId)) must be 10 uppercase alphanumeric characters.")
  158. }
  159. if !apnsKey.contains("-----BEGIN PRIVATE KEY-----") || !apnsKey.contains("-----END PRIVATE KEY-----") {
  160. errors.append("APNS Key must be a valid PEM-formatted private key.")
  161. } else {
  162. if let keyData = extractKeyData(from: apnsKey) {
  163. if Data(base64Encoded: keyData) == nil {
  164. errors.append("APNS Key contains invalid Base64 key data.")
  165. }
  166. } else {
  167. errors.append("APNS Key has invalid formatting.")
  168. }
  169. }
  170. return errors.isEmpty ? nil : errors
  171. }
  172. private func matchesRegex(_ text: String, pattern: String) -> Bool {
  173. let regex = try? NSRegularExpression(pattern: pattern)
  174. let range = NSRange(location: 0, length: text.utf16.count)
  175. return regex?.firstMatch(in: text, options: [], range: range) != nil
  176. }
  177. private func extractKeyData(from pemString: String) -> String? {
  178. let lines = pemString.components(separatedBy: "\n")
  179. guard let startIndex = lines.firstIndex(of: "-----BEGIN PRIVATE KEY-----"),
  180. let endIndex = lines.firstIndex(of: "-----END PRIVATE KEY-----"),
  181. startIndex < endIndex
  182. else {
  183. return nil
  184. }
  185. let keyLines = lines[(startIndex + 1) ..< endIndex]
  186. return keyLines.joined()
  187. }
  188. private func sendEncryptedCommand(payload: CommandPayload, completion: @escaping (Bool, String?) -> Void) {
  189. var missingFields = [String]()
  190. if sharedSecret.isEmpty { missingFields.append("sharedSecret") }
  191. if apnsKey.isEmpty { missingFields.append("apnsKey") }
  192. if keyId.isEmpty { missingFields.append("keyId") }
  193. if user.isEmpty { missingFields.append("user") }
  194. if deviceToken.isEmpty { missingFields.append("deviceToken") }
  195. if bundleId.isEmpty { missingFields.append("bundleId") }
  196. if teamId.isEmpty { missingFields.append("teamId") }
  197. if !missingFields.isEmpty {
  198. let errorMessage = "Missing required fields for command: \(missingFields.joined(separator: ", "))"
  199. LogManager.shared.log(category: .apns, message: errorMessage)
  200. completion(false, errorMessage)
  201. return
  202. }
  203. if let validationErrors = validateCredentials() {
  204. let errorMessage = "Credential validation failed: \(validationErrors.joined(separator: ", "))"
  205. LogManager.shared.log(category: .apns, message: errorMessage)
  206. completion(false, errorMessage)
  207. return
  208. }
  209. guard let url = constructAPNsURL() else {
  210. let errorMessage = "Failed to construct APNs URL"
  211. LogManager.shared.log(category: .apns, message: errorMessage)
  212. completion(false, errorMessage)
  213. return
  214. }
  215. guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else {
  216. let errorMessage = "Failed to generate JWT, please check that the token is correct."
  217. LogManager.shared.log(category: .apns, message: errorMessage)
  218. completion(false, errorMessage)
  219. return
  220. }
  221. do {
  222. guard let messenger = SecureMessenger(sharedSecret: sharedSecret) else {
  223. let errorMessage = "Failed to initialize security module. Check shared secret."
  224. LogManager.shared.log(category: .apns, message: errorMessage)
  225. completion(false, errorMessage)
  226. return
  227. }
  228. let encryptedDataString = try messenger.encrypt(payload)
  229. let finalMessage = EncryptedPushMessage(encryptedData: encryptedDataString, commandType: payload.commandType)
  230. var request = URLRequest(url: url)
  231. request.httpMethod = "POST"
  232. request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
  233. request.setValue("application/json", forHTTPHeaderField: "content-type")
  234. request.setValue("10", forHTTPHeaderField: "apns-priority")
  235. request.setValue("600", forHTTPHeaderField: "apns-expiration")
  236. request.setValue(bundleId, forHTTPHeaderField: "apns-topic")
  237. request.setValue("alert", forHTTPHeaderField: "apns-push-type")
  238. request.setValue(payload.commandType.rawValue, forHTTPHeaderField: "apns-collapse-id")
  239. request.httpBody = try JSONEncoder().encode(finalMessage)
  240. let task = URLSession.shared.dataTask(with: request) { data, response, error in
  241. if let error = error {
  242. let errorMessage = "Failed to send push notification: \(error.localizedDescription)"
  243. LogManager.shared.log(category: .apns, message: errorMessage)
  244. completion(false, errorMessage)
  245. return
  246. }
  247. if let httpResponse = response as? HTTPURLResponse {
  248. LogManager.shared.log(category: .apns, message: "Push notification sent. Status code: \(httpResponse.statusCode)", isDebug: true)
  249. var responseBodyMessage = ""
  250. if let data = data, let responseBody = String(data: data, encoding: .utf8) {
  251. LogManager.shared.log(category: .apns, message: "Response body: \(responseBody)", isDebug: true)
  252. if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
  253. let reason = json["reason"] as? String
  254. {
  255. responseBodyMessage = reason
  256. }
  257. }
  258. switch httpResponse.statusCode {
  259. case 200:
  260. completion(true, nil)
  261. case 400:
  262. completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)")
  263. case 403:
  264. JWTManager.shared.invalidateCache()
  265. completion(false, "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)")
  266. case 404:
  267. completion(false, "Invalid request: The :path value was incorrect. \(responseBodyMessage)")
  268. case 405:
  269. completion(false, "Invalid request: Only POST requests are supported. \(responseBodyMessage)")
  270. case 410:
  271. completion(false, "The device token is no longer active for the topic. \(responseBodyMessage)")
  272. case 413:
  273. completion(false, "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)")
  274. case 429:
  275. completion(false, "Too many requests. \(responseBodyMessage)")
  276. case 500:
  277. completion(false, "Internal server error at APNs. \(responseBodyMessage)")
  278. case 503:
  279. completion(false, "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)")
  280. default:
  281. completion(false, "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)")
  282. }
  283. } else {
  284. completion(false, "Failed to get a valid HTTP response.")
  285. }
  286. }
  287. task.resume()
  288. } catch {
  289. let errorMessage = "Failed to encode or encrypt push message: \(error.localizedDescription)"
  290. LogManager.shared.log(category: .apns, message: errorMessage)
  291. completion(false, errorMessage)
  292. }
  293. }
  294. private func constructAPNsURL() -> URL? {
  295. let host = productionEnvironment ? "api.push.apple.com" : "api.sandbox.push.apple.com"
  296. let urlString = "https://\(host)/3/device/\(deviceToken)"
  297. return URL(string: urlString)
  298. }
  299. }