diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 4c2444734..def850eae 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -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 */; }; @@ -868,6 +869,7 @@ FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = ""; }; FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryKeyPath.swift; sourceTree = ""; }; FCC688592489554800A0279D /* BackgroundTaskAudio.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskAudio.swift; sourceTree = ""; }; + A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = ""; }; FCC6885B2489559400A0279D /* blank.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = blank.wav; sourceTree = ""; }; FCC6885D24896A6C00A0279D /* silence.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = silence.mp3; sourceTree = ""; }; FCC6886624898F8000A0279D /* UserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsValue.swift; sourceTree = ""; }; @@ -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 */, @@ -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 */, diff --git a/LoopFollow/Helpers/APNsCredentialValidator.swift b/LoopFollow/Helpers/APNsCredentialValidator.swift new file mode 100644 index 000000000..6eae74f81 --- /dev/null +++ b/LoopFollow/Helpers/APNsCredentialValidator.swift @@ -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) + } +} diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 8755b1b27..98db8c3d8 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -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] = [ @@ -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) } } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 180fdc1c1..896677e77 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -11,6 +11,18 @@ import UIKit import UserNotifications // Live Activity manager for LoopFollow. +// +// iOS 17.2+: every LA creation (initial start, renewal, forced +// restart) goes through APNs push-to-start. Updates +// ride the same APNs transport. One transport, one +// credential failure mode that surfaces in settings. +// +// iOS 16.6 – 17.1: legacy Activity.request() for everything; +// renewal-failed notification when backgrounded. +// The entry-point `if #available(iOS 17.2, *)` checks +// isolate every iOS 17.2 code path, so the legacy +// helpers can be deleted in one commit when the +// deployment target reaches 17.2. final class LiveActivityManager { static let shared = LiveActivityManager() @@ -39,6 +51,127 @@ final class LiveActivityManager { name: .backgroundAudioFailed, object: nil, ) + startPushToStartTokenObservation() + startActivityUpdatesObservation() + } + + // MARK: - Push-to-start observation (iOS 17.2+) + + /// Observes the type-level push-to-start token (iOS 17.2+) and persists it. + /// The token survives app relaunches but is reissued by iOS periodically or when + /// the user toggles LA permissions — each new delivery overwrites the stored value. + private func startPushToStartTokenObservation() { + if #available(iOS 17.2, *) { + pushToStartObservationTask?.cancel() + LogManager.shared.log( + category: .general, + message: "[LA] pushToStartTokenUpdates observation starting (iOS 17.2+)" + ) + pushToStartObservationTask = Task { + var deliveries = 0 + for await tokenData in Activity.pushToStartTokenUpdates { + deliveries += 1 + let token = tokenData.map { String(format: "%02x", $0) }.joined() + let previousTail = Storage.shared.laPushToStartToken.value.isEmpty + ? "nil" + : String(Storage.shared.laPushToStartToken.value.suffix(8)) + let tail = String(token.suffix(8)) + let changed = tail != previousTail + Storage.shared.laPushToStartToken.value = token + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")" + ) + } + LogManager.shared.log( + category: .general, + message: "[LA] pushToStartTokenUpdates stream ended after \(deliveries) deliveries — no further tokens will arrive" + ) + } + } else { + LogManager.shared.log( + category: .general, + message: "[LA] pushToStartTokenUpdates unavailable (iOS <17.2) — push-to-start will never fire" + ) + } + } + + /// Observes new Activity creations. When an activity is started by + /// push-to-start (iOS 17.2+), the app discovers it through this stream and + /// adopts it via the same bind/update path as an app-initiated start. + private func startActivityUpdatesObservation() { + activityUpdatesObservationTask?.cancel() + LogManager.shared.log( + category: .general, + message: "[LA] activityUpdates observation starting" + ) + activityUpdatesObservationTask = Task { [weak self] in + var deliveries = 0 + for await activity in Activity.activityUpdates { + deliveries += 1 + let incomingID = activity.id + LogManager.shared.log( + category: .general, + message: "[LA] activityUpdates delivery #\(deliveries) id=\(incomingID) — dispatching to MainActor" + ) + await MainActor.run { + self?.adoptPushToStartActivity(activity) + } + } + LogManager.shared.log( + category: .general, + message: "[LA] activityUpdates stream ended after \(deliveries) deliveries — push-to-start adoption will no longer work until app relaunch" + ) + } + } + + @MainActor + private func adoptPushToStartActivity(_ activity: Activity) { + // Skip if it's the activity we already track (app-initiated path binds it directly). + if current?.id == activity.id { + LogManager.shared.log( + category: .general, + message: "[LA] activityUpdates: ignoring own activity id=\(activity.id) (already current)" + ) + return + } + + let adoptDelay = lastPushToStartSuccessAt.map { Int(Date().timeIntervalSince($0)) } + let delayDescription = adoptDelay.map { "\($0)s after last push-to-start success" } ?? "no prior push-to-start this session" + let totalActivities = Activity.activities.count + let staleDate = activity.content.staleDate + let staleDesc = staleDate.map { String(format: "%.0f", $0.timeIntervalSinceNow) + "s" } ?? "nil" + let incomingSeq = activity.content.state.seq + LogManager.shared.log( + category: .general, + message: "[LA] adopt: id=\(activity.id) seq=\(incomingSeq) staleIn=\(staleDesc) totalActivities=\(totalActivities) (\(delayDescription))" + ) + lastPushToStartSuccessAt = nil + pushToStartSendsWithoutAdoption = 0 + + // If we already have a current activity and this is a different one, it's likely + // the new push-to-start LA replacing an old one. End the old, then bind the new. + if let old = current, old.id != activity.id { + LogManager.shared.log( + category: .general, + message: "[LA] activityUpdates: replacing old=\(old.id) with new=\(activity.id)" + ) + let oldActivity = old + Task { + await oldActivity.end(nil, dismissalPolicy: .immediate) + } + } else { + LogManager.shared.log( + category: .general, + message: "[LA] activityUpdates: adopting new activity id=\(activity.id) (no prior current)" + ) + } + // Fresh deadline — push-to-start-initiated LAs reset the 8-hour clock. + Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + dismissedByUser = false + bind(to: activity, logReason: "push-to-start-adopt") } /// Fires before the app loses focus (lock screen, home button, etc.). @@ -105,21 +238,36 @@ final class LiveActivityManager { let renewBy = Storage.shared.laRenewBy.value let now = Date().timeIntervalSince1970 let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + let stuckSends = pushToStartSendsWithoutAdoption + let pushToStartLooksStuck = stuckSends >= LiveActivityManager.pushToStartForceRestartThreshold LogManager.shared.log( category: .general, - message: "[LA] foreground: renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now)" + message: "[LA] foreground: renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now), pushToStartSendsWithoutAdoption=\(stuckSends)" ) - guard renewalFailed || overlayIsShowing else { + guard renewalFailed || overlayIsShowing || pushToStartLooksStuck else { LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)") return } - LogManager.shared.log( - category: .general, - message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))" - ) + if pushToStartLooksStuck { + // Reset the counter now so we don't re-trigger on every foreground + // entry until the next round of silently-failed sends actually + // builds up again. The restart itself ends the current LA and + // starts a fresh one, which (per Apple's docs) should cause iOS to + // emit a new pushToStartToken — the workaround for FB21158660. + pushToStartSendsWithoutAdoption = 0 + LogManager.shared.log( + category: .general, + message: "[LA] foreground: push-to-start looks stuck (sendsWithoutAdoption=\(stuckSends) ≥ \(LiveActivityManager.pushToStartForceRestartThreshold)) — forcing local restart to nudge token rotation" + ) + } else { + LogManager.shared.log( + category: .general, + message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))" + ) + } skipNextDidBecomeActive = true @@ -231,6 +379,20 @@ final class LiveActivityManager { static let renewalWarning: TimeInterval = 30 * 60 static let extensionLivenessGrace: TimeInterval = 15 * 60 + /// Base backoff after a 429 for push-to-start; doubled on each subsequent 429, + /// capped at `pushToStartMaxBackoff`. Reset to base after a successful send. + private static let pushToStartBaseBackoff: TimeInterval = 300 // 5 min + private static let pushToStartMaxBackoff: TimeInterval = 3600 // 60 min + /// When a successful APNs push-to-start does not result in an `activityUpdates` + /// adoption, count those orphaned sends. After this threshold, the next + /// foreground entry forces a local restart to nudge iOS to issue a new + /// pushToStartToken — Apple FB21158660 workaround. + private static let pushToStartForceRestartThreshold: Int = 2 + /// Polling timeout for the push-to-start token to arrive after a fresh install. + /// `pushToStartTokenUpdates` typically delivers within a couple of seconds. + private static let pushToStartTokenWaitTimeout: TimeInterval = 5 + private static let pushToStartTokenPollInterval: TimeInterval = 0.5 + private(set) var current: Activity? private var stateObserverTask: Task? private var updateTask: Task? @@ -252,15 +414,68 @@ final class LiveActivityManager { /// Set by handleForeground() when it takes ownership of the restart sequence. /// Prevents handleDidBecomeActive() from racing with an in-flight end+restart. private var skipNextDidBecomeActive = false + /// Observes `pushToStartTokenUpdates` (iOS 17.2+) and persists the token. + /// Long-lived — started once at init and never cancelled. + private var pushToStartObservationTask: Task? + /// Observes `Activity<>.activityUpdates` so activities started out-of-band + /// (push-to-start) are adopted automatically. + private var activityUpdatesObservationTask: Task? + /// Timestamp of the last successful push-to-start APNs dispatch. Used to log + /// the delay until iOS delivers the new activity via `activityUpdates`. If + /// adoption never happens, a growing gap here is the fingerprint. + private var lastPushToStartSuccessAt: Date? + /// Number of consecutive successful push-to-start APNs sends that have NOT + /// been followed by an `activityUpdates` adoption. When this reaches + /// `pushToStartForceRestartThreshold`, the next foreground entry forces a + /// local restart even outside the renewal window — ending the existing LA + /// and starting a fresh one is the only known way to nudge iOS to issue a + /// new `pushToStartToken` when the current one has gone silent + /// (Apple FB21158660). + private var pushToStartSendsWithoutAdoption: Int = 0 // MARK: - Public API + @MainActor func startIfNeeded() { guard ActivityAuthorizationInfo().areActivitiesEnabled else { LogManager.shared.log(category: .general, message: "Live Activity not authorized") return } + if #available(iOS 17.2, *) { + // iOS 17.2+ uses push-to-start for every creation path. If an + // activity is already running and not stale we adopt/reuse it + // (covers warm starts where the LA survived a relaunch); only + // truly new starts dispatch APNs. + if let existing = Activity.activities.first { + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false + let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed + if !needsRestart { + bind(to: existing, logReason: "reuse") + Storage.shared.laRenewalFailed.value = false + return + } + LogManager.shared.log( + category: .general, + message: "[LA] existing activity is stale on startIfNeeded (iOS 17.2+) — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))" + ) + attemptPushToStartCreate(reason: "user-start", oldActivity: existing) + return + } + attemptPushToStartCreate(reason: "user-start", oldActivity: nil) + } else { + startIfNeededLegacy() + } + } + + /// Pre-17.2 path (iOS 16.6 – 17.1). Identical to dev's `startIfNeeded` — + /// Activity.request() for everything. Removable when the deployment target + /// reaches 17.2. + @MainActor + private func startIfNeededLegacy() { if let existing = Activity.activities.first { // Before reusing, check whether this activity needs a restart. This covers cold // starts (app was killed while the overlay was showing — willEnterForeground is @@ -287,7 +502,7 @@ final class LiveActivityManager { Task { await existing.end(nil, dismissalPolicy: .immediate) - await MainActor.run { self.startIfNeeded() } + await MainActor.run { self.startIfNeededLegacy() } } return } @@ -300,9 +515,6 @@ final class LiveActivityManager { do { let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - // Prefer a freshly built snapshot so all extended fields are populated. - // Fall back to the persisted store (covers cold-start with real data), - // then to a zero seed (true first-ever launch with no data yet). let provider = StorageCurrentGlucoseStateProvider() let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider) ?? GlucoseSnapshotStore.shared.load() @@ -399,9 +611,18 @@ final class LiveActivityManager { func forceRestart() { guard Storage.shared.laEnabled.value else { return } LogManager.shared.log(category: .general, message: "[LA] forceRestart called") + // Mark as system-initiated so any residual `.dismissed` delivered from + // the cancelled state observer cannot flip dismissedByUser=true and + // spoil the freshly started LA. + endingForRestart = true dismissedByUser = false Storage.shared.laRenewBy.value = 0 Storage.shared.laRenewalFailed.value = false + // The user explicitly asked for a fresh LA — clear any push-to-start + // backoff that would otherwise rate-limit the Restart button silently. + Storage.shared.laLastPushToStartAt.value = 0 + Storage.shared.laPushToStartBackoff.value = 0 + pushToStartSendsWithoutAdoption = 0 LALivenessStore.clear() cancelRenewalFailedNotification() current = nil @@ -420,6 +641,7 @@ final class LiveActivityManager { } } + @MainActor func startFromCurrentState(cleanupOrphans: Bool = false) { guard Storage.shared.laEnabled.value, !dismissedByUser else { return } @@ -457,8 +679,6 @@ final class LiveActivityManager { /// Requests a fresh Live Activity to replace the current one when the renewal /// deadline has passed, working around Apple's 8-hour maximum LA lifetime. - /// The new LA is requested FIRST — the old one is only ended if that succeeds, - /// so the user keeps live data if Activity.request() throws. /// Returns true if renewal was performed (caller should return early). private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool { guard let oldActivity = current else { return false } @@ -469,6 +689,27 @@ final class LiveActivityManager { let overdueBy = Date().timeIntervalSince1970 - renewBy LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") + if #available(iOS 17.2, *) { + // iOS 17.2+: renewal goes through push-to-start. The dispatch hops + // to MainActor and returns immediately; adoption (or failure) lands + // in the observer. Return true so performRefresh stops processing + // this tick. + Task { @MainActor [weak self] in + self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot) + } + return true + } else { + return attemptLegacyRenewal(snapshot: snapshot, oldActivity: oldActivity) + } + } + + /// Pre-17.2 renewal (iOS 16.6 – 17.1): foreground Activity.request, mark + /// renewal-failed if it throws. Removable when the deployment target + /// reaches 17.2. + private func attemptLegacyRenewal( + snapshot: GlucoseSnapshot, + oldActivity: Activity + ) -> Bool { let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") @@ -523,6 +764,225 @@ final class LiveActivityManager { } } + // MARK: - Push-to-start (iOS 17.2+) + + /// Single creation path for iOS 17.2+. Handles initial start, renewal, and + /// forced restart. Verifies token + APNs credentials, applies backoff, ends + /// the old activity (if any) before sending so the new push-to-start LA + /// cleanly replaces it. Adoption is delivered via the `activityUpdates` + /// observer — `handlePushToStartResult` only updates backoff/state. + @available(iOS 17.2, *) + @MainActor + private func attemptPushToStartCreate( + reason: String, + oldActivity: Activity?, + snapshot: GlucoseSnapshot? = nil + ) { + // Validate APNs credentials up-front — push-to-start is the ONLY transport + // on iOS 17.2+, so missing/invalid creds mean the LA will never display. + let keyId = Storage.shared.lfKeyId.value + let apnsKey = Storage.shared.lfApnsKey.value + guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else { + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start (\(reason)) blocked — APNs credentials missing or invalid (keyId valid=\(APNsCredentialValidator.isValidKeyId(keyId)), apnsKey valid=\(APNsCredentialValidator.isValidApnsKey(apnsKey)))" + ) + scheduleApnsCredentialsMissingNotification() + return + } + + let now = Date().timeIntervalSince1970 + let lastAt = Storage.shared.laLastPushToStartAt.value + let backoff = Storage.shared.laPushToStartBackoff.value + if lastAt > 0, now < lastAt + backoff { + let wait = Int(lastAt + backoff - now) + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start (\(reason)) rate-limited: next allowed in \(wait)s (backoff=\(Int(backoff))s)" + ) + return + } + + // Build snapshot if caller didn't supply one (initial start path). + let workingSnapshot: GlucoseSnapshot = { + if let snapshot { return snapshot } + let provider = StorageCurrentGlucoseStateProvider() + return GlucoseSnapshotBuilder.build(from: provider) + ?? GlucoseSnapshotStore.shared.load() + ?? GlucoseSnapshot( + glucose: 0, + delta: 0, + trend: .unknown, + updatedAt: Date(), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false, + ) + }() + + Task { [weak self] in + guard let self else { return } + await self.dispatchPushToStart( + reason: reason, + oldActivity: oldActivity, + snapshot: workingSnapshot + ) + } + } + + @available(iOS 17.2, *) + private func dispatchPushToStart( + reason: String, + oldActivity: Activity?, + snapshot: GlucoseSnapshot + ) async { + // Wait briefly for the push-to-start token to arrive — covers the + // fresh-install case where the user toggles LA on before iOS has + // delivered the first token via pushToStartTokenUpdates. + var token = Storage.shared.laPushToStartToken.value + if token.isEmpty { + let pollIntervalNs = UInt64(LiveActivityManager.pushToStartTokenPollInterval * 1_000_000_000) + let maxAttempts = Int(LiveActivityManager.pushToStartTokenWaitTimeout / LiveActivityManager.pushToStartTokenPollInterval) + for attempt in 1 ... maxAttempts { + try? await Task.sleep(nanoseconds: pollIntervalNs) + token = Storage.shared.laPushToStartToken.value + if !token.isEmpty { + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start (\(reason)) token arrived after \(attempt) poll(s)" + ) + break + } + } + } + guard !token.isEmpty else { + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start (\(reason)) aborted — no token after \(LiveActivityManager.pushToStartTokenWaitTimeout)s wait (iOS hasn't issued one yet)" + ) + await MainActor.run { self.schedulePushToStartTokenMissingNotification() } + return + } + + // Record attempt time up-front so two refresh ticks can't double-fire. + await MainActor.run { + Storage.shared.laLastPushToStartAt.value = Date().timeIntervalSince1970 + } + + let nextSeq = await MainActor.run { () -> Int in + self.seq += 1 + return self.seq + } + let freshSnapshot = snapshot.withRenewalOverlay(false) + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: freshSnapshot, + seq: nextSeq, + reason: reason, + producedAt: Date(), + ) + let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + + let tail = String(token.suffix(8)) + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start (\(reason)) firing token=…\(tail) seq=\(nextSeq) staleIn=\(Int(staleDate.timeIntervalSinceNow))s" + ) + + // End the old activity inline so the push-to-start cleanly replaces it. + if let oldActivity { + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start (\(reason)) ending oldActivity=\(oldActivity.id) before send" + ) + await oldActivity.end(nil, dismissalPolicy: .immediate) + } + + let sendStart = Date() + let result = await APNSClient.shared.sendLiveActivityStart( + pushToStartToken: token, + attributesTitle: "LoopFollow", + state: state, + staleDate: staleDate, + ) + let elapsedMs = Int(Date().timeIntervalSince(sendStart) * 1000) + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start (\(reason)) APNs round-trip result=\(result) elapsed=\(elapsedMs)ms" + ) + await MainActor.run { + self.handlePushToStartResult(result, reason: reason) + } + } + + @available(iOS 17.2, *) + @MainActor + private func handlePushToStartResult( + _ result: APNSClient.PushToStartResult, + reason: String + ) { + switch result { + case .success: + // Adoption of the new LA runs via `activityUpdates` observation, + // which ends the old activity, resets the renewal deadline and + // clears `laRenewalFailed`. Apply base backoff so refresh ticks + // between now and adoption don't re-fire push-to-start. + Storage.shared.laPushToStartBackoff.value = LiveActivityManager.pushToStartBaseBackoff + lastPushToStartSuccessAt = Date() + pushToStartSendsWithoutAdoption += 1 + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start (\(reason)) succeeded — awaiting activityUpdates to adopt new LA (backoff=\(Int(LiveActivityManager.pushToStartBaseBackoff))s, sendsWithoutAdoption=\(pushToStartSendsWithoutAdoption))" + ) + case .rateLimited: + let currentBackoff = Storage.shared.laPushToStartBackoff.value + let next = min( + LiveActivityManager.pushToStartMaxBackoff, + max(LiveActivityManager.pushToStartBaseBackoff, currentBackoff * 2) + ) + Storage.shared.laPushToStartBackoff.value = next + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start (\(reason)) 429 — backoff raised to \(Int(next))s" + ) + if reason == "renew" { markRenewalFailedFromBackground() } + case .tokenInvalid: + // Clear the stored token so the next `pushToStartTokenUpdates` + // delivery overwrites it. Reset backoff — no point holding off + // while we wait for iOS to reissue. + Storage.shared.laPushToStartToken.value = "" + Storage.shared.laPushToStartBackoff.value = 0 + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start (\(reason)) token invalid — cleared, awaiting new token" + ) + if reason == "renew" { markRenewalFailedFromBackground() } + case .failed: + let currentBackoff = Storage.shared.laPushToStartBackoff.value + if currentBackoff < LiveActivityManager.pushToStartBaseBackoff { + Storage.shared.laPushToStartBackoff.value = LiveActivityManager.pushToStartBaseBackoff + } + if reason == "renew" { markRenewalFailedFromBackground() } + } + } + + /// Background renewal couldn't restart the LA via push-to-start (rate-limited, + /// invalid token, etc.). Mark the state so the renewal overlay shows on the + /// lock screen, and post a local notification on the first failure so the + /// user knows to foreground the app. + private func markRenewalFailedFromBackground() { + let isFirstFailure = !Storage.shared.laRenewalFailed.value + Storage.shared.laRenewalFailed.value = true + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start renewal failed — renewal marked failed" + ) + if isFirstFailure { + scheduleRenewalFailedNotification() + } + } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -570,9 +1030,11 @@ final class LiveActivityManager { return } if isAppVisibleForLiveActivityStart() { - startIfNeeded() - if current != nil { - update(snapshot: snapshot, reason: reason) + Task { @MainActor in + self.startIfNeeded() + if self.current != nil { + self.update(snapshot: snapshot, reason: reason) + } } } else { LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true) @@ -687,6 +1149,10 @@ final class LiveActivityManager { } func handleExpiredToken() { + // Mark as system-initiated so the `.dismissed` delivered by end() + // is not classified as a user swipe — that would set dismissedByUser=true + // and block the auto-restart promised by the comment below. + endingForRestart = true end() // Activity will restart on next BG refresh via refreshFromCurrentState() } @@ -694,6 +1160,8 @@ final class LiveActivityManager { // MARK: - Renewal Notifications private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed" + private static let apnsCredentialsNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.apns.missing" + private static let pushToStartTokenNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.token.missing" private func scheduleRenewalFailedNotification() { let content = UNMutableNotificationContent() @@ -720,6 +1188,32 @@ final class LiveActivityManager { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id]) } + private func scheduleApnsCredentialsMissingNotification() { + let content = UNMutableNotificationContent() + content.title = "Live Activity Setup Needed" + content.body = "APNs credentials are missing or invalid. Configure them in Settings → APN." + content.sound = .default + let request = UNNotificationRequest( + identifier: LiveActivityManager.apnsCredentialsNotificationID, + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false), + ) + UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) + } + + private func schedulePushToStartTokenMissingNotification() { + let content = UNMutableNotificationContent() + content.title = "Live Activity Could Not Start" + content.body = "Live Activity could not start — try again in a moment." + content.sound = .default + let request = UNNotificationRequest( + identifier: LiveActivityManager.pushToStartTokenNotificationID, + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false), + ) + UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) + } + private func attachStateObserver(to activity: Activity) { stateObserverTask?.cancel() stateObserverTask = Task { diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 2ee27bd59..1722d49fe 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -134,9 +134,19 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { #if targetEnvironment(macCatalyst) return false #else - let renewBy = Storage.shared.laRenewBy.value - let now = Date().timeIntervalSince1970 - return renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + // iOS 17.2+ renews silently via push-to-start at the deadline, so the + // pre-emptive 30-minute "tap to update" overlay would be misleading + // during normal operation. Only show it once renewal has actually + // failed (no token, bad creds, rate-limited) — that is genuinely + // user-actionable. iOS 16.x keeps the time-based warning because + // renewal there requires the user to foreground the app. + if #available(iOS 17.2, *) { + return Storage.shared.laRenewalFailed.value + } else { + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + return renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + } #endif } } diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 22403cd0f..56d4d6c71 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -2,6 +2,7 @@ // LogManager.swift import Foundation +import UIKit class LogManager { static let shared = LogManager() @@ -113,6 +114,16 @@ class LogManager { let expirationHeaderString = buildDetails.expirationHeaderString let isMacApp = buildDetails.isMacApp() let isSimulatorBuild = buildDetails.isSimulatorBuild() + let osLabel: String + let osVersion: String + if isMacApp { + osLabel = "macOS" + let v = ProcessInfo.processInfo.operatingSystemVersion + osVersion = "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } else { + osLabel = "iOS" + osVersion = UIDevice.current.systemVersion + } // Assemble header information var headerLines = [String]() @@ -122,6 +133,7 @@ class LogManager { } headerLines.append("Built: \(formattedBuildDate)") headerLines.append("Branch: \(branchAndSha)") + headerLines.append("\(osLabel): \(osVersion)") let headerMessage = headerLines.joined(separator: ", ") + "\n" let logMessage = formattedLogMessage(for: .general, message: headerMessage) diff --git a/LoopFollow/Settings/APNSettingsView.swift b/LoopFollow/Settings/APNSettingsView.swift index 79b07e7cd..359664dbe 100644 --- a/LoopFollow/Settings/APNSettingsView.swift +++ b/LoopFollow/Settings/APNSettingsView.swift @@ -7,26 +7,59 @@ struct APNSettingsView: View { @State private var keyId: String = Storage.shared.lfKeyId.value @State private var apnsKey: String = Storage.shared.lfApnsKey.value + private var keyIdValid: Bool { + APNsCredentialValidator.isValidKeyId(keyId) + } + + private var apnsKeyValid: Bool { + APNsCredentialValidator.isValidApnsKey(apnsKey) + } + var body: some View { Form { Section(header: Text("LoopFollow APNs Credentials")) { - HStack { - Text("APNS Key ID") - TogglableSecureInput( - placeholder: "Enter APNS Key ID", - text: $keyId, - style: .singleLine - ) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $keyId, + style: .singleLine + ) + if !keyId.isEmpty { + Image(systemName: keyIdValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundColor(keyIdValid ? .green : .orange) + .accessibilityLabel(keyIdValid ? "Valid Key ID" : "Invalid Key ID") + } + } + if !keyId.isEmpty, !keyIdValid { + Text("Key ID must be exactly 10 uppercase letters or digits.") + .font(.caption) + .foregroundColor(.orange) + } } - VStack(alignment: .leading) { - Text("APNS Key") + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("APNS Key") + Spacer() + if !apnsKey.isEmpty { + Image(systemName: apnsKeyValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundColor(apnsKeyValid ? .green : .orange) + .accessibilityLabel(apnsKeyValid ? "Valid APNs key" : "Invalid APNs key") + } + } TogglableSecureInput( placeholder: "Paste APNS Key", text: $apnsKey, style: .multiLine ) .frame(minHeight: 110) + if !apnsKey.isEmpty, !apnsKeyValid { + Text("Paste the full .p8 contents — must include the BEGIN PRIVATE KEY and END PRIVATE KEY lines.") + .font(.caption) + .foregroundColor(.orange) + } } } } @@ -35,7 +68,14 @@ struct APNSettingsView: View { } .onChange(of: apnsKey) { newValue in let apnsService = LoopAPNSService() - Storage.shared.lfApnsKey.value = apnsService.validateAndFixAPNSKey(newValue) + let normalized = apnsService.validateAndFixAPNSKey(newValue) + Storage.shared.lfApnsKey.value = normalized + // Reflect normalization (whitespace fixes etc.) in the field so the + // green badge appears when paste added stray whitespace around an + // otherwise-valid key. + if normalized != newValue { + apnsKey = normalized + } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationTitle("APN") diff --git a/LoopFollow/Settings/LiveActivitySettingsView.swift b/LoopFollow/Settings/LiveActivitySettingsView.swift index bfe39b3ee..3d49df818 100644 --- a/LoopFollow/Settings/LiveActivitySettingsView.swift +++ b/LoopFollow/Settings/LiveActivitySettingsView.swift @@ -6,14 +6,35 @@ import SwiftUI struct LiveActivitySettingsView: View { @State private var laEnabled: Bool = Storage.shared.laEnabled.value @State private var restartConfirmed = false + @State private var keyId: String = Storage.shared.lfKeyId.value + @State private var apnsKey: String = Storage.shared.lfApnsKey.value + + private var apnsConfigured: Bool { + APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) + } var body: some View { Form { - Section(header: Text("Live Activity")) { + Section( + header: Text("Live Activity"), + footer: Text("Live Activity updates require APNs credentials. Configure them in Settings → APN.") + ) { Toggle("Enable Live Activity", isOn: $laEnabled) } if laEnabled { + if !apnsConfigured { + Section { + Label { + Text("APNs credentials are missing or invalid — Live Activity updates will not work. Open Settings → APN to fix.") + .font(.callout) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + } + } + } + Section { Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { LiveActivityManager.shared.forceRestart() @@ -29,6 +50,12 @@ struct LiveActivitySettingsView: View { .onReceive(Storage.shared.laEnabled.$value) { newValue in if newValue != laEnabled { laEnabled = newValue } } + .onReceive(Storage.shared.lfKeyId.$value) { newValue in + if newValue != keyId { keyId = newValue } + } + .onReceive(Storage.shared.lfApnsKey.$value) { newValue in + if newValue != apnsKey { apnsKey = newValue } + } .onChange(of: laEnabled) { newValue in Storage.shared.laEnabled.value = newValue if !newValue { diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 5dea5ebb1..2100c9b89 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -116,6 +116,9 @@ class Storage { var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) + var laPushToStartToken = StorageValue(key: "laPushToStartToken", defaultValue: "") + var laLastPushToStartAt = StorageValue(key: "laLastPushToStartAt", defaultValue: 0) + var laPushToStartBackoff = StorageValue(key: "laPushToStartBackoff", defaultValue: 0) // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) @@ -325,6 +328,9 @@ class Storage { laEnabled.reload() laRenewBy.reload() laRenewalFailed.reload() + laPushToStartToken.reload() + laLastPushToStartAt.reload() + laPushToStartBackoff.reload() showDots.reload() showLines.reload()