PushNotificationManager.swift 16 KB

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