From a3954f4245528944e7c5f437afe82d40ffa54d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 20 Apr 2026 21:09:22 +0200 Subject: [PATCH 1/7] Fix Live Activity restart classification and foreground race - handleExpiredToken, endOnTerminate, forceRestart: mark endingForRestart before ending so the state observer does not misclassify the resulting .dismissed as a user swipe (which would set dismissedByUser=true and block auto-restart on the next background refresh). - Defer foreground restart from willEnterForeground to didBecomeActive so Activity.request() is not called before the scene is active (avoids the "visibility" failure). - Remove duplicate orphan LiveActivitySettingsView.swift under Settings/ (not referenced by the Xcode project). --- .../LiveActivity/LiveActivityManager.swift | 39 +++++++++++++---- .../Settings/LiveActivitySettingsView.swift | 42 ------------------- 2 files changed, 30 insertions(+), 51 deletions(-) delete mode 100644 LoopFollow/Settings/LiveActivitySettingsView.swift diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index fb73c3409..16d7254e0 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -87,9 +87,13 @@ 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 + if pendingForegroundRestart { + pendingForegroundRestart = false + LogManager.shared.log( + category: .general, + message: "[LA] didBecomeActive: running deferred foreground restart" + ) + performForegroundRestart() return } LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: calling startFromCurrentState, dismissedByUser=\(dismissedByUser)", isDebug: true) @@ -116,13 +120,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 @@ -249,9 +257,10 @@ 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 // MARK: - Public API @@ -344,6 +353,10 @@ 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() @@ -399,6 +412,10 @@ 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 @@ -687,6 +704,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() } 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) - } -} From e165fb7a17cc6a88030d046788e4a5b2971759a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 20 Apr 2026 21:14:40 +0200 Subject: [PATCH 2/7] Add Live Activity troubleshooting logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - startIfNeeded: log entry state (authorized, activities, current, flags) and enrich Activity.request failure with NSError domain/code + scene state. - renewIfNeeded: enrich catch with NSError domain/code + authorization state. - handleForeground / handleDidBecomeActive: include applicationState and the existing activities count at entry. - observePushToken: log token fingerprint (last 8 chars) and prior value so token rotations are visible. - update: log when the direct ActivityKit update is skipped (app backgrounded) and when APNs is skipped because no push token has been received yet. - performRefresh: log the gate that blocks LA updates — especially dismissedByUser=true, which previously caused silent extended outages. - handleExpiredToken: log current id, activities count, and flags before ending so APNs 410/404 events are correlatable to the restart path. - bind: include activityState and the previous endingForRestart value so the dismissal-classification path is traceable. --- .../LiveActivity/LiveActivityManager.swift | 73 ++++++++++++++++--- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 16d7254e0..d757424fa 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -87,16 +87,18 @@ final class LiveActivityManager { @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } + 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" + 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() } @@ -109,10 +111,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 { @@ -265,7 +269,14 @@ final class LiveActivityManager { // 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 } @@ -344,7 +355,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)" + ) } } @@ -532,7 +548,11 @@ final class LiveActivityManager { // 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] renewal failed, keeping existing LA: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), activities=\(Activity.activities.count)" + ) if isFirstFailure { scheduleRenewalFailedNotification() } @@ -574,9 +594,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 { @@ -649,6 +677,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 } @@ -663,6 +697,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" + ) } } } @@ -685,25 +724,41 @@ 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) + LogManager.shared.log( + category: .general, + message: "Live Activity bound id=\(activity.id) state=\(activity.activityState) (\(logReason)) — endingForRestart cleared (was \(wasEndingForRestart))", + isDebug: true + ) 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. From 0b757d360dcf6391d5c5b4355071d6c76dd343bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 21 Apr 2026 20:52:46 +0200 Subject: [PATCH 3/7] Add push-to-start fallback for background Live Activity renewal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the 8-hour renewal deadline passes while the app isn't foregroundActive, Activity.request() fails with `visibility` and the LA decays until the user foregrounds the app. On iOS 17.2+, fall back to an APNs push-to-start: LoopFollow — already awake in the background via silent tune / bluetooth — sends the start payload to itself. The new activity is discovered via Activity.activityUpdates, the old one is ended, and the renewal deadline is reset. Attempts are gated by a stored backoff: base 5 min, doubled on APNs 429 up to 60 min; a 410/404 clears the stored token so the next pushToStartTokenUpdates delivery re-arms it. On iOS <17.2 no token is ever stored, the push-to-start path no-ops, and the existing markRenewalFailed + local-notification fallback runs unchanged. --- LoopFollow/LiveActivity/APNSClient.swift | 118 ++++++++- .../LiveActivity/LiveActivityManager.swift | 227 ++++++++++++++++++ LoopFollow/Storage/Storage.swift | 9 + 3 files changed, 343 insertions(+), 11 deletions(-) diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 8755b1b27..483bc5b99 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -101,9 +101,115 @@ 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, + "alert": [ + "title": "LoopFollow", + "body": "Live Activity restarted", + ], + ], + ] + 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 +245,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 d757424fa..50325d2a6 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -39,6 +39,76 @@ 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() + pushToStartObservationTask = Task { + for await tokenData in Activity.pushToStartTokenUpdates { + 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)) + Storage.shared.laPushToStartToken.value = token + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start token received token=…\(tail) (prev=…\(previousTail))" + ) + } + } + } + } + + /// 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() + activityUpdatesObservationTask = Task { [weak self] in + for await activity in Activity.activityUpdates { + await MainActor.run { + self?.adoptPushToStartActivity(activity) + } + } + } + } + } + + @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 { return } + // 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)" + ) + } + // 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.). @@ -265,6 +335,16 @@ final class LiveActivityManager { /// 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? + /// 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 @@ -500,6 +580,20 @@ final class LiveActivityManager { guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } let overdueBy = Date().timeIntervalSince1970 - renewBy + + // 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: overdueBy, oldActivity: oldActivity) { + // Fired — the async result handler updates state based on success/failure. + return false + } + markRenewalFailedFromBackground(overdueBy: overdueBy) + return false + } + LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) @@ -560,6 +654,139 @@ 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)) + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start firing overdueBy=\(Int(overdueBy))s token=…\(tail) seq=\(nextSeq)" + ) + + Task { [weak self] in + let result = await APNSClient.shared.sendLiveActivityStart( + pushToStartToken: token, + attributesTitle: "LoopFollow", + state: state, + staleDate: staleDate, + ) + 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 + LogManager.shared.log( + category: .general, + message: "[LA] push-to-start succeeded — awaiting activityUpdates to adopt new LA" + ) + 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 { 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() From f0515c654fd86ebab261bae6c1d9aa5339c0c451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 21 Apr 2026 21:13:57 +0200 Subject: [PATCH 4/7] Restart Live Activity when the widget extension looks stuck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS occasionally stops invoking the widget extension long before the 8-hour renewal deadline — the LA keeps its shared content state but the lock screen shows stale glucose because the body is never re-rendered. `LALivenessStore` (written from `LALivenessMarker` in the widget body) already tracks the last rendered seq and timestamp, and `shouldRestartBecauseExtensionLooksStuck()` already encodes the "behind on seq AND silent for 15 min" decision, but nothing called it. `performRefresh` now checks it every tick after `renewIfNeeded`. Both paths delegate to a new shared `attemptLARestart` helper so deadline renewal and stuck-extension restart take the same decisions: foreground does end + `Activity.request`, background tries push-to-start (iOS 17.2+) and falls back to `markRenewalFailedFromBackground` + local notification. Running every tick is safe because `laLastPushToStartAt` / `laPushToStartBackoff` and `isFirstFailure` naturally dedupe repeat firings. Also clear `LALivenessStore` when a fresh LA takes over — both in the foreground restart path and in `adoptPushToStartActivity` — so the previous LA's last-seen seq can't leave the new one looking stuck on its first refresh ticks. --- .../LiveActivity/LiveActivityManager.swift | 103 ++++++++++++++---- 1 file changed, 84 insertions(+), 19 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 50325d2a6..c8e7eadaa 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -107,6 +107,9 @@ final class LiveActivityManager { Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold Storage.shared.laRenewalFailed.value = false cancelRenewalFailedNotification() + // Clear extension-liveness tracking: the old LA's last-seen seq must not + // leave the new LA looking stuck on the first few refresh ticks. + LALivenessStore.clear() dismissedByUser = false bind(to: activity, logReason: "push-to-start-adopt") } @@ -570,9 +573,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 } @@ -580,41 +581,98 @@ final class LiveActivityManager { guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } let overdueBy = Date().timeIntervalSince1970 - renewBy + return attemptLARestart( + snapshot: snapshot, + oldActivity: oldActivity, + logReason: "renew", + ageSeconds: overdueBy + ) + } + + /// Restarts the LA when the widget extension stops rendering new seqs — + /// iOS sometimes freezes the widget process while the renewal deadline is + /// still in the future, leaving the lock screen showing stale glucose. + /// The `LALivenessStore` marker written by `LALivenessMarker` in the + /// widget body is the authoritative signal; `shouldRestartBecauseExtensionLooksStuck` + /// gates on a 15-minute grace period plus a seq-behind check. + /// Rate-limiting on push-to-start (`laLastPushToStartAt` + `laPushToStartBackoff`) + /// plus `laRenewalFailed`'s `isFirstFailure` gate make this safe to call every tick. + private func restartIfExtensionLooksStuck(snapshot: GlucoseSnapshot) -> Bool { + guard let oldActivity = current else { return false } + guard shouldRestartBecauseExtensionLooksStuck() else { return false } + + let lastSeenAt = LALivenessStore.lastExtensionSeenAt + let silenceSeconds = lastSeenAt > 0 + ? Date().timeIntervalSince1970 - lastSeenAt + : 0 + LogManager.shared.log( + category: .general, + message: "[LA] extension looks stuck — triggering restart (silenceSeconds=\(Int(silenceSeconds)))" + ) + return attemptLARestart( + snapshot: snapshot, + oldActivity: oldActivity, + logReason: "stuck", + ageSeconds: silenceSeconds + ) + } + + /// Unified restart path. Shared by deadline-based renewal and + /// extension-stuck detection so both take 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: overdueBy, oldActivity: oldActivity) { - // Fired — the async result handler updates state based on success/failure. + if attemptPushToStartIfEligible(snapshot: snapshot, overdueBy: ageSeconds, oldActivity: oldActivity) { return false } - markRenewalFailedFromBackground(overdueBy: overdueBy) + markRenewalFailedFromBackground(overdueBy: ageSeconds) return false } - LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") + 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) do { + // Clear before Activity.request so the new LA starts with a clean + // liveness slate. A stale lastSeenAt from the old LA could otherwise + // satisfy `seenSeq < expectedSeq` for the new one and trip the stuck + // check again. + LALivenessStore.clear() let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token) Task { @@ -629,23 +687,23 @@ 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 let ns = error as NSError LogManager.shared.log( category: .general, - message: "[LA] renewal failed, keeping existing LA: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), activities=\(Activity.activities.count)" + 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() @@ -802,6 +860,13 @@ final class LiveActivityManager { // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so. if renewIfNeeded(snapshot: snapshot) { return } + // Catch a frozen widget extension — iOS occasionally stops invoking the + // extension before the 8h deadline. `shouldRestartBecauseExtensionLooksStuck` + // consults the LALivenessMarker's shared-defaults timestamp written from the + // widget's SwiftUI body. Safe to run every tick: the underlying push-to-start + // rate-limit and `laRenewalFailed`'s first-failure gate dedupe repeat firings. + if restartIfExtensionLooksStuck(snapshot: snapshot) { return } + if snapshot.showRenewalOverlay { LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible") } From d37e347d47735f848f8b200dddb88419e86ce271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 21 Apr 2026 22:20:24 +0200 Subject: [PATCH 5/7] Fix LA liveness store app-group suite; silent push-to-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LALivenessStore was using the bare bundle id as the UserDefaults suite, so the widget extension's liveness marker and the main app ended up on different backing stores. The app-side read of lastExtensionSeenAt was permanently 0.0, which made shouldRestartBecauseExtensionLooksStuck() short-circuit as "extension has never checked in → treat as silent". Use AppGroupID.current() so both processes share the same suite, the marker actually crosses the boundary, and stuck-detection only fires after real silence. Also drop the alert block from the push-to-start APNs payload so background Live Activity restarts happen silently, without a banner. --- LoopFollow/LiveActivity/APNSClient.swift | 4 ---- LoopFollow/LiveActivity/LALivenessStore.swift | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 483bc5b99..fbeea394c 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -186,10 +186,6 @@ class APNSClient { "attributes-type": "GlucoseLiveActivityAttributes", "attributes": ["title": attributesTitle], "content-state": contentStateDict, - "alert": [ - "title": "LoopFollow", - "body": "Live Activity restarted", - ], ], ] return try? JSONSerialization.data(withJSONObject: payload) diff --git a/LoopFollow/LiveActivity/LALivenessStore.swift b/LoopFollow/LiveActivity/LALivenessStore.swift index e6f8ebe98..3b9282a7f 100644 --- a/LoopFollow/LiveActivity/LALivenessStore.swift +++ b/LoopFollow/LiveActivity/LALivenessStore.swift @@ -4,7 +4,7 @@ import Foundation enum LALivenessStore { - private static let defaults = UserDefaults(suiteName: AppGroupID.baseBundleID) + private static let defaults = UserDefaults(suiteName: AppGroupID.current()) private enum Key { static let lastExtensionSeenAt = "la.liveness.lastExtensionSeenAt" From e8c5cfb3c1280e83140588a563f429f9456d5083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 22 Apr 2026 21:03:32 +0200 Subject: [PATCH 6/7] Remove stuck-extension restart based on widget UserDefaults probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget extension cannot persist writes to the shared App Group UserDefaults — its sandbox silently blocks them. The liveness probe therefore never recorded any extension renders, and the stuck-detection heuristic fired false positives that pushed users into the renewal overlay and the local notification path. The 7.5h deadline renewal remains the safety net for the underlying 8h Live Activity ceiling. --- LoopFollow.xcodeproj/project.pbxproj | 10 -- .../LiveActivity/LALivenessMarker.swift | 21 ----- LoopFollow/LiveActivity/LALivenessStore.swift | 38 -------- .../LiveActivity/LiveActivityManager.swift | 92 +------------------ .../LoopFollowLiveActivity.swift | 12 --- 5 files changed, 2 insertions(+), 171 deletions(-) delete mode 100644 LoopFollow/LiveActivity/LALivenessMarker.swift delete mode 100644 LoopFollow/LiveActivity/LALivenessStore.swift 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/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 3b9282a7f..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.current()) - - 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 c8e7eadaa..0897a415f 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -107,9 +107,6 @@ final class LiveActivityManager { Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold Storage.shared.laRenewalFailed.value = false cancelRenewalFailedNotification() - // Clear extension-liveness tracking: the old LA's last-seen seq must not - // leave the new LA looking stuck on the first few refresh ticks. - LALivenessStore.clear() dismissedByUser = false bind(to: activity, logReason: "push-to-start-adopt") } @@ -274,47 +271,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? @@ -430,7 +388,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") @@ -458,7 +415,6 @@ final class LiveActivityManager { endingForRestart = true current = nil Storage.shared.laRenewBy.value = 0 - LALivenessStore.clear() let semaphore = DispatchSemaphore(value: 0) Task.detached { await activity.end(nil, dismissalPolicy: .immediate) @@ -500,7 +456,6 @@ final class LiveActivityManager { if current?.id == activity.id { current = nil Storage.shared.laRenewBy.value = 0 - LALivenessStore.clear() } } } @@ -518,7 +473,6 @@ final class LiveActivityManager { dismissedByUser = false Storage.shared.laRenewBy.value = 0 Storage.shared.laRenewalFailed.value = false - LALivenessStore.clear() cancelRenewalFailedNotification() current = nil updateTask?.cancel(); updateTask = nil @@ -589,38 +543,8 @@ final class LiveActivityManager { ) } - /// Restarts the LA when the widget extension stops rendering new seqs — - /// iOS sometimes freezes the widget process while the renewal deadline is - /// still in the future, leaving the lock screen showing stale glucose. - /// The `LALivenessStore` marker written by `LALivenessMarker` in the - /// widget body is the authoritative signal; `shouldRestartBecauseExtensionLooksStuck` - /// gates on a 15-minute grace period plus a seq-behind check. - /// Rate-limiting on push-to-start (`laLastPushToStartAt` + `laPushToStartBackoff`) - /// plus `laRenewalFailed`'s `isFirstFailure` gate make this safe to call every tick. - private func restartIfExtensionLooksStuck(snapshot: GlucoseSnapshot) -> Bool { - guard let oldActivity = current else { return false } - guard shouldRestartBecauseExtensionLooksStuck() else { return false } - - let lastSeenAt = LALivenessStore.lastExtensionSeenAt - let silenceSeconds = lastSeenAt > 0 - ? Date().timeIntervalSince1970 - lastSeenAt - : 0 - - LogManager.shared.log( - category: .general, - message: "[LA] extension looks stuck — triggering restart (silenceSeconds=\(Int(silenceSeconds)))" - ) - return attemptLARestart( - snapshot: snapshot, - oldActivity: oldActivity, - logReason: "stuck", - ageSeconds: silenceSeconds - ) - } - - /// Unified restart path. Shared by deadline-based renewal and - /// extension-stuck detection so both take the same foreground / - /// background / push-to-start / mark-failed decisions. + /// 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. @@ -668,11 +592,6 @@ final class LiveActivityManager { let content = ActivityContent(state: state, staleDate: renewDeadline) do { - // Clear before Activity.request so the new LA starts with a clean - // liveness slate. A stale lastSeenAt from the old LA could otherwise - // satisfy `seenSeq < expectedSeq` for the new one and trip the stuck - // check again. - LALivenessStore.clear() let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token) Task { @@ -860,13 +779,6 @@ final class LiveActivityManager { // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so. if renewIfNeeded(snapshot: snapshot) { return } - // Catch a frozen widget extension — iOS occasionally stops invoking the - // extension before the 8h deadline. `shouldRestartBecauseExtensionLooksStuck` - // consults the LALivenessMarker's shared-defaults timestamp written from the - // widget's SwiftUI body. Safe to run every tick: the underlying push-to-start - // rate-limit and `laRenewalFailed`'s first-failure gate dedupe repeat firings. - if restartIfExtensionLooksStuck(snapshot: snapshot) { return } - if snapshot.showRenewalOverlay { LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible") } 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() From 0ea99e1bec7d4676b469aa6dc3aa2c7dcfd2d49e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 22 Apr 2026 21:20:05 +0200 Subject: [PATCH 7/7] Add push-to-start diagnostics for renewal timing follow-up Extend the Live Activity logs so a post-mortem can answer "did the restart land before the 8-hour ceiling, and if not why" from a single log file: - `renewIfNeeded`: log laAge, overdueBy, and timeToCeiling when the renewal deadline trips. Positive timeToCeiling means we still beat the 8h ceiling; negative means we missed it. - `bind`: log renewIn / ceilingIn so each LA's lifetime can be anchored without cross-referencing storage keys. - `startPushToStartTokenObservation` / `startActivityUpdatesObservation`: log start and stream-end, count deliveries, and mark the iOS<17.2 / iOS<16.2 unavailable branch explicitly. A silent stream end is a common "why push-to-start stopped working" fingerprint. - `adoptPushToStartActivity`: log staleIn, totalActivities, incoming seq, and the delay since the last successful push-to-start send. - `attemptPushToStartIfEligible`: log staleIn, existingActivities, and the current bound id at the firing site. - `handlePushToStartResult`: log APNs round-trip elapsedMs so a slow APNs call can be distinguished from a slow iOS adoption. --- .../LiveActivity/LiveActivityManager.swift | 95 +++++++++++++++++-- 1 file changed, 88 insertions(+), 7 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 0897a415f..84609fa14 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -49,20 +49,36 @@ final class LiveActivityManager { 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 token=…\(tail) (prev=…\(previousTail))" + 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" + ) } } @@ -72,20 +88,59 @@ final class LiveActivityManager { 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 { return } + 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 { @@ -100,7 +155,7 @@ final class LiveActivityManager { } else { LogManager.shared.log( category: .general, - message: "[LA] activityUpdates: adopting new activity id=\(activity.id)" + message: "[LA] activityUpdates: adopting new activity id=\(activity.id) (no prior current)" ) } // Fresh deadline — push-to-start-initiated LAs reset the 8-hour clock. @@ -302,6 +357,10 @@ final class LiveActivityManager { /// 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 @@ -535,6 +594,16 @@ final class LiveActivityManager { guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } let overdueBy = Date().timeIntervalSince1970 - renewBy + // 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, @@ -681,11 +750,14 @@ final class LiveActivityManager { 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)" + 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, @@ -693,6 +765,11 @@ final class LiveActivityManager { 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) } @@ -712,9 +789,10 @@ final class LiveActivityManager { // `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" + 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 @@ -932,10 +1010,13 @@ final class LiveActivityManager { dismissedByUser = false endingForRestart = false attachStateObserver(to: activity) + 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: "Live Activity bound id=\(activity.id) state=\(activity.activityState) (\(logReason)) — endingForRestart cleared (was \(wasEndingForRestart))", - isDebug: true + message: "[LA] bind id=\(activity.id) state=\(activity.activityState) (\(logReason)) — renewIn=\(renewIn)s ceilingIn=\(ceilingIn)s endingForRestart cleared (was \(wasEndingForRestart))" ) observePushToken(for: activity) }