Skip to content

Commit e595114

Browse files
bjorkertMtlPhilclaude
authored
Add iOS 17.2+ push-to-start for Live Activity renewal (#622)
* Add iOS 17.2+ push-to-start for Live Activity renewal iOS 17.2+ now uses APNs push-to-start for every Live Activity creation path — initial start, renewal, and forced restart — so the LA can renew silently in the background instead of requiring the user to foreground the app at the 7.5 h ceiling. iOS 16.x retains the existing Activity.request() flow with the renewal-failed notification; the #available gates are at the entry points so the legacy helpers can be removed in one commit when the deployment target reaches 17.2. Push-to-start uses a silent payload (alert with empty title/body + interruption-level: passive) so adoption is invisible on phone and watch. The push-to-start token is observed at startup and persisted between launches; activityUpdates adoption resets the renewal deadline. The "tap to update" overlay is suppressed on iOS 17.2+ unless renewal has actually failed, since the time-based pre-emptive warning would be misleading when push-to-start is handling renewal automatically. Settings: - APN page: inline validity badges for Key ID and APNs key, with one- line error text when either is malformed. - Live Activity page: section footer noting APNs is required, plus a warning row when credentials are missing or invalid. * Remove dead iOS 16.2 availability checks in LiveActivityManager Deployment target is 16.6, so #available(iOS 16.2, *) is always true at runtime and the @available(iOS 16.2, *) on Activity.activityUpdates is satisfied by the deployment target alone. The runtime branch and its '(iOS 16.2+)' log strings just made the file harder to read alongside the real iOS 17.2 push-to-start gating. * Log iOS/macOS version in log file header UIDevice.current.systemVersion reports the iOS-equivalent on Mac Catalyst, so use ProcessInfo.operatingSystemVersion (and label it macOS) when running as a Catalyst app. * Improve push-to-start safety, backoff, and token-wait behaviour (#625) - Keep old LA alive until APNs send confirms success, so a failed push-to-start (rate-limited, invalid token, network error) no longer leaves the user with no activity and nothing to replace it - Reset laPushToStartBackoff to 0 in adoptPushToStartActivity so a near-term renewal is not silently blocked by the 5-minute post-send base interval once the new LA is confirmed by activityUpdates - Add apns-collapse-id (bundle-id.la.start) so APNs coalesces redundant push-to-start sends that race (refresh tick + user restart) - Set apns-expiration to 10 minutes instead of 0 so a brief connectivity gap does not permanently lose the start notification, while avoiding delivery of clinically stale glucose data - Raise pushToStartForceRestartThreshold from 2 to 4 to reduce false positives on slow connections where activityUpdates delivery lags - Add a single automatic 10-second retry when the push-to-start token is not yet available, before surfacing the "could not start" error https://claude.ai/code/session_01GJZERMhqLmEy8p4cpVX53q Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Phil A <76601115+MtlPhil@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1cd1f60 commit e595114

9 files changed

Lines changed: 800 additions & 37 deletions

File tree

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@
291291
FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2E24A232A3001B652C /* DataStructs.swift */; };
292292
FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */; };
293293
FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC688592489554800A0279D /* BackgroundTaskAudio.swift */; };
294+
A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; };
294295
FC5A5C3D2497B229009C550E /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = FC5A5C3C2497B229009C550E /* Config.xcconfig */; };
295296
FC7CE518248ABE37001F83B8 /* Siri_Alert_Calibration_Needed.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4A9248ABE2B001F83B8 /* Siri_Alert_Calibration_Needed.caf */; };
296297
FC7CE519248ABE37001F83B8 /* Rise_And_Shine.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4AA248ABE2B001F83B8 /* Rise_And_Shine.caf */; };
@@ -875,6 +876,7 @@
875876
FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = "<group>"; };
876877
FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryKeyPath.swift; sourceTree = "<group>"; };
877878
FCC688592489554800A0279D /* BackgroundTaskAudio.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskAudio.swift; sourceTree = "<group>"; };
879+
A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = "<group>"; };
878880
FCC6885B2489559400A0279D /* blank.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = blank.wav; sourceTree = "<group>"; };
879881
FCC6885D24896A6C00A0279D /* silence.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = silence.mp3; sourceTree = "<group>"; };
880882
FCC6886624898F8000A0279D /* UserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsValue.swift; sourceTree = "<group>"; };
@@ -1690,6 +1692,7 @@
16901692
FCC6886A24898FD800A0279D /* ObservationToken.swift */,
16911693
FCC6886C2489909D00A0279D /* AnyConvertible.swift */,
16921694
FCC688592489554800A0279D /* BackgroundTaskAudio.swift */,
1695+
A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */,
16931696
A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */,
16941697
FCFEEC9F2488157B00402A7F /* Chart.swift */,
16951698
FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */,
@@ -2385,6 +2388,7 @@
23852388
DD493ADD2ACF21E0009A6922 /* Basals.swift in Sources */,
23862389
FC16A98124996C07003D6245 /* DateTime.swift in Sources */,
23872390
FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */,
2391+
A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */,
23882392
DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */,
23892393
DDEF50402D479B8A00884336 /* LoopAPNSService.swift in Sources */,
23902394
DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// LoopFollow
2+
// APNsCredentialValidator.swift
3+
4+
import Foundation
5+
6+
/// Validation rules for the APNs credentials the user pastes in
7+
/// `APNSettingsView`. Used both by the settings UI to surface inline
8+
/// errors and by `LiveActivitySettingsView` to warn when push-based
9+
/// updates won't work.
10+
enum APNsCredentialValidator {
11+
/// Apple Key IDs are exactly 10 uppercase alphanumeric characters.
12+
static func isValidKeyId(_ keyId: String) -> Bool {
13+
guard keyId.count == 10 else { return false }
14+
return keyId.allSatisfy { $0.isASCII && ($0.isUppercase || $0.isNumber) }
15+
}
16+
17+
/// A pasted .p8 must contain both PEM markers. We don't try to verify
18+
/// the inner base64 here — `LoopAPNSService.validateAndFixAPNSKey`
19+
/// already normalizes whitespace and logs deeper warnings, and we
20+
/// don't want to reject keys that JWTManager would happily sign.
21+
static func isValidApnsKey(_ key: String) -> Bool {
22+
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
23+
guard !trimmed.isEmpty else { return false }
24+
return trimmed.contains("-----BEGIN PRIVATE KEY-----")
25+
&& trimmed.contains("-----END PRIVATE KEY-----")
26+
}
27+
28+
/// Convenience for "is the user fully set up to send APNs pushes?"
29+
static func isFullyConfigured(keyId: String, apnsKey: String) -> Bool {
30+
isValidKeyId(keyId) && isValidApnsKey(apnsKey)
31+
}
32+
}

