PushNotificationManager.swift 15 KB

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