Skip to content

Commit b20ca3e

Browse files
committed
Merge PR #622: Add iOS 17.2+ push-to-start for Live Activity renewal
# Conflicts: # LoopFollow/LiveActivity/LiveActivityManager.swift # LoopFollow/Settings/LiveActivitySettingsView.swift
2 parents fc40566 + ccc97e6 commit b20ca3e

8 files changed

Lines changed: 759 additions & 40 deletions

File tree

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@
289289
FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2E24A232A3001B652C /* DataStructs.swift */; };
290290
FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */; };
291291
FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC688592489554800A0279D /* BackgroundTaskAudio.swift */; };
292+
A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; };
292293
FC5A5C3D2497B229009C550E /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = FC5A5C3C2497B229009C550E /* Config.xcconfig */; };
293294
FC7CE518248ABE37001F83B8 /* Siri_Alert_Calibration_Needed.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4A9248ABE2B001F83B8 /* Siri_Alert_Calibration_Needed.caf */; };
294295
FC7CE519248ABE37001F83B8 /* Rise_And_Shine.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4AA248ABE2B001F83B8 /* Rise_And_Shine.caf */; };
@@ -870,6 +871,7 @@
870871
FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = "<group>"; };
871872
FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryKeyPath.swift; sourceTree = "<group>"; };
872873
FCC688592489554800A0279D /* BackgroundTaskAudio.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskAudio.swift; sourceTree = "<group>"; };
874+
A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = "<group>"; };
873875
FCC6885B2489559400A0279D /* blank.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = blank.wav; sourceTree = "<group>"; };
874876
FCC6885D24896A6C00A0279D /* silence.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = silence.mp3; sourceTree = "<group>"; };
875877
FCC6886624898F8000A0279D /* UserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsValue.swift; sourceTree = "<group>"; };
@@ -1681,6 +1683,7 @@
16811683
FCC6886A24898FD800A0279D /* ObservationToken.swift */,
16821684
FCC6886C2489909D00A0279D /* AnyConvertible.swift */,
16831685
FCC688592489554800A0279D /* BackgroundTaskAudio.swift */,
1686+
A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */,
16841687
A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */,
16851688
FCFEEC9F2488157B00402A7F /* Chart.swift */,
16861689
FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */,
@@ -2373,6 +2376,7 @@
23732376
DD493ADD2ACF21E0009A6922 /* Basals.swift in Sources */,
23742377
FC16A98124996C07003D6245 /* DateTime.swift in Sources */,
23752378
FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */,
2379+
A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */,
23762380
DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */,
23772381
DDEF50402D479B8A00884336 /* LoopAPNSService.swift in Sources */,
23782382
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: 120 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)