LoopFollow/LiveActivity/APNSClient.swift

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,135 @@ 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+
// 10-minute expiry — long enough to survive a brief connectivity gap
146+
// while the glucose reading in the payload is still clinically meaningful.
147+
// The stale date (8 h) is too generous: delivering a start with hours-old
148+
// glucose data is worse than not starting at all.
149+
request.setValue("\(Int(Date().timeIntervalSince1970) + 10 * 60)", forHTTPHeaderField: "apns-expiration")
150+
// Collapse key prevents duplicate LA creation if two sends race (e.g., a
151+
// refresh tick and a user-initiated restart overlap).
152+
request.setValue("\(bundleID).la.start", forHTTPHeaderField: "apns-collapse-id")
153+
request.httpBody = payload
154+
155+
do {
156+
let (data, response) = try await URLSession.shared.data(for: request)
157+
guard let httpResponse = response as? HTTPURLResponse else {
158+
LogManager.shared.log(category: .apns, message: "APNs push-to-start: no HTTP response")
159+
return .failed
160+
}
161+
switch httpResponse.statusCode {
162+
case 200:
163+
LogManager.shared.log(category: .apns, message: "APNs push-to-start sent successfully")
164+
return .success
165+
case 403:
166+
JWTManager.shared.invalidateCache()
167+
LogManager.shared.log(category: .apns, message: "APNs push-to-start JWT rejected (403) — token cache cleared")
168+
return .failed
169+
case 404, 410:
170+
// Push-to-start token rotated or invalid — caller should clear stored token
171+
// so the next pushToStartTokenUpdates delivery overwrites it.
172+
let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)"
173+
LogManager.shared.log(category: .apns, message: "APNs push-to-start token \(reason) — clearing stored token")
174+
return .tokenInvalid
175+
case 429:
176+
LogManager.shared.log(category: .apns, message: "APNs push-to-start rate limited (429)")
177+
return .rateLimited
178+
default:
179+
let responseBody = String(data: data, encoding: .utf8) ?? "empty"
180+
LogManager.shared.log(category: .apns, message: "APNs push-to-start failed status=\(httpResponse.statusCode) body=\(responseBody)")
181+
return .failed
182+
}
183+
} catch {
184+
LogManager.shared.log(category: .apns, message: "APNs push-to-start error: \(error.localizedDescription)")
185+
return .failed
186+
}
187+
}
188+
189+
// alert with empty title/body + interruption-level: passive is what
190+
// keeps both phone and watch silent during adoption — iOS drops the
191+
// start payload entirely if alert is missing, so the keys must be
192+
// present even though the strings are empty.
193+
private func buildStartPayload(
194+
attributesTitle: String,
195+
state: GlucoseLiveActivityAttributes.ContentState,
196+
staleDate: Date,
197+
) -> Data? {
198+
guard let contentStateDict = contentStateDictionary(state: state) else { return nil }
199+
200+
let payload: [String: Any] = [
201+
"aps": [
202+
"timestamp": Int(Date().timeIntervalSince1970),
203+
"event": "start",
204+
"stale-date": Int(staleDate.timeIntervalSince1970),
205+
"attributes-type": "GlucoseLiveActivityAttributes",
206+
"attributes": ["title": attributesTitle],
207+
"content-state": contentStateDict,
208+
"alert": [
209+
"title": "",
210+
"body": "",
211+
],
212+
"interruption-level": "passive",
213+
],
214+
]
215+
return try? JSONSerialization.data(withJSONObject: payload)
216+
}
217+
104218
// MARK: - Payload Builder
105219

