APNSClient.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. // LoopFollow
  2. // APNSClient.swift
  3. // swiftformat:disable indent
  4. #if !targetEnvironment(macCatalyst)
  5. import Foundation
  6. class APNSClient {
  7. static let shared = APNSClient()
  8. private init() {}
  9. // MARK: - Configuration
  10. private let bundleID = Bundle.main.bundleIdentifier ?? "com.apple.unknown"
  11. private var apnsHost: String {
  12. let isProduction = BuildDetails.default.isTestFlightBuild()
  13. return isProduction
  14. ? "https://api.push.apple.com"
  15. : "https://api.sandbox.push.apple.com"
  16. }
  17. private var lfKeyId: String {
  18. Storage.shared.lfKeyId.value
  19. }
  20. private var lfTeamId: String {
  21. BuildDetails.default.teamID ?? ""
  22. }
  23. private var lfApnsKey: String {
  24. Storage.shared.lfApnsKey.value
  25. }
  26. // MARK: - Send Live Activity Update
  27. func sendLiveActivityUpdate(
  28. pushToken: String,
  29. state: GlucoseLiveActivityAttributes.ContentState,
  30. ) async {
  31. guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else {
  32. LogManager.shared.log(category: .apns, message: "APNs failed to generate JWT for Live Activity push")
  33. return
  34. }
  35. let payload = buildPayload(state: state)
  36. guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else {
  37. LogManager.shared.log(category: .apns, message: "APNs invalid URL", isDebug: true)
  38. return
  39. }
  40. var request = URLRequest(url: url)
  41. request.httpMethod = "POST"
  42. request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
  43. request.setValue("application/json", forHTTPHeaderField: "content-type")
  44. request.setValue("\(bundleID).push-type.liveactivity", forHTTPHeaderField: "apns-topic")
  45. request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type")
  46. request.setValue("10", forHTTPHeaderField: "apns-priority")
  47. request.httpBody = payload
  48. do {
  49. let (data, response) = try await URLSession.shared.data(for: request)
  50. if let httpResponse = response as? HTTPURLResponse {
  51. switch httpResponse.statusCode {
  52. case 200:
  53. LogManager.shared.log(category: .apns, message: "APNs push sent successfully", isDebug: true)
  54. case 400:
  55. let responseBody = String(data: data, encoding: .utf8) ?? "empty"
  56. LogManager.shared.log(category: .apns, message: "APNs bad request (400) — malformed payload: \(responseBody)")
  57. case 403:
  58. // JWT rejected — force regenerate on next push
  59. JWTManager.shared.invalidateCache()
  60. LogManager.shared.log(category: .apns, message: "APNs JWT rejected (403) — token cache cleared, will regenerate")
  61. case 404, 410:
  62. // Activity token not found or expired — end and restart on next refresh
  63. let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)"
  64. LogManager.shared.log(category: .apns, message: "APNs token \(reason) — restarting Live Activity")
  65. LiveActivityManager.shared.handleExpiredToken()
  66. case 429:
  67. LogManager.shared.log(category: .apns, message: "APNs rate limited (429) — will retry on next refresh")
  68. case 500 ... 599:
  69. let responseBody = String(data: data, encoding: .utf8) ?? "empty"
  70. LogManager.shared.log(category: .apns, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)")
  71. default:
  72. let responseBody = String(data: data, encoding: .utf8) ?? "empty"
  73. LogManager.shared.log(category: .apns, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)")
  74. }
  75. }
  76. } catch {
  77. LogManager.shared.log(category: .apns, message: "APNs error: \(error.localizedDescription)")
  78. }
  79. }
  80. // MARK: - Send Live Activity Start (push-to-start, iOS 17.2+)
  81. enum PushToStartResult {
  82. case success
  83. case rateLimited
  84. case tokenInvalid
  85. case failed
  86. }
  87. func sendLiveActivityStart(
  88. pushToStartToken: String,
  89. attributesTitle: String,
  90. state: GlucoseLiveActivityAttributes.ContentState,
  91. staleDate: Date,
  92. ) async -> PushToStartResult {
  93. guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else {
  94. LogManager.shared.log(category: .apns, message: "APNs failed to generate JWT for Live Activity push-to-start")
  95. return .failed
  96. }
  97. let payload = buildStartPayload(attributesTitle: attributesTitle, state: state, staleDate: staleDate)
  98. let host = apnsHost
  99. guard let url = URL(string: "\(host)/3/device/\(pushToStartToken)") else {
  100. LogManager.shared.log(category: .apns, message: "APNs invalid URL (push-to-start)", isDebug: true)
  101. return .failed
  102. }
  103. let environment = BuildDetails.default.isTestFlightBuild() ? "production" : "sandbox"
  104. LogManager.shared.log(
  105. category: .apns,
  106. message: "APNs push-to-start sending host=\(host) env=\(environment) tokenTail=…\(String(pushToStartToken.suffix(8)))"
  107. )
  108. var request = URLRequest(url: url)
  109. request.httpMethod = "POST"
  110. request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
  111. request.setValue("application/json", forHTTPHeaderField: "content-type")
  112. request.setValue("\(bundleID).push-type.liveactivity", forHTTPHeaderField: "apns-topic")
  113. request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type")
  114. request.setValue("10", forHTTPHeaderField: "apns-priority")
  115. // 10-minute expiry — long enough to survive a brief connectivity gap
  116. // while the glucose reading in the payload is still clinically meaningful.
  117. // The stale date (8 h) is too generous: delivering a start with hours-old
  118. // glucose data is worse than not starting at all.
  119. request.setValue("\(Int(Date().timeIntervalSince1970) + 10 * 60)", forHTTPHeaderField: "apns-expiration")
  120. // Collapse key prevents duplicate LA creation if two sends race (e.g., a
  121. // refresh tick and a user-initiated restart overlap).
  122. request.setValue("\(bundleID).la.start", forHTTPHeaderField: "apns-collapse-id")
  123. request.httpBody = payload
  124. do {
  125. let (data, response) = try await URLSession.shared.data(for: request)
  126. guard let httpResponse = response as? HTTPURLResponse else {
  127. LogManager.shared.log(category: .apns, message: "APNs push-to-start: no HTTP response")
  128. return .failed
  129. }
  130. switch httpResponse.statusCode {
  131. case 200:
  132. LogManager.shared.log(category: .apns, message: "APNs push-to-start sent successfully")
  133. return .success
  134. case 403:
  135. JWTManager.shared.invalidateCache()
  136. LogManager.shared.log(category: .apns, message: "APNs push-to-start JWT rejected (403) — token cache cleared")
  137. return .failed
  138. case 404, 410:
  139. // Push-to-start token rotated or invalid — caller should clear stored token
  140. // so the next pushToStartTokenUpdates delivery overwrites it.
  141. let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)"
  142. LogManager.shared.log(category: .apns, message: "APNs push-to-start token \(reason) — clearing stored token")
  143. return .tokenInvalid
  144. case 429:
  145. LogManager.shared.log(category: .apns, message: "APNs push-to-start rate limited (429)")
  146. return .rateLimited
  147. default:
  148. let responseBody = String(data: data, encoding: .utf8) ?? "empty"
  149. LogManager.shared.log(category: .apns, message: "APNs push-to-start failed status=\(httpResponse.statusCode) body=\(responseBody)")
  150. return .failed
  151. }
  152. } catch {
  153. LogManager.shared.log(category: .apns, message: "APNs push-to-start error: \(error.localizedDescription)")
  154. return .failed
  155. }
  156. }
  157. // alert with empty title/body + interruption-level: passive is what
  158. // keeps both phone and watch silent during adoption — iOS drops the
  159. // start payload entirely if alert is missing, so the keys must be
  160. // present even though the strings are empty.
  161. private func buildStartPayload(
  162. attributesTitle: String,
  163. state: GlucoseLiveActivityAttributes.ContentState,
  164. staleDate: Date,
  165. ) -> Data? {
  166. guard let contentStateDict = contentStateDictionary(state: state) else { return nil }
  167. let payload: [String: Any] = [
  168. "aps": [
  169. "timestamp": Int(Date().timeIntervalSince1970),
  170. "event": "start",
  171. "stale-date": Int(staleDate.timeIntervalSince1970),
  172. "attributes-type": "GlucoseLiveActivityAttributes",
  173. "attributes": ["title": attributesTitle],
  174. "content-state": contentStateDict,
  175. "alert": [
  176. "title": "",
  177. "body": "",
  178. ],
  179. "interruption-level": "passive",
  180. ],
  181. ]
  182. return try? JSONSerialization.data(withJSONObject: payload)
  183. }
  184. // MARK: - Payload Builder
  185. private func buildPayload(state: GlucoseLiveActivityAttributes.ContentState) -> Data? {
  186. guard let contentState = contentStateDictionary(state: state) else { return nil }
  187. let payload: [String: Any] = [
  188. "aps": [
  189. "timestamp": Int(Date().timeIntervalSince1970),
  190. "event": "update",
  191. "content-state": contentState,
  192. ],
  193. ]
  194. return try? JSONSerialization.data(withJSONObject: payload)
  195. }
  196. private func contentStateDictionary(state: GlucoseLiveActivityAttributes.ContentState) -> [String: Any]? {
  197. let snapshot = state.snapshot
  198. var snapshotDict: [String: Any] = [
  199. "glucose": snapshot.glucose,
  200. "delta": snapshot.delta,
  201. "trend": snapshot.trend.rawValue,
  202. "updatedAt": snapshot.updatedAt.timeIntervalSince1970,
  203. "unit": snapshot.unit.rawValue,
  204. ]
  205. snapshotDict["isNotLooping"] = snapshot.isNotLooping
  206. snapshotDict["showRenewalOverlay"] = snapshot.showRenewalOverlay
  207. if let iob = snapshot.iob { snapshotDict["iob"] = iob }
  208. if let cob = snapshot.cob { snapshotDict["cob"] = cob }
  209. if let projected = snapshot.projected { snapshotDict["projected"] = projected }
  210. if let override = snapshot.override { snapshotDict["override"] = override }
  211. if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus }
  212. if let battery = snapshot.battery { snapshotDict["battery"] = battery }
  213. if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery }
  214. if !snapshot.basalRate.isEmpty { snapshotDict["basalRate"] = snapshot.basalRate }
  215. if let pumpReservoirU = snapshot.pumpReservoirU { snapshotDict["pumpReservoirU"] = pumpReservoirU }
  216. if let autosens = snapshot.autosens { snapshotDict["autosens"] = autosens }
  217. if let tdd = snapshot.tdd { snapshotDict["tdd"] = tdd }
  218. if let targetLowMgdl = snapshot.targetLowMgdl { snapshotDict["targetLowMgdl"] = targetLowMgdl }
  219. if let targetHighMgdl = snapshot.targetHighMgdl { snapshotDict["targetHighMgdl"] = targetHighMgdl }
  220. if let isfMgdlPerU = snapshot.isfMgdlPerU { snapshotDict["isfMgdlPerU"] = isfMgdlPerU }
  221. if let carbRatio = snapshot.carbRatio { snapshotDict["carbRatio"] = carbRatio }
  222. if let carbsToday = snapshot.carbsToday { snapshotDict["carbsToday"] = carbsToday }
  223. if let profileName = snapshot.profileName { snapshotDict["profileName"] = profileName }
  224. if snapshot.sageInsertTime > 0 { snapshotDict["sageInsertTime"] = snapshot.sageInsertTime }
  225. if snapshot.cageInsertTime > 0 { snapshotDict["cageInsertTime"] = snapshot.cageInsertTime }
  226. if snapshot.iageInsertTime > 0 { snapshotDict["iageInsertTime"] = snapshot.iageInsertTime }
  227. if let minBgMgdl = snapshot.minBgMgdl { snapshotDict["minBgMgdl"] = minBgMgdl }
  228. if let maxBgMgdl = snapshot.maxBgMgdl { snapshotDict["maxBgMgdl"] = maxBgMgdl }
  229. return [
  230. "snapshot": snapshotDict,
  231. "seq": state.seq,
  232. "reason": state.reason,
  233. "producedAt": state.producedAt.timeIntervalSince1970,
  234. ]
  235. }
  236. }
  237. #endif