APNSClient.swift 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  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: - Payload Builder
  81. private func buildPayload(state: GlucoseLiveActivityAttributes.ContentState) -> Data? {
  82. let snapshot = state.snapshot
  83. var snapshotDict: [String: Any] = [
  84. "glucose": snapshot.glucose,
  85. "delta": snapshot.delta,
  86. "trend": snapshot.trend.rawValue,
  87. "updatedAt": snapshot.updatedAt.timeIntervalSince1970,
  88. "unit": snapshot.unit.rawValue,
  89. ]
  90. snapshotDict["isNotLooping"] = snapshot.isNotLooping
  91. snapshotDict["showRenewalOverlay"] = snapshot.showRenewalOverlay
  92. if let iob = snapshot.iob { snapshotDict["iob"] = iob }
  93. if let cob = snapshot.cob { snapshotDict["cob"] = cob }
  94. if let projected = snapshot.projected { snapshotDict["projected"] = projected }
  95. if let override = snapshot.override { snapshotDict["override"] = override }
  96. if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus }
  97. if let battery = snapshot.battery { snapshotDict["battery"] = battery }
  98. if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery }
  99. if !snapshot.basalRate.isEmpty { snapshotDict["basalRate"] = snapshot.basalRate }
  100. if let pumpReservoirU = snapshot.pumpReservoirU { snapshotDict["pumpReservoirU"] = pumpReservoirU }
  101. if let autosens = snapshot.autosens { snapshotDict["autosens"] = autosens }
  102. if let tdd = snapshot.tdd { snapshotDict["tdd"] = tdd }
  103. if let targetLowMgdl = snapshot.targetLowMgdl { snapshotDict["targetLowMgdl"] = targetLowMgdl }
  104. if let targetHighMgdl = snapshot.targetHighMgdl { snapshotDict["targetHighMgdl"] = targetHighMgdl }
  105. if let isfMgdlPerU = snapshot.isfMgdlPerU { snapshotDict["isfMgdlPerU"] = isfMgdlPerU }
  106. if let carbRatio = snapshot.carbRatio { snapshotDict["carbRatio"] = carbRatio }
  107. if let carbsToday = snapshot.carbsToday { snapshotDict["carbsToday"] = carbsToday }
  108. if let profileName = snapshot.profileName { snapshotDict["profileName"] = profileName }
  109. if snapshot.sageInsertTime > 0 { snapshotDict["sageInsertTime"] = snapshot.sageInsertTime }
  110. if snapshot.cageInsertTime > 0 { snapshotDict["cageInsertTime"] = snapshot.cageInsertTime }
  111. if snapshot.iageInsertTime > 0 { snapshotDict["iageInsertTime"] = snapshot.iageInsertTime }
  112. if let minBgMgdl = snapshot.minBgMgdl { snapshotDict["minBgMgdl"] = minBgMgdl }
  113. if let maxBgMgdl = snapshot.maxBgMgdl { snapshotDict["maxBgMgdl"] = maxBgMgdl }
  114. let contentState: [String: Any] = [
  115. "snapshot": snapshotDict,
  116. "seq": state.seq,
  117. "reason": state.reason,
  118. "producedAt": state.producedAt.timeIntervalSince1970,
  119. ]
  120. let payload: [String: Any] = [
  121. "aps": [
  122. "timestamp": Int(Date().timeIntervalSince1970),
  123. "event": "update",
  124. "content-state": contentState,
  125. ],
  126. ]
  127. return try? JSONSerialization.data(withJSONObject: payload)
  128. }
  129. }
  130. #endif