PushNotificationManager.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. //
  2. // PushNotificationManager.swift
  3. // LoopFollow
  4. //
  5. // Created by Jonas Björkert on 2024-08-27.
  6. // Copyright © 2024 Jon Fawcett. All rights reserved.
  7. //
  8. import Foundation
  9. import SwiftJWT
  10. import HealthKit
  11. struct APNsJWTClaims: Claims {
  12. let iss: String
  13. let iat: Date
  14. }
  15. class PushNotificationManager {
  16. private var deviceToken: String
  17. private var sharedSecret: String
  18. private var productionEnvironment: Bool
  19. private var apnsKey: String
  20. private var teamId: String
  21. private var keyId: String
  22. private var user: String
  23. private var bundleId: String
  24. init() {
  25. self.deviceToken = Storage.shared.deviceToken.value
  26. self.sharedSecret = Storage.shared.sharedSecret.value
  27. self.productionEnvironment = Storage.shared.productionEnvironment.value
  28. self.apnsKey = Storage.shared.apnsKey.value
  29. self.teamId = Storage.shared.teamId.value ?? ""
  30. self.keyId = Storage.shared.keyId.value
  31. self.user = Storage.shared.user.value
  32. self.bundleId = Storage.shared.bundleId.value
  33. }
  34. func sendOverridePushNotification(override: ProfileManager.TrioOverride, completion: @escaping (Bool, String?) -> Void) {
  35. let message = PushMessage(
  36. user: user,
  37. commandType: .startOverride,
  38. sharedSecret: sharedSecret,
  39. timestamp: Date().timeIntervalSince1970,
  40. overrideName: override.name
  41. )
  42. sendPushNotification(message: message, completion: completion)
  43. }
  44. func sendCancelOverridePushNotification(completion: @escaping (Bool, String?) -> Void) {
  45. let message = PushMessage(
  46. user: user,
  47. commandType: .cancelOverride,
  48. sharedSecret: sharedSecret,
  49. timestamp: Date().timeIntervalSince1970,
  50. overrideName: nil
  51. )
  52. sendPushNotification(message: message, completion: completion)
  53. }
  54. func sendBolusPushNotification(bolusAmount: HKQuantity, completion: @escaping (Bool, String?) -> Void) {
  55. let bolusAmount = Decimal(bolusAmount.doubleValue(for: .internationalUnit()))
  56. let message = PushMessage(
  57. user: user,
  58. commandType: .bolus,
  59. bolusAmount: bolusAmount,
  60. sharedSecret: sharedSecret,
  61. timestamp: Date().timeIntervalSince1970
  62. )
  63. sendPushNotification(message: message, completion: completion)
  64. }
  65. func sendTempTargetPushNotification(target: HKQuantity, duration: HKQuantity, completion: @escaping (Bool, String?) -> Void) {
  66. let targetValue = Int(target.doubleValue(for: HKUnit.milligramsPerDeciliter))
  67. let durationValue = Int(duration.doubleValue(for: HKUnit.minute()))
  68. let message = PushMessage(
  69. user: user,
  70. commandType: .tempTarget,
  71. bolusAmount: nil,
  72. target: targetValue,
  73. duration: durationValue,
  74. sharedSecret: sharedSecret,
  75. timestamp: Date().timeIntervalSince1970
  76. )
  77. sendPushNotification(message: message, completion: completion)
  78. }
  79. func sendCancelTempTargetPushNotification(completion: @escaping (Bool, String?) -> Void) {
  80. let message = PushMessage(
  81. user: user,
  82. commandType: .cancelTempTarget,
  83. sharedSecret: sharedSecret,
  84. timestamp: Date().timeIntervalSince1970
  85. )
  86. sendPushNotification(message: message, completion: completion)
  87. }
  88. func sendMealPushNotification(
  89. carbs: HKQuantity,
  90. protein: HKQuantity,
  91. fat: HKQuantity,
  92. scheduledTime: Date?,
  93. completion: @escaping (Bool, String?) -> Void
  94. ) {
  95. func convertToOptionalInt(_ quantity: HKQuantity) -> Int? {
  96. let valueInGrams = quantity.doubleValue(for: .gram())
  97. return valueInGrams > 0 ? Int(valueInGrams) : nil
  98. }
  99. let carbsValue = convertToOptionalInt(carbs)
  100. let proteinValue = convertToOptionalInt(protein)
  101. let fatValue = convertToOptionalInt(fat)
  102. let scheduledTimeInterval: TimeInterval? = scheduledTime?.timeIntervalSince1970
  103. guard carbsValue != nil || proteinValue != nil || fatValue != nil else {
  104. completion(false, "No nutrient data provided. At least one of carbs, fat, or protein must be greater than 0.")
  105. return
  106. }
  107. let message = PushMessage(
  108. user: user,
  109. commandType: .meal,
  110. carbs: carbsValue,
  111. protein: proteinValue,
  112. fat: fatValue,
  113. sharedSecret: sharedSecret,
  114. timestamp: Date().timeIntervalSince1970,
  115. scheduledTime: scheduledTimeInterval
  116. )
  117. sendPushNotification(message: message, completion: completion)
  118. }
  119. private func sendPushNotification(message: PushMessage, completion: @escaping (Bool, String?) -> Void) {
  120. print("Push message to send: \(message)")
  121. var missingFields = [String]()
  122. if sharedSecret.isEmpty { missingFields.append("sharedSecret") }
  123. if apnsKey.isEmpty { missingFields.append("token") }
  124. if keyId.isEmpty { missingFields.append("keyId") }
  125. if user.isEmpty { missingFields.append("user") }
  126. if !missingFields.isEmpty {
  127. let errorMessage = "Missing required fields, check your remote settings: \(missingFields.joined(separator: ", "))"
  128. print(errorMessage)
  129. completion(false, errorMessage)
  130. return
  131. }
  132. if deviceToken.isEmpty { missingFields.append("deviceToken") }
  133. if bundleId.isEmpty { missingFields.append("bundleId") }
  134. if teamId.isEmpty { missingFields.append("teamId") }
  135. if !missingFields.isEmpty {
  136. let errorMessage = "Missing required data, verify that you are using the latest version of Trio: \(missingFields.joined(separator: ", "))"
  137. print(errorMessage)
  138. completion(false, errorMessage)
  139. return
  140. }
  141. guard let url = constructAPNsURL() else {
  142. let errorMessage = "Failed to construct APNs URL"
  143. print(errorMessage)
  144. completion(false, errorMessage)
  145. return
  146. }
  147. guard let jwt = getOrGenerateJWT() else {
  148. let errorMessage = "Failed to generate JWT, please check that the token is correct."
  149. print(errorMessage)
  150. completion(false, errorMessage)
  151. return
  152. }
  153. var request = URLRequest(url: url)
  154. request.httpMethod = "POST"
  155. request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
  156. request.setValue("application/json", forHTTPHeaderField: "content-type")
  157. request.setValue("10", forHTTPHeaderField: "apns-priority")
  158. request.setValue("0", forHTTPHeaderField: "apns-expiration")
  159. request.setValue(bundleId, forHTTPHeaderField: "apns-topic")
  160. request.setValue("background", forHTTPHeaderField: "apns-push-type")
  161. do {
  162. let jsonData = try JSONEncoder().encode(message)
  163. request.httpBody = jsonData
  164. let task = URLSession.shared.dataTask(with: request) { data, response, error in
  165. if let error = error {
  166. let errorMessage = "Failed to send push notification: \(error.localizedDescription)"
  167. print(errorMessage)
  168. completion(false, errorMessage)
  169. return
  170. }
  171. if let httpResponse = response as? HTTPURLResponse {
  172. print("Push notification sent.")
  173. print("Status code: \(httpResponse.statusCode)")
  174. print("Response headers:")
  175. for (key, value) in httpResponse.allHeaderFields {
  176. print("\(key): \(value)")
  177. }
  178. var responseBodyMessage = ""
  179. if let data = data, let responseBody = String(data: data, encoding: .utf8) {
  180. print("Response body: \(responseBody)")
  181. if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
  182. let reason = json["reason"] as? String {
  183. responseBodyMessage = reason
  184. }
  185. } else {
  186. print("No response body")
  187. }
  188. switch httpResponse.statusCode {
  189. case 200:
  190. completion(true, nil)
  191. case 400:
  192. completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)")
  193. case 403:
  194. completion(false, "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)")
  195. case 404:
  196. completion(false, "Invalid request: The :path value was incorrect. \(responseBodyMessage)")
  197. case 405:
  198. completion(false, "Invalid request: Only POST requests are supported. \(responseBodyMessage)")
  199. case 410:
  200. completion(false, "The device token is no longer active for the topic. \(responseBodyMessage)")
  201. case 413:
  202. completion(false, "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)")
  203. case 429:
  204. completion(false, "Too many requests. \(responseBodyMessage)")
  205. case 500:
  206. completion(false, "Internal server error at APNs. \(responseBodyMessage)")
  207. case 503:
  208. completion(false, "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)")
  209. default:
  210. completion(false, "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)")
  211. }
  212. } else {
  213. completion(false, "Failed to get a valid HTTP response.")
  214. }
  215. }
  216. task.resume()
  217. } catch {
  218. let errorMessage = "Failed to encode push message: \(error.localizedDescription)"
  219. print(errorMessage)
  220. completion(false, errorMessage)
  221. }
  222. }
  223. private func constructAPNsURL() -> URL? {
  224. let host = productionEnvironment ? "api.push.apple.com" : "api.sandbox.push.apple.com"
  225. let urlString = "https://\(host)/3/device/\(deviceToken)"
  226. return URL(string: urlString)
  227. }
  228. private func getOrGenerateJWT() -> String? {
  229. if let cachedJWT = Storage.shared.cachedJWT.value, let expirationDate = Storage.shared.jwtExpirationDate.value {
  230. if Date() < expirationDate {
  231. return cachedJWT
  232. }
  233. }
  234. let header = Header(kid: keyId)
  235. let claims = APNsJWTClaims(iss: teamId, iat: Date())
  236. var jwt = JWT(header: header, claims: claims)
  237. do {
  238. let privateKey = Data(apnsKey.utf8)
  239. let jwtSigner = JWTSigner.es256(privateKey: privateKey)
  240. let signedJWT = try jwt.sign(using: jwtSigner)
  241. Storage.shared.cachedJWT.value = signedJWT
  242. Storage.shared.jwtExpirationDate.value = Date().addingTimeInterval(3600)
  243. return signedJWT
  244. } catch {
  245. print("Failed to sign JWT: \(error.localizedDescription)")
  246. return nil
  247. }
  248. }
  249. }