Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@
FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2E24A232A3001B652C /* DataStructs.swift */; };
FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */; };
FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC688592489554800A0279D /* BackgroundTaskAudio.swift */; };
A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; };
FC5A5C3D2497B229009C550E /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = FC5A5C3C2497B229009C550E /* Config.xcconfig */; };
FC7CE518248ABE37001F83B8 /* Siri_Alert_Calibration_Needed.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4A9248ABE2B001F83B8 /* Siri_Alert_Calibration_Needed.caf */; };
FC7CE519248ABE37001F83B8 /* Rise_And_Shine.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4AA248ABE2B001F83B8 /* Rise_And_Shine.caf */; };
Expand Down Expand Up @@ -868,6 +869,7 @@
FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = "<group>"; };
FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryKeyPath.swift; sourceTree = "<group>"; };
FCC688592489554800A0279D /* BackgroundTaskAudio.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskAudio.swift; sourceTree = "<group>"; };
A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = "<group>"; };
FCC6885B2489559400A0279D /* blank.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = blank.wav; sourceTree = "<group>"; };
FCC6885D24896A6C00A0279D /* silence.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = silence.mp3; sourceTree = "<group>"; };
FCC6886624898F8000A0279D /* UserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsValue.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1679,6 +1681,7 @@
FCC6886A24898FD800A0279D /* ObservationToken.swift */,
FCC6886C2489909D00A0279D /* AnyConvertible.swift */,
FCC688592489554800A0279D /* BackgroundTaskAudio.swift */,
A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */,
A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */,
FCFEEC9F2488157B00402A7F /* Chart.swift */,
FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */,
Expand Down Expand Up @@ -2369,6 +2372,7 @@
DD493ADD2ACF21E0009A6922 /* Basals.swift in Sources */,
FC16A98124996C07003D6245 /* DateTime.swift in Sources */,
FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */,
A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */,
DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */,
DDEF50402D479B8A00884336 /* LoopAPNSService.swift in Sources */,
DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */,
Expand Down
32 changes: 32 additions & 0 deletions LoopFollow/Helpers/APNsCredentialValidator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// LoopFollow
// APNsCredentialValidator.swift

import Foundation

/// Validation rules for the APNs credentials the user pastes in
/// `APNSettingsView`. Used both by the settings UI to surface inline
/// errors and by `LiveActivitySettingsView` to warn when push-based
/// updates won't work.
enum APNsCredentialValidator {
/// Apple Key IDs are exactly 10 uppercase alphanumeric characters.
static func isValidKeyId(_ keyId: String) -> Bool {
guard keyId.count == 10 else { return false }
return keyId.allSatisfy { $0.isASCII && ($0.isUppercase || $0.isNumber) }
}

/// A pasted .p8 must contain both PEM markers. We don't try to verify
/// the inner base64 here — `LoopAPNSService.validateAndFixAPNSKey`
/// already normalizes whitespace and logs deeper warnings, and we
/// don't want to reject keys that JWTManager would happily sign.
static func isValidApnsKey(_ key: String) -> Bool {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return trimmed.contains("-----BEGIN PRIVATE KEY-----")
&& trimmed.contains("-----END PRIVATE KEY-----")
}

/// Convenience for "is the user fully set up to send APNs pushes?"
static func isFullyConfigured(keyId: String, apnsKey: String) -> Bool {
isValidKeyId(keyId) && isValidApnsKey(apnsKey)
}
}
131 changes: 120 additions & 11 deletions LoopFollow/LiveActivity/APNSClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,128 @@ class APNSClient {
}
}

// MARK: - Send Live Activity Start (push-to-start, iOS 17.2+)

enum PushToStartResult {
case success
case rateLimited
case tokenInvalid
case failed
}

func sendLiveActivityStart(
pushToStartToken: String,
attributesTitle: String,
state: GlucoseLiveActivityAttributes.ContentState,
staleDate: Date,
) async -> PushToStartResult {
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-to-start")
return .failed
}

