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
10 changes: 0 additions & 10 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@
37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; };
37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; };
37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
37E4DD0D2F7E0967000511C8 /* LALivenessStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */; };
37E4DD0E2F7E097D000511C8 /* LALivenessStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */; };
37E4DD112F7E0D35000511C8 /* LALivenessMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */; };
3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; };
5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; };
654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; };
Expand Down Expand Up @@ -478,8 +475,6 @@
37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = "<absolute>"; };
37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = "<absolute>"; };
37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessStore.swift; sourceTree = "<group>"; };
37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessMarker.swift; sourceTree = "<group>"; };
654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = "<group>"; };
654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = "<group>"; };
654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -922,8 +917,6 @@
376310762F5CD65100656488 /* LiveActivity */ = {
isa = PBXGroup;
children = (
37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */,
37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */,
374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */,
374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */,
374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */,
Expand Down Expand Up @@ -2069,9 +2062,7 @@
files = (
DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */,
374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */,
37E4DD0E2F7E097D000511C8 /* LALivenessStore.swift in Sources */,
374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */,
37E4DD112F7E0D35000511C8 /* LALivenessMarker.swift in Sources */,
374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */,
374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */,
);
Expand Down Expand Up @@ -2233,7 +2224,6 @@
6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */,
5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */,
6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */,
37E4DD0D2F7E0967000511C8 /* LALivenessStore.swift in Sources */,
6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */,
6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */,
6589CC672E9E7D1600BB18FE /* ImportExportSettingsViewModel.swift in Sources */,
Expand Down
114 changes: 103 additions & 11 deletions LoopFollow/LiveActivity/APNSClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,111 @@ 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)

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

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)
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
}
}

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,
],
]
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 +241,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
21 changes: 0 additions & 21 deletions LoopFollow/LiveActivity/LALivenessMarker.swift

This file was deleted.

38 changes: 0 additions & 38 deletions LoopFollow/LiveActivity/LALivenessStore.swift

This file was deleted.

Loading