@@ -101,9 +101,128 @@ class APNSClient {
101101 }
102102 }
103103
104+ // MARK: - Send Live Activity Start (push-to-start, iOS 17.2+)
105+
106+ enum PushToStartResult {
107+ case success
108+ case rateLimited
109+ case tokenInvalid
110+ case failed
111+ }
112+
113+ func sendLiveActivityStart(
114+ pushToStartToken: String ,
115+ attributesTitle: String ,
116+ state: GlucoseLiveActivityAttributes . ContentState ,
117+ staleDate: Date ,
118+ ) async -> PushToStartResult {
119+ guard let jwt = JWTManager . shared. getOrGenerateJWT ( keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else {
120+ LogManager . shared. log ( category: . apns, message: " APNs failed to generate JWT for Live Activity push-to-start " )
121+ return . failed
122+ }
123+
124+ let payload = buildStartPayload ( attributesTitle: attributesTitle, state: state, staleDate: staleDate)
125+
126+ let host = apnsHost
127+ guard let url = URL ( string: " \( host) /3/device/ \( pushToStartToken) " ) else {
128+ LogManager . shared. log ( category: . apns, message: " APNs invalid URL (push-to-start) " , isDebug: true )
129+ return . failed
130+ }
131+
132+ let environment = BuildDetails . default. isTestFlightBuild ( ) ? " production " : " sandbox "
133+ LogManager . shared. log (
134+ category: . apns,
135+ message: " APNs push-to-start sending host= \( host) env= \( environment) tokenTail=… \( String ( pushToStartToken. suffix ( 8 ) ) ) "
136+ )
137+
138+ var request = URLRequest ( url: url)
139+ request. httpMethod = " POST "
140+ request. setValue ( " bearer \( jwt) " , forHTTPHeaderField: " authorization " )
141+ request. setValue ( " application/json " , forHTTPHeaderField: " content-type " )
142+ request. setValue ( " \( bundleID) .push-type.liveactivity " , forHTTPHeaderField: " apns-topic " )
143+ request. setValue ( " liveactivity " , forHTTPHeaderField: " apns-push-type " )
144+ request. setValue ( " 10 " , forHTTPHeaderField: " apns-priority " )
145+ request. setValue ( " 0 " , forHTTPHeaderField: " apns-expiration " )
146+ request. httpBody = payload
147+
148+ do {
149+ let ( data, response) = try await URLSession . shared. data ( for: request)
150+ guard let httpResponse = response as? HTTPURLResponse else {
151+ LogManager . shared. log ( category: . apns, message: " APNs push-to-start: no HTTP response " )
152+ return . failed
153+ }
154+ switch httpResponse. statusCode {
155+ case 200 :
156+ LogManager . shared. log ( category: . apns, message: " APNs push-to-start sent successfully " )
157+ return . success
158+ case 403 :
159+ JWTManager . shared. invalidateCache ( )
160+ LogManager . shared. log ( category: . apns, message: " APNs push-to-start JWT rejected (403) — token cache cleared " )
161+ return . failed
162+ case 404 , 410 :
163+ // Push-to-start token rotated or invalid — caller should clear stored token
164+ // so the next pushToStartTokenUpdates delivery overwrites it.
165+ let reason = httpResponse. statusCode == 410 ? " expired (410) " : " not found (404) "
166+ LogManager . shared. log ( category: . apns, message: " APNs push-to-start token \( reason) — clearing stored token " )
167+ return . tokenInvalid
168+ case 429 :
169+ LogManager . shared. log ( category: . apns, message: " APNs push-to-start rate limited (429) " )
170+ return . rateLimited
171+ default :
172+ let responseBody = String ( data: data, encoding: . utf8) ?? " empty "
173+ LogManager . shared. log ( category: . apns, message: " APNs push-to-start failed status= \( httpResponse. statusCode) body= \( responseBody) " )
174+ return . failed
175+ }
176+ } catch {
177+ LogManager . shared. log ( category: . apns, message: " APNs push-to-start error: \( error. localizedDescription) " )
178+ return . failed
179+ }
180+ }
181+
182+ // alert with empty title/body + interruption-level: passive is what
183+ // keeps both phone and watch silent during adoption — iOS drops the
184+ // start payload entirely if alert is missing, so the keys must be
185+ // present even though the strings are empty.
186+ private func buildStartPayload(
187+ attributesTitle: String ,
188+ state: GlucoseLiveActivityAttributes . ContentState ,
189+ staleDate: Date ,
190+ ) -> Data ? {
191+ guard let contentStateDict = contentStateDictionary ( state: state) else { return nil }
192+
193+ let payload : [ String : Any ] = [
194+ " aps " : [
195+ " timestamp " : Int ( Date ( ) . timeIntervalSince1970) ,
196+ " event " : " start " ,
197+ " stale-date " : Int ( staleDate. timeIntervalSince1970) ,
198+ " attributes-type " : " GlucoseLiveActivityAttributes " ,
199+ " attributes " : [ " title " : attributesTitle] ,
200+ " content-state " : contentStateDict,
201+ " alert " : [
202+ " title " : " " ,
203+ " body " : " " ,
204+ ] ,
205+ " interruption-level " : " passive " ,
206+ ] ,
207+ ]
208+ return try ? JSONSerialization . data ( withJSONObject: payload)
209+ }
210+
104211 // MARK: - Payload Builder
105212
106213 private func buildPayload( state: GlucoseLiveActivityAttributes . ContentState ) -> Data ? {
214+ guard let contentState = contentStateDictionary ( state: state) else { return nil }
215+ let payload : [ String : Any ] = [
216+ " aps " : [
217+ " timestamp " : Int ( Date ( ) . timeIntervalSince1970) ,
218+ " event " : " update " ,
219+ " content-state " : contentState,
220+ ] ,
221+ ]
222+ return try ? JSONSerialization . data ( withJSONObject: payload)
223+ }
224+
225+ private func contentStateDictionary( state: GlucoseLiveActivityAttributes . ContentState ) -> [ String : Any ] ? {
107226 let snapshot = state. snapshot
108227
109228 var snapshotDict : [ String : Any ] = [
@@ -139,22 +258,12 @@ class APNSClient {
139258 if let minBgMgdl = snapshot. minBgMgdl { snapshotDict [ " minBgMgdl " ] = minBgMgdl }
140259 if let maxBgMgdl = snapshot. maxBgMgdl { snapshotDict [ " maxBgMgdl " ] = maxBgMgdl }
141260
142- let contentState : [ String : Any ] = [
261+ return [
143262 " snapshot " : snapshotDict,
144263 " seq " : state. seq,
145264 " reason " : state. reason,
146265 " producedAt " : state. producedAt. timeIntervalSince1970,
147266 ]
148-
149- let payload : [ String : Any ] = [
150- " aps " : [
151- " timestamp " : Int ( Date ( ) . timeIntervalSince1970) ,
152- " event " : " update " ,
153- " content-state " : contentState,
154- ] ,
155- ]
156-
157- return try ? JSONSerialization . data ( withJSONObject: payload)
158267 }
159268}
160269
0 commit comments