| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162 |
- // LoopFollow
- // APNSClient.swift
- // swiftformat:disable indent
- #if !targetEnvironment(macCatalyst)
- import Foundation
- class APNSClient {
- static let shared = APNSClient()
- private init() {}
- // MARK: - Configuration
- private let bundleID = Bundle.main.bundleIdentifier ?? "com.apple.unknown"
- private var apnsHost: String {
- let isProduction = BuildDetails.default.isTestFlightBuild()
- return isProduction
- ? "https://api.push.apple.com"
- : "https://api.sandbox.push.apple.com"
- }
- private var lfKeyId: String {
- Storage.shared.lfKeyId.value
- }
- private var lfTeamId: String {
- BuildDetails.default.teamID ?? ""
- }
- private var lfApnsKey: String {
- Storage.shared.lfApnsKey.value
- }
- // MARK: - Send Live Activity Update
- func sendLiveActivityUpdate(
- pushToken: String,
- state: GlucoseLiveActivityAttributes.ContentState,
- ) async {
- guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else {
- LogManager.shared.log(category: .apns, message: "APNs failed to generate JWT for Live Activity push")
- return
- }
- let payload = buildPayload(state: state)
- guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else {
- LogManager.shared.log(category: .apns, message: "APNs invalid URL", isDebug: true)
- return
- }
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
- request.setValue("application/json", forHTTPHeaderField: "content-type")
- request.setValue("\(bundleID).push-type.liveactivity", forHTTPHeaderField: "apns-topic")
- request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type")
- request.setValue("10", forHTTPHeaderField: "apns-priority")
- request.httpBody = payload
- do {
- let (data, response) = try await URLSession.shared.data(for: request)
- if let httpResponse = response as? HTTPURLResponse {
- switch httpResponse.statusCode {
- case 200:
- LogManager.shared.log(category: .apns, message: "APNs push sent successfully", isDebug: true)
- case 400:
- let responseBody = String(data: data, encoding: .utf8) ?? "empty"
- LogManager.shared.log(category: .apns, message: "APNs bad request (400) — malformed payload: \(responseBody)")
- case 403:
- // JWT rejected — force regenerate on next push
- JWTManager.shared.invalidateCache()
- LogManager.shared.log(category: .apns, message: "APNs JWT rejected (403) — token cache cleared, will regenerate")
- case 404, 410:
- // Activity token not found or expired — end and restart on next refresh
- let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)"
- LogManager.shared.log(category: .apns, message: "APNs token \(reason) — restarting Live Activity")
- LiveActivityManager.shared.handleExpiredToken()
- case 429:
- LogManager.shared.log(category: .apns, message: "APNs rate limited (429) — will retry on next refresh")
- case 500 ... 599:
- let responseBody = String(data: data, encoding: .utf8) ?? "empty"
- LogManager.shared.log(category: .apns, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)")
- default:
- let responseBody = String(data: data, encoding: .utf8) ?? "empty"
- LogManager.shared.log(category: .apns, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)")
- }
- }
- } catch {
- LogManager.shared.log(category: .apns, message: "APNs error: \(error.localizedDescription)")
- }
- }
- // MARK: - Payload Builder
- private func buildPayload(state: GlucoseLiveActivityAttributes.ContentState) -> Data? {
- let snapshot = state.snapshot
- var snapshotDict: [String: Any] = [
- "glucose": snapshot.glucose,
- "delta": snapshot.delta,
- "trend": snapshot.trend.rawValue,
- "updatedAt": snapshot.updatedAt.timeIntervalSince1970,
- "unit": snapshot.unit.rawValue,
- ]
- snapshotDict["isNotLooping"] = snapshot.isNotLooping
- snapshotDict["showRenewalOverlay"] = snapshot.showRenewalOverlay
- if let iob = snapshot.iob { snapshotDict["iob"] = iob }
- if let cob = snapshot.cob { snapshotDict["cob"] = cob }
- if let projected = snapshot.projected { snapshotDict["projected"] = projected }
- if let override = snapshot.override { snapshotDict["override"] = override }
- if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus }
- if let battery = snapshot.battery { snapshotDict["battery"] = battery }
- if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery }
- if !snapshot.basalRate.isEmpty { snapshotDict["basalRate"] = snapshot.basalRate }
- if let pumpReservoirU = snapshot.pumpReservoirU { snapshotDict["pumpReservoirU"] = pumpReservoirU }
- if let autosens = snapshot.autosens { snapshotDict["autosens"] = autosens }
- if let tdd = snapshot.tdd { snapshotDict["tdd"] = tdd }
- if let targetLowMgdl = snapshot.targetLowMgdl { snapshotDict["targetLowMgdl"] = targetLowMgdl }
- if let targetHighMgdl = snapshot.targetHighMgdl { snapshotDict["targetHighMgdl"] = targetHighMgdl }
- if let isfMgdlPerU = snapshot.isfMgdlPerU { snapshotDict["isfMgdlPerU"] = isfMgdlPerU }
- if let carbRatio = snapshot.carbRatio { snapshotDict["carbRatio"] = carbRatio }
- if let carbsToday = snapshot.carbsToday { snapshotDict["carbsToday"] = carbsToday }
- if let profileName = snapshot.profileName { snapshotDict["profileName"] = profileName }
- if snapshot.sageInsertTime > 0 { snapshotDict["sageInsertTime"] = snapshot.sageInsertTime }
- if snapshot.cageInsertTime > 0 { snapshotDict["cageInsertTime"] = snapshot.cageInsertTime }
- if snapshot.iageInsertTime > 0 { snapshotDict["iageInsertTime"] = snapshot.iageInsertTime }
- if let minBgMgdl = snapshot.minBgMgdl { snapshotDict["minBgMgdl"] = minBgMgdl }
- if let maxBgMgdl = snapshot.maxBgMgdl { snapshotDict["maxBgMgdl"] = maxBgMgdl }
- let contentState: [String: Any] = [
- "snapshot": snapshotDict,
- "seq": state.seq,
- "reason": state.reason,
- "producedAt": state.producedAt.timeIntervalSince1970,
- ]
- let payload: [String: Any] = [
- "aps": [
- "timestamp": Int(Date().timeIntervalSince1970),
- "event": "update",
- "content-state": contentState,
- ],
- ]
- return try? JSONSerialization.data(withJSONObject: payload)
- }
- }
- #endif
|