PushNotificationManager.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. // LoopFollow
  2. // PushNotificationManager.swift
  3. // Created by Jonas Björkert.
  4. import Foundation
  5. import HealthKit
  6. import SwiftJWT
  7. class PushNotificationManager {
  8. private var deviceToken: String
  9. private var sharedSecret: String
  10. private var productionEnvironment: Bool
  11. private var apnsKey: String
  12. private var teamId: String
  13. private var keyId: String
  14. private var user: String
  15. private var bundleId: String
  16. init() {
  17. deviceToken = Storage.shared.deviceToken.value
  18. sharedSecret = Storage.shared.sharedSecret.value
  19. productionEnvironment = Storage.shared.productionEnvironment.value
  20. apnsKey = Storage.shared.apnsKey.value
  21. teamId = Storage.shared.teamId.value ?? ""
  22. keyId = Storage.shared.keyId.value
  23. user = Storage.shared.user.value
  24. bundleId = Storage.shared.bundleId.value
  25. }
  26. private func createReturnNotificationInfo() -> CommandPayload.ReturnNotificationInfo? {
  27. let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value
  28. guard !loopFollowDeviceToken.isEmpty else {
  29. return nil
  30. }
  31. guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else {
  32. LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.")
  33. return nil
  34. }
  35. let teamIdsAreDifferent = loopFollowTeamID != teamId
  36. let keyIdForReturn: String
  37. let apnsKeyForReturn: String
  38. if teamIdsAreDifferent {
  39. keyIdForReturn = Storage.shared.returnKeyId.value
  40. apnsKeyForReturn = Storage.shared.returnApnsKey.value
  41. } else {
  42. keyIdForReturn = keyId
  43. apnsKeyForReturn = apnsKey
  44. }
  45. guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else {
  46. LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.")
  47. return nil
  48. }
  49. return CommandPayload.ReturnNotificationInfo(
  50. productionEnvironment: BuildDetails.default.isTestFlightBuild(),
  51. deviceToken: loopFollowDeviceToken,
  52. bundleId: Bundle.main.bundleIdentifier ?? "",
  53. teamId: loopFollowTeamID,
  54. keyId: keyIdForReturn,
  55. apnsKey: apnsKeyForReturn
  56. )
  57. }
  58. func sendOverridePushNotification(override: ProfileManager.TrioOverride, completion: @escaping (Bool, String?) -> Void) {
  59. let payload = CommandPayload(
  60. user: user,
  61. commandType: .startOverride,
  62. timestamp: Date().timeIntervalSince1970,
  63. overrideName: override.name,
  64. returnNotification: createReturnNotificationInfo()
  65. )
  66. sendEncryptedCommand(payload: payload, completion: completion)
  67. }
  68. func sendCancelOverridePushNotification(completion: @escaping (Bool, String?) -> Void) {
  69. let payload = CommandPayload(
  70. user: user,
  71. commandType: .cancelOverride,
  72. timestamp: Date().timeIntervalSince1970,
  73. overrideName: nil,
  74. returnNotification: createReturnNotificationInfo()
  75. )
  76. sendEncryptedCommand(payload: payload, completion: completion)
  77. }
  78. func sendBolusPushNotification(bolusAmount: HKQuantity, completion: @escaping (Bool, String?) -> Void) {
  79. let bolusAmountDecimal = Decimal(bolusAmount.doubleValue(for: .internationalUnit()))
  80. let payload = CommandPayload(
  81. user: user,
  82. commandType: .bolus,
  83. timestamp: Date().timeIntervalSince1970,
  84. bolusAmount: bolusAmountDecimal,
  85. returnNotification: createReturnNotificationInfo()
  86. )
  87. sendEncryptedCommand(payload: payload, completion: completion)
  88. }
  89. func sendTempTargetPushNotification(target: HKQuantity, duration: HKQuantity, completion: @escaping (Bool, String?) -> Void) {
  90. let targetValue = Int(target.doubleValue(for: HKUnit.milligramsPerDeciliter))
  91. let durationValue = Int(duration.doubleValue(for: HKUnit.minute()))
  92. let payload = CommandPayload(
  93. user: user,
  94. commandType: .tempTarget,
  95. timestamp: Date().timeIntervalSince1970,
  96. target: targetValue,
  97. duration: durationValue,
  98. returnNotification: createReturnNotificationInfo()
  99. )
  100. sendEncryptedCommand(payload: payload, completion: completion)
  101. }
  102. func sendCancelTempTargetPushNotification(completion: @escaping (Bool, String?) -> Void) {
  103. let payload = CommandPayload(
  104. user: user,
  105. commandType: .cancelTempTarget,
  106. timestamp: Date().timeIntervalSince1970,
  107. returnNotification: createReturnNotificationInfo()
  108. )
  109. sendEncryptedCommand(payload: payload, completion: completion)
  110. }
  111. func sendMealPushNotification(
  112. carbs: HKQuantity,
  113. protein: HKQuantity,
  114. fat: HKQuantity,
  115. bolusAmount: HKQuantity,
  116. scheduledTime: Date?,
  117. completion: @escaping (Bool, String?) -> Void
  118. ) {
  119. func convertToOptionalInt(_ quantity: HKQuantity) -> Int? {
  120. let valueInGrams = quantity.doubleValue(for: .gram())
  121. return valueInGrams > 0 ? Int(valueInGrams) : nil
  122. }
  123. func convertToOptionalDecimal(_ quantity: HKQuantity?) -> Decimal? {
  124. guard let quantity = quantity else { return nil }
  125. let value = quantity.doubleValue(for: .internationalUnit())
  126. return value > 0 ? Decimal(value) : nil
  127. }
  128. let carbsValue = convertToOptionalInt(carbs)
  129. let proteinValue = convertToOptionalInt(protein)
  130. let fatValue = convertToOptionalInt(fat)
  131. let scheduledTimeInterval: TimeInterval? = scheduledTime?.timeIntervalSince1970
  132. let bolusAmountValue = convertToOptionalDecimal(bolusAmount)
  133. guard carbsValue != nil || proteinValue != nil || fatValue != nil else {
  134. completion(false, "No nutrient data provided. At least one of carbs, fat, or protein must be greater than 0.")
  135. return
  136. }
  137. let payload = CommandPayload(
  138. user: user,
  139. commandType: .meal,
  140. timestamp: Date().timeIntervalSince1970,
  141. bolusAmount: bolusAmountValue,
  142. carbs: carbsValue,
  143. protein: proteinValue,
  144. fat: fatValue,
  145. scheduledTime: scheduledTimeInterval,
  146. returnNotification: createReturnNotificationInfo()
  147. )
  148. sendEncryptedCommand(payload: payload, completion: completion)
  149. }
  150. private func validateCredentials() -> [String]? {
  151. var errors = [String]()
  152. let keyIdPattern = "^[A-Z0-9]{10}$"
  153. if !matchesRegex(keyId, pattern: keyIdPattern) {
  154. errors.append("APNS Key ID (\(keyId)) must be 10 uppercase alphanumeric characters.")
  155. }
  156. let teamIdPattern = "^[A-Z0-9]{10}$"
  157. if !matchesRegex(teamId, pattern: teamIdPattern) {
  158. errors.append("Team ID (\(teamId)) must be 10 uppercase alphanumeric characters.")
  159. }
  160. if !apnsKey.contains("-----BEGIN PRIVATE KEY-----") || !apnsKey.contains("-----END PRIVATE KEY-----") {
  161. errors.append("APNS Key must be a valid PEM-formatted private key.")
  162. } else {
  163. if let keyData = extractKeyData(from: apnsKey) {
  164. if Data(base64Encoded: keyData) == nil {
  165. errors.append("APNS Key contains invalid Base64 key data.")
  166. }
  167. } else {
  168. errors.append("APNS Key has invalid formatting.")
  169. }
  170. }
  171. return errors.isEmpty ? nil : errors
  172. }
  173. private func matchesRegex(_ text: String, pattern: String) -> Bool {
  174. let regex = try? NSRegularExpression(pattern: pattern)
  175. let range = NSRange(location: 0, length: text.utf16.count)
  176. return regex?.firstMatch(in: text, options: [], range: range) != nil
  177. }
  178. private func extractKeyData(from pemString: String) -> String? {
  179. let lines = pemString.components(separatedBy: "\n")
  180. guard let startIndex = lines.firstIndex(of: "-----BEGIN PRIVATE KEY-----"),
  181. let endIndex = lines.firstIndex(of: "-----END PRIVATE KEY-----"),
  182. startIndex < endIndex
  183. else {
  184. return nil
  185. }
  186. let keyLines = lines[(startIndex + 1) ..< endIndex]
  187. return keyLines.joined()
  188. }
  189. private func sendEncryptedCommand(payload: CommandPayload, completion: @escaping (Bool, String?) -> Void) {
  190. var missingFields = [String]()
  191. if sharedSecret.isEmpty { missingFields.append("sharedSecret") }
  192. if apnsKey.isEmpty { missingFields.append("apnsKey") }
  193. if keyId.isEmpty { missingFields.append("keyId") }
  194. if user.isEmpty { missingFields.append("user") }
  195. if deviceToken.isEmpty { missingFields.append("deviceToken") }
  196. if bundleId.isEmpty { missingFields.append("bundleId") }
  197. if teamId.isEmpty { missingFields.append("teamId") }
  198. if !missingFields.isEmpty {
  199. let errorMessage = "Missing required fields for command: \(missingFields.joined(separator: ", "))"
  200. LogManager.shared.log(category: .apns, message: errorMessage)
  201. completion(false, errorMessage)
  202. return
  203. }
  204. if let validationErrors = validateCredentials() {
  205. let errorMessage = "Credential validation failed: \(validationErrors.joined(separator: ", "))"
  206. LogManager.shared.log(category: .apns, message: errorMessage)
  207. completion(false, errorMessage)
  208. return
  209. }
  210. guard let url = constructAPNsURL() else {
  211. let errorMessage = "Failed to construct APNs URL"
  212. LogManager.shared.log(category: .apns, message: errorMessage)
  213. completion(false, errorMessage)
  214. return
  215. }
  216. guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else {
  217. let errorMessage = "Failed to generate JWT, please check that the token is correct."
  218. LogManager.shared.log(category: .apns, message: errorMessage)
  219. completion(false, errorMessage)
  220. return
  221. }
  222. do {
  223. guard let messenger = SecureMessenger(sharedSecret: sharedSecret) else {
  224. let errorMessage = "Failed to initialize security module. Check shared secret."
  225. LogManager.shared.log(category: .apns, message: errorMessage)
  226. completion(false, errorMessage)
  227. return
  228. }
  229. let encryptedDataString = try messenger.encrypt(payload)
  230. let finalMessage = EncryptedPushMessage(encryptedData: encryptedDataString)
  231. var request = URLRequest(url: url)
  232. request.httpMethod = "POST"
  233. request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
  234. request.setValue("application/json", forHTTPHeaderField: "content-type")
  235. request.setValue("10", forHTTPHeaderField: "apns-priority")
  236. request.setValue("0", forHTTPHeaderField: "apns-expiration")
  237. request.setValue(bundleId, forHTTPHeaderField: "apns-topic")
  238. request.setValue("background", forHTTPHeaderField: "apns-push-type")
  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. print("Push notification sent.")
  249. print("Status code: \(httpResponse.statusCode)")
  250. var responseBodyMessage = ""
  251. if let data = data, let responseBody = String(data: data, encoding: .utf8) {
  252. print("Response body: \(responseBody)")
  253. if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
  254. let reason = json["reason"] as? String
  255. {
  256. responseBodyMessage = reason
  257. }
  258. }
  259. switch httpResponse.statusCode {
  260. case 200:
  261. completion(true, nil)
  262. case 400:
  263. completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)")
  264. case 403:
  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. }