diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 9a11022d6..17ae46c6e 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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 = ""; }; 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; - 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessStore.swift; sourceTree = ""; }; - 37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessMarker.swift; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; @@ -922,8 +917,6 @@ 376310762F5CD65100656488 /* LiveActivity */ = { isa = PBXGroup; children = ( - 37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */, - 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */, 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */, 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */, 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */, @@ -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 */, ); @@ -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 */, diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 8755b1b27..fbeea394c 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -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] = [ @@ -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) } } diff --git a/LoopFollow/LiveActivity/LALivenessMarker.swift b/LoopFollow/LiveActivity/LALivenessMarker.swift deleted file mode 100644 index ce066e489..000000000 --- a/LoopFollow/LiveActivity/LALivenessMarker.swift +++ /dev/null @@ -1,21 +0,0 @@ -// LoopFollow -// LALivenessMarker.swift - -import SwiftUI - -struct LALivenessMarker: View { - let seq: Int - let producedAt: Date - - var body: some View { - Color.clear - .frame(width: 0, height: 0) - .task(id: markerID) { - LALivenessStore.markExtensionRender(seq: seq, producedAt: producedAt) - } - } - - private var markerID: String { - "\(seq)-\(producedAt.timeIntervalSince1970)" - } -} diff --git a/LoopFollow/LiveActivity/LALivenessStore.swift b/LoopFollow/LiveActivity/LALivenessStore.swift deleted file mode 100644 index e6f8ebe98..000000000 --- a/LoopFollow/LiveActivity/LALivenessStore.swift +++ /dev/null @@ -1,38 +0,0 @@ -// LoopFollow -// LALivenessStore.swift - -import Foundation - -enum LALivenessStore { - private static let defaults = UserDefaults(suiteName: AppGroupID.baseBundleID) - - private enum Key { - static let lastExtensionSeenAt = "la.liveness.lastExtensionSeenAt" - static let lastExtensionSeq = "la.liveness.lastExtensionSeq" - static let lastExtensionProducedAt = "la.liveness.lastExtensionProducedAt" - } - - static func markExtensionRender(seq: Int, producedAt: Date) { - defaults?.set(Date().timeIntervalSince1970, forKey: Key.lastExtensionSeenAt) - defaults?.set(seq, forKey: Key.lastExtensionSeq) - defaults?.set(producedAt.timeIntervalSince1970, forKey: Key.lastExtensionProducedAt) - } - - static var lastExtensionSeenAt: TimeInterval { - defaults?.double(forKey: Key.lastExtensionSeenAt) ?? 0 - } - - static var lastExtensionSeq: Int { - defaults?.integer(forKey: Key.lastExtensionSeq) ?? 0 - } - - static var lastExtensionProducedAt: TimeInterval { - defaults?.double(forKey: Key.lastExtensionProducedAt) ?? 0 - } - - static func clear() { - defaults?.removeObject(forKey: Key.lastExtensionSeenAt) - defaults?.removeObject(forKey: Key.lastExtensionSeq) - defaults?.removeObject(forKey: Key.lastExtensionProducedAt) - } -} diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index fb73c3409..84609fa14 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -39,6 +39,131 @@ final class LiveActivityManager { name: .backgroundAudioFailed, object: nil, ) + startPushToStartTokenObservation() + startActivityUpdatesObservation() + } + + /// 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 (iOS 16.2+). 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() { + if #available(iOS 16.2, *) { + activityUpdatesObservationTask?.cancel() + LogManager.shared.log( + category: .general, + message: "[LA] activityUpdates observation starting (iOS 16.2+)" + ) + 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" + ) + } + } else { + LogManager.shared.log( + category: .general, + message: "[LA] activityUpdates unavailable (iOS <16.2) — push-to-start adoption cannot work" + ) + } + } + + @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 + + // 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.). @@ -87,12 +212,18 @@ final class LiveActivityManager { @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } - if skipNextDidBecomeActive { - LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: skipped (handleForeground owns restart)", isDebug: true) - skipNextDidBecomeActive = false + let appState = UIApplication.shared.applicationState.rawValue + let existing = Activity.activities.count + if pendingForegroundRestart { + pendingForegroundRestart = false + LogManager.shared.log( + category: .general, + message: "[LA] didBecomeActive: running deferred foreground restart (appState=\(appState), activities=\(existing))" + ) + performForegroundRestart() return } - LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: calling startFromCurrentState, dismissedByUser=\(dismissedByUser)", isDebug: true) + LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: startFromCurrentState (appState=\(appState), activities=\(existing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser))", isDebug: true) Task { @MainActor in self.startFromCurrentState() } @@ -105,10 +236,12 @@ final class LiveActivityManager { let renewBy = Storage.shared.laRenewBy.value let now = Date().timeIntervalSince1970 let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + let appState = UIApplication.shared.applicationState.rawValue + let existing = Activity.activities.count 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: appState=\(appState), activities=\(existing), renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now)" ) guard renewalFailed || overlayIsShowing else { @@ -116,13 +249,17 @@ final class LiveActivityManager { return } + // willEnterForegroundNotification fires before the scene reaches + // foregroundActive — Activity.request() returns `visibility` during + // this window. Defer the actual restart to didBecomeActive. + pendingForegroundRestart = true LogManager.shared.log( category: .general, - message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))" + message: "[LA] foreground: scheduling restart on next didBecomeActive (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))" ) + } - skipNextDidBecomeActive = true - + private func performForegroundRestart() { // Mark restart intent BEFORE clearing storage flags, so any late .dismissed // from the old activity is never misclassified as a user swipe. endingForRestart = true @@ -189,47 +326,8 @@ final class LiveActivityManager { refreshFromCurrentState(reason: "audio-session-failed") } - private func shouldRestartBecauseExtensionLooksStuck() -> Bool { - guard Storage.shared.laEnabled.value else { return false } - guard !dismissedByUser else { return false } - - guard let activity = current ?? Activity.activities.first else { - return false - } - - let now = Date().timeIntervalSince1970 - let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false - if staleDatePassed { - LogManager.shared.log( - category: .general, - message: "[LA] liveness check: staleDate already passed" - ) - return true - } - - let expectedSeq = activity.content.state.seq - let seenSeq = LALivenessStore.lastExtensionSeq - let lastSeenAt = LALivenessStore.lastExtensionSeenAt - let lastProducedAt = LALivenessStore.lastExtensionProducedAt - - let extensionHasNeverCheckedIn = lastSeenAt <= 0 - let extensionLooksBehind = seenSeq < expectedSeq - let noRecentExtensionTouch = extensionHasNeverCheckedIn || (now - lastSeenAt > LiveActivityManager.extensionLivenessGrace) - - LogManager.shared.log( - category: .general, - message: "[LA] liveness check: expectedSeq=\(expectedSeq), seenSeq=\(seenSeq), lastSeenAt=\(lastSeenAt), lastProducedAt=\(lastProducedAt), behind=\(extensionLooksBehind), noRecentTouch=\(noRecentExtensionTouch)", - isDebug: true - ) - - // Conservative rule: - // only suspect "stuck" if the extension is both behind AND has not checked in recently. - return extensionLooksBehind && noRecentExtensionTouch - } - static let renewalThreshold: TimeInterval = 7.5 * 3600 static let renewalWarning: TimeInterval = 30 * 60 - static let extensionLivenessGrace: TimeInterval = 15 * 60 private(set) var current: Activity? private var stateObserverTask: Task? @@ -249,14 +347,36 @@ final class LiveActivityManager { /// a .dismissed delivery triggered by our own end() call is never misclassified as a /// user swipe — regardless of the order in which the MainActor executes the two writes. private var endingForRestart = false - /// 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 + /// Set by handleForeground() when the renewal window has been detected. + /// The actual end+restart is run from handleDidBecomeActive() because + /// Activity.request() returns `visibility` during willEnterForeground. + private var pendingForegroundRestart = 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` (iOS 16.2+) 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? + /// Base backoff after a 429 for push-to-start; doubled on each subsequent 429, + /// capped at `pushToStartMaxBackoff`. Reset to zero after a successful send. + private static let pushToStartBaseBackoff: TimeInterval = 300 // 5 min + private static let pushToStartMaxBackoff: TimeInterval = 3600 // 60 min // MARK: - Public API func startIfNeeded() { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { + let authorized = ActivityAuthorizationInfo().areActivitiesEnabled + let existingCount = Activity.activities.count + LogManager.shared.log( + category: .general, + message: "[LA] startIfNeeded: authorized=\(authorized), activities=\(existingCount), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), laEnabled=\(Storage.shared.laEnabled.value)", + isDebug: true + ) + guard authorized else { LogManager.shared.log(category: .general, message: "Live Activity not authorized") return } @@ -327,7 +447,6 @@ final class LiveActivityManager { let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let content = ActivityContent(state: initialState, staleDate: renewDeadline) - LALivenessStore.clear() let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) bind(to: activity, logReason: "start-new") @@ -335,7 +454,12 @@ final class LiveActivityManager { Storage.shared.laRenewalFailed.value = false LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)") } catch { - LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)") + let ns = error as NSError + let scene = isAppVisibleForLiveActivityStart() + LogManager.shared.log( + category: .general, + message: "Live Activity failed to start: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), sceneActive=\(scene), activities=\(Activity.activities.count)" + ) } } @@ -344,9 +468,12 @@ final class LiveActivityManager { /// Does not clear laEnabled — the user's preference is preserved for relaunch. func endOnTerminate() { guard let activity = current else { return } + // Flag the end as system-initiated so the state observer does not + // classify the resulting `.dismissed` as a user swipe (laRenewBy is + // cleared below, which would otherwise make pastDeadline=false). + endingForRestart = true current = nil Storage.shared.laRenewBy.value = 0 - LALivenessStore.clear() let semaphore = DispatchSemaphore(value: 0) Task.detached { await activity.end(nil, dismissalPolicy: .immediate) @@ -388,7 +515,6 @@ final class LiveActivityManager { if current?.id == activity.id { current = nil Storage.shared.laRenewBy.value = 0 - LALivenessStore.clear() } } } @@ -399,10 +525,13 @@ 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 stream cannot flip dismissedByUser=true + // and spoil the freshly started LA. + endingForRestart = true dismissedByUser = false Storage.shared.laRenewBy.value = 0 Storage.shared.laRenewalFailed.value = false - LALivenessStore.clear() cancelRenewalFailedNotification() current = nil updateTask?.cancel(); updateTask = nil @@ -457,9 +586,7 @@ 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). + /// Returns true if a foreground restart was performed (caller returns early). private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool { guard let oldActivity = current else { return false } @@ -467,22 +594,68 @@ final class LiveActivityManager { guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } let overdueBy = Date().timeIntervalSince1970 - renewBy - LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") + // Negative timeToCeiling means the LA is already past Apple's 8h limit + // (the renewal deadline is 7.5h and the ceiling is 8h, so 30 minutes after + // `renewBy`). A foreground Activity.request should still work but a + // background adoption may race against system end-of-life. + let timeToCeiling = 30 * 60 - overdueBy + let laAgeHours = String(format: "%.2f", (LiveActivityManager.renewalThreshold + overdueBy) / 3600.0) + LogManager.shared.log( + category: .general, + message: "[LA] renewIfNeeded: firing — laAge=\(laAgeHours)h overdueBy=\(Int(overdueBy))s timeToCeiling=\(Int(timeToCeiling))s (positive = before 8h, negative = past 8h)" + ) + return attemptLARestart( + snapshot: snapshot, + oldActivity: oldActivity, + logReason: "renew", + ageSeconds: overdueBy + ) + } + + /// Unified restart path. Shared by deadline-based renewal so it takes the + /// same foreground / background / push-to-start / mark-failed decisions. + /// + /// 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 a foreground restart was performed (caller returns + /// early). Background paths return false even on successful push-to-start + /// dispatch — their async handlers update state as results come in. + private func attemptLARestart( + snapshot: GlucoseSnapshot, + oldActivity: Activity, + logReason: String, + ageSeconds: TimeInterval + ) -> Bool { + // Activity.request() requires a foregroundActive scene — from the background + // it always fails with `visibility`. Try push-to-start instead (iOS 17.2+); + // fall back to marking renewal failed and waiting for the user to foreground + // the app if push-to-start isn't available or doesn't succeed. + guard isAppVisibleForLiveActivityStart() else { + if attemptPushToStartIfEligible(snapshot: snapshot, overdueBy: ageSeconds, oldActivity: oldActivity) { + return false + } + markRenewalFailedFromBackground(overdueBy: ageSeconds) + return false + } + + LogManager.shared.log( + category: .general, + message: "[LA] restart (\(logReason)) age=\(Int(ageSeconds))s, requesting new LA" + ) let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - // Build the fresh snapshot with showRenewalOverlay: false — the new LA has a - // fresh deadline so no overlay is needed from the first frame. We pass the - // deadline as staleDate to ActivityContent below, not to Storage yet; Storage - // is only updated after Activity.request succeeds so a crash between the two - // can't leave the deadline permanently stuck in the future. + // showRenewalOverlay: false — the new LA has a fresh deadline so no overlay + // is needed from the first frame. The deadline is passed as staleDate below, + // not written to Storage yet; Storage is only updated after Activity.request + // succeeds so a crash between the two can't leave a stuck future deadline. let freshSnapshot = snapshot.withRenewalOverlay(false) let state = GlucoseLiveActivityAttributes.ContentState( snapshot: freshSnapshot, seq: seq, - reason: "renew", + reason: logReason, producedAt: Date(), ) let content = ActivityContent(state: state, staleDate: renewDeadline) @@ -502,20 +675,24 @@ final class LiveActivityManager { stateObserverTask = nil pushToken = nil - // Write deadline only on success — avoids a stuck future deadline if we crash - // between the write and the Activity.request call. Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 - bind(to: newActivity, logReason: "renew") + bind(to: newActivity, logReason: logReason) Storage.shared.laRenewalFailed.value = false cancelRenewalFailedNotification() GlucoseSnapshotStore.shared.save(freshSnapshot) - LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") + LogManager.shared.log( + category: .general, + message: "[LA] Live Activity restarted (\(logReason)) id=\(newActivity.id)" + ) return true } catch { - // Renewal failed — deadline was never written, so no rollback needed. let isFirstFailure = !Storage.shared.laRenewalFailed.value Storage.shared.laRenewalFailed.value = true - LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)") + let ns = error as NSError + LogManager.shared.log( + category: .general, + message: "[LA] restart (\(logReason)) failed, keeping existing LA: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), activities=\(Activity.activities.count)" + ) if isFirstFailure { scheduleRenewalFailedNotification() } @@ -523,6 +700,148 @@ final class LiveActivityManager { } } + /// Attempts to kick off a fresh LA via APNs push-to-start (iOS 17.2+) when the + /// app is not foregroundActive and the renewal deadline has passed. + /// + /// Returns true if an APNs request was actually dispatched. When true, the + /// async result handler updates backoff/renewal state. When false, the caller + /// falls back to `markRenewalFailedFromBackground`. + /// + /// Rate-limited: the stored backoff gates subsequent attempts. On iOS <17.2 + /// no token is ever stored, so this path simply returns false. + private func attemptPushToStartIfEligible( + snapshot: GlucoseSnapshot, + overdueBy: TimeInterval, + oldActivity _: Activity + ) -> Bool { + let token = Storage.shared.laPushToStartToken.value + guard !token.isEmpty else { + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start unavailable (no token — iOS <17.2 or not yet issued)" + ) + return false + } + + 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 rate-limited: next allowed in \(wait)s (backoff=\(Int(backoff))s)" + ) + return false + } + + // Record attempt time up-front so two refresh ticks can't double-fire. + Storage.shared.laLastPushToStartAt.value = now + + seq += 1 + let nextSeq = seq + let freshSnapshot = snapshot.withRenewalOverlay(false) + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: freshSnapshot, + seq: nextSeq, + reason: "push-to-start", + producedAt: Date(), + ) + let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + + let tail = String(token.suffix(8)) + let existingActivities = Activity.activities.count + let staleInSeconds = Int(staleDate.timeIntervalSinceNow) + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start firing overdueBy=\(Int(overdueBy))s token=…\(tail) seq=\(nextSeq) staleIn=\(staleInSeconds)s existingActivities=\(existingActivities) current=\(current?.id ?? "nil")" + ) + + let sendStart = Date() + Task { [weak self] in + 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 APNs round-trip result=\(result) elapsed=\(elapsedMs)ms" + ) + await MainActor.run { + self?.handlePushToStartResult(result, overdueBy: overdueBy) + } + } + return true + } + + @MainActor + private func handlePushToStartResult( + _ result: APNSClient.PushToStartResult, + overdueBy: TimeInterval + ) { + 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() + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start succeeded — awaiting activityUpdates to adopt new LA (backoff=\(Int(LiveActivityManager.pushToStartBaseBackoff))s)" + ) + 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 429 — backoff raised to \(Int(next))s" + ) + markRenewalFailedFromBackground(overdueBy: overdueBy) + case .tokenInvalid: + // Clear the stored token so the next `pushToStartTokenUpdates` + // delivery can overwrite 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 token invalid — cleared, awaiting new token" + ) + markRenewalFailedFromBackground(overdueBy: overdueBy) + case .failed: + let currentBackoff = Storage.shared.laPushToStartBackoff.value + if currentBackoff < LiveActivityManager.pushToStartBaseBackoff { + Storage.shared.laPushToStartBackoff.value = LiveActivityManager.pushToStartBaseBackoff + } + markRenewalFailedFromBackground(overdueBy: overdueBy) + } + } + + /// Background renewal couldn't restart the LA (not visible, and push-to-start + /// unavailable or rate-limited). 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(overdueBy: TimeInterval) { + let isFirstFailure = !Storage.shared.laRenewalFailed.value + Storage.shared.laRenewalFailed.value = true + LogManager.shared.log( + category: .general, + message: "[LA] renewal deadline passed by \(Int(overdueBy))s — app not visible, push-to-start unavailable, renewal marked failed" + ) + if isFirstFailure { + scheduleRenewalFailedNotification() + } + } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -557,9 +876,17 @@ final class LiveActivityManager { // WatchConnectivityManager.shared.send(snapshot: snapshot) // LA update: gated on LA being active, snapshot having changed, and activities enabled. - guard Storage.shared.laEnabled.value, !dismissedByUser else { return } + if !Storage.shared.laEnabled.value { + LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — laEnabled=false reason=\(reason)", isDebug: true) + return + } + if dismissedByUser { + LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — dismissedByUser=true reason=\(reason)") + return + } guard !snapshotUnchanged || forceRefreshNeeded else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { + LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)") return } if current == nil, let existing = Activity.activities.first { @@ -632,6 +959,12 @@ final class LiveActivityManager { if isForeground { await activity.update(content) + } else { + LogManager.shared.log( + category: .general, + message: "[LA] update seq=\(nextSeq) — app backgrounded, direct ActivityKit update skipped, relying on APNs", + isDebug: true + ) } if Task.isCancelled { return } @@ -646,6 +979,11 @@ final class LiveActivityManager { if let token = pushToken { await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) + } else { + LogManager.shared.log( + category: .general, + message: "[LA] update seq=\(nextSeq) reason=\(reason) — no push token yet, APNs skipped" + ) } } } @@ -668,25 +1006,48 @@ final class LiveActivityManager { private func bind(to activity: Activity, logReason: String) { if current?.id == activity.id { return } current = activity + let wasEndingForRestart = endingForRestart dismissedByUser = false endingForRestart = false attachStateObserver(to: activity) - LogManager.shared.log(category: .general, message: "Live Activity bound id=\(activity.id) (\(logReason))", isDebug: true) + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let renewIn = renewBy > 0 ? Int(renewBy - now) : 0 + let ceilingIn = renewBy > 0 ? Int(renewBy + 30 * 60 - now) : 0 + LogManager.shared.log( + category: .general, + message: "[LA] bind id=\(activity.id) state=\(activity.activityState) (\(logReason)) — renewIn=\(renewIn)s ceilingIn=\(ceilingIn)s endingForRestart cleared (was \(wasEndingForRestart))" + ) observePushToken(for: activity) } private func observePushToken(for activity: Activity) { tokenObservationTask?.cancel() + let activityID = activity.id tokenObservationTask = Task { for await tokenData in activity.pushTokenUpdates { let token = tokenData.map { String(format: "%02x", $0) }.joined() + let previousTail = self.pushToken.map { String($0.suffix(8)) } ?? "nil" + let tail = String(token.suffix(8)) self.pushToken = token - LogManager.shared.log(category: .general, message: "Live Activity push token received", isDebug: true) + LogManager.shared.log( + category: .general, + message: "[LA] push token received id=\(activityID) token=…\(tail) (prev=…\(previousTail))" + ) } } } func handleExpiredToken() { + let existing = Activity.activities.count + LogManager.shared.log( + category: .general, + message: "[LA] handleExpiredToken: current=\(current?.id ?? "nil"), activities=\(existing), dismissedByUser=\(dismissedByUser) — marking endingForRestart and ending" + ) + // 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() } diff --git a/LoopFollow/Settings/LiveActivitySettingsView.swift b/LoopFollow/Settings/LiveActivitySettingsView.swift deleted file mode 100644 index bfe39b3ee..000000000 --- a/LoopFollow/Settings/LiveActivitySettingsView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// LoopFollow -// LiveActivitySettingsView.swift - -import SwiftUI - -struct LiveActivitySettingsView: View { - @State private var laEnabled: Bool = Storage.shared.laEnabled.value - @State private var restartConfirmed = false - - var body: some View { - Form { - Section(header: Text("Live Activity")) { - Toggle("Enable Live Activity", isOn: $laEnabled) - } - - if laEnabled { - Section { - Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { - LiveActivityManager.shared.forceRestart() - restartConfirmed = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - restartConfirmed = false - } - } - .disabled(restartConfirmed) - } - } - } - .onReceive(Storage.shared.laEnabled.$value) { newValue in - if newValue != laEnabled { laEnabled = newValue } - } - .onChange(of: laEnabled) { newValue in - Storage.shared.laEnabled.value = newValue - if !newValue { - LiveActivityManager.shared.end(dismissalPolicy: .immediate) - } - } - .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) - .navigationTitle("Live Activity") - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 11997da35..2e161cba4 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -116,6 +116,12 @@ class Storage { var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) + // Push-to-start (iOS 17.2+). Token persists across launches; empty when unavailable. + var laPushToStartToken = StorageValue(key: "laPushToStartToken", defaultValue: "") + // Unix timestamp of last push-to-start attempt; used to rate-limit and back off on 429. + var laLastPushToStartAt = StorageValue(key: "laLastPushToStartAt", defaultValue: 0) + // Current backoff in seconds before the next push-to-start attempt is allowed (set by 429). + var laPushToStartBackoff = StorageValue(key: "laPushToStartBackoff", defaultValue: 0) // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) @@ -324,6 +330,9 @@ class Storage { laEnabled.reload() laRenewBy.reload() laRenewalFailed.reload() + laPushToStartToken.reload() + laLastPushToStartAt.reload() + laPushToStartBackoff.reload() showDots.reload() showLines.reload() diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 88e57cec3..14a513674 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -54,12 +54,6 @@ struct LoopFollowLiveActivityWidget: Widget { return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) - .background( - LALivenessMarker( - seq: context.state.seq, - producedAt: context.state.producedAt - ) - ) .activitySystemActionForegroundColor(.white) .applyActivityContentMarginsFixIfAvailable() .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!) @@ -71,12 +65,6 @@ struct LoopFollowLiveActivityWidget: Widget { return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenLiveActivityView(state: context.state) .id(context.state.seq) - .background( - LALivenessMarker( - seq: context.state.seq, - producedAt: context.state.producedAt - ) - ) .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) .applyActivityContentMarginsFixIfAvailable()