let payload = buildStartPayload(attributesTitle: attributesTitle, state: state, staleDate: staleDate)

let host = apnsHost
guard let url = URL(string: "\(host)/3/device/\(pushToStartToken)") else {
LogManager.shared.log(category: .apns, message: "APNs invalid URL (push-to-start)", isDebug: true)
return .failed
}

let environment = BuildDetails.default.isTestFlightBuild() ? "production" : "sandbox"
LogManager.shared.log(
category: .apns,
message: "APNs push-to-start sending host=\(host) env=\(environment) tokenTail=…\(String(pushToStartToken.suffix(8)))"
)

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.setValue("0", forHTTPHeaderField: "apns-expiration")
request.httpBody = payload

do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
LogManager.shared.log(category: .apns, message: "APNs push-to-start: no HTTP response")
return .failed
}
switch httpResponse.statusCode {
case 200:
LogManager.shared.log(category: .apns, message: "APNs push-to-start sent successfully")
return .success
case 403:
JWTManager.shared.invalidateCache()
LogManager.shared.log(category: .apns, message: "APNs push-to-start JWT rejected (403) — token cache cleared")
return .failed
case 404, 410:
// Push-to-start token rotated or invalid — caller should clear stored token
// so the next pushToStartTokenUpdates delivery overwrites it.
let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)"
LogManager.shared.log(category: .apns, message: "APNs push-to-start token \(reason) — clearing stored token")
return .tokenInvalid
case 429:
LogManager.shared.log(category: .apns, message: "APNs push-to-start rate limited (429)")
return .rateLimited
default:
let responseBody = String(data: data, encoding: .utf8) ?? "empty"
LogManager.shared.log(category: .apns, message: "APNs push-to-start failed status=\(httpResponse.statusCode) body=\(responseBody)")
return .failed
}
} catch {
LogManager.shared.log(category: .apns, message: "APNs push-to-start error: \(error.localizedDescription)")
return .failed
}
}

// alert with empty title/body + interruption-level: passive is what
// keeps both phone and watch silent during adoption — iOS drops the
// start payload entirely if alert is missing, so the keys must be
// present even though the strings are empty.
private func buildStartPayload(
attributesTitle: String,
state: GlucoseLiveActivityAttributes.ContentState,
staleDate: Date,
) -> Data? {
guard let contentStateDict = contentStateDictionary(state: state) else { return nil }

let payload: [String: Any] = [
"aps": [
"timestamp": Int(Date().timeIntervalSince1970),
"event": "start",
"stale-date": Int(staleDate.timeIntervalSince1970),
"attributes-type": "GlucoseLiveActivityAttributes",
"attributes": ["title": attributesTitle],
"content-state": contentStateDict,
"alert": [
"title": "",
"body": "",
],
"interruption-level": "passive",
],
]
return try? JSONSerialization.data(withJSONObject: payload)
}

// MARK: - Payload Builder

private func buildPayload(state: GlucoseLiveActivityAttributes.ContentState) -> Data? {
guard let contentState = contentStateDictionary(state: state) else { return nil }
let payload: [String: Any] = [
"aps": [
"timestamp": Int(Date().timeIntervalSince1970),
"event": "update",
"content-state": contentState,
],
]
return try? JSONSerialization.data(withJSONObject: payload)
}

private func contentStateDictionary(state: GlucoseLiveActivityAttributes.ContentState) -> [String: Any]? {
let snapshot = state.snapshot

var snapshotDict: [String: Any] = [
Expand Down Expand Up @@ -139,22 +258,12 @@ class APNSClient {
if let minBgMgdl = snapshot.minBgMgdl { snapshotDict["minBgMgdl"] = minBgMgdl }
if let maxBgMgdl = snapshot.maxBgMgdl { snapshotDict["maxBgMgdl"] = maxBgMgdl }

let contentState: [String: Any] = [
return [
"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)
}
}

Expand Down
Loading
Loading