106220
private func buildPayload(state: GlucoseLiveActivityAttributes.ContentState) -> Data? {
221+
guard let contentState = contentStateDictionary(state: state) else { return nil }
222+
let payload: [String: Any] = [
223+
"aps": [
224+
"timestamp": Int(Date().timeIntervalSince1970),
225+
"event": "update",
226+
"content-state": contentState,
227+
],
228+
]
229+
return try? JSONSerialization.data(withJSONObject: payload)
230+
}
231+
232+
private func contentStateDictionary(state: GlucoseLiveActivityAttributes.ContentState) -> [String: Any]? {
107233
let snapshot = state.snapshot
108234

109235
var snapshotDict: [String: Any] = [
@@ -139,22 +265,12 @@ class APNSClient {
139265
if let minBgMgdl = snapshot.minBgMgdl { snapshotDict["minBgMgdl"] = minBgMgdl }
140266
if let maxBgMgdl = snapshot.maxBgMgdl { snapshotDict["maxBgMgdl"] = maxBgMgdl }
141267

142-
let contentState: [String: Any] = [
268+
return [
143269
"snapshot": snapshotDict,
144270
"seq": state.seq,
145271
"reason": state.reason,
146272
"producedAt": state.producedAt.timeIntervalSince1970,
147273
]
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)
158274
}
159275
}
160276

0 commit comments

Comments
 (0)