Przeglądaj źródła

Add iOS 17.2+ push-to-start for Live Activity renewal (#622)

* Add iOS 17.2+ push-to-start for Live Activity renewal

iOS 17.2+ now uses APNs push-to-start for every Live Activity creation
path — initial start, renewal, and forced restart — so the LA can renew
silently in the background instead of requiring the user to foreground
the app at the 7.5 h ceiling. iOS 16.x retains the existing
Activity.request() flow with the renewal-failed notification; the
#available gates are at the entry points so the legacy helpers can be
removed in one commit when the deployment target reaches 17.2.

Push-to-start uses a silent payload (alert with empty title/body +
interruption-level: passive) so adoption is invisible on phone and
watch. The push-to-start token is observed at startup and persisted
between launches; activityUpdates adoption resets the renewal deadline.
The "tap to update" overlay is suppressed on iOS 17.2+ unless renewal
has actually failed, since the time-based pre-emptive warning would be
misleading when push-to-start is handling renewal automatically.

Settings:
- APN page: inline validity badges for Key ID and APNs key, with one-
  line error text when either is malformed.
- Live Activity page: section footer noting APNs is required, plus a
  warning row when credentials are missing or invalid.

* Remove dead iOS 16.2 availability checks in LiveActivityManager

Deployment target is 16.6, so #available(iOS 16.2, *) is always true at
runtime and the @available(iOS 16.2, *) on Activity.activityUpdates is
satisfied by the deployment target alone. The runtime branch and its
'(iOS 16.2+)' log strings just made the file harder to read alongside
the real iOS 17.2 push-to-start gating.

* Log iOS/macOS version in log file header

UIDevice.current.systemVersion reports the iOS-equivalent on Mac
Catalyst, so use ProcessInfo.operatingSystemVersion (and label it
macOS) when running as a Catalyst app.

* Improve push-to-start safety, backoff, and token-wait behaviour (#625)

- Keep old LA alive until APNs send confirms success, so a failed
  push-to-start (rate-limited, invalid token, network error) no longer
  leaves the user with no activity and nothing to replace it
- Reset laPushToStartBackoff to 0 in adoptPushToStartActivity so a
  near-term renewal is not silently blocked by the 5-minute post-send
  base interval once the new LA is confirmed by activityUpdates
- Add apns-collapse-id (bundle-id.la.start) so APNs coalesces redundant
  push-to-start sends that race (refresh tick + user restart)
- Set apns-expiration to 10 minutes instead of 0 so a brief
  connectivity gap does not permanently lose the start notification,
  while avoiding delivery of clinically stale glucose data
- Raise pushToStartForceRestartThreshold from 2 to 4 to reduce false
  positives on slow connections where activityUpdates delivery lags
- Add a single automatic 10-second retry when the push-to-start token
  is not yet available, before surfacing the "could not start" error

https://claude.ai/code/session_01GJZERMhqLmEy8p4cpVX53q

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Phil A <76601115+MtlPhil@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Jonas Björkert 2 miesięcy temu
rodzic
commit
e595114887

+ 4 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -291,6 +291,7 @@
 		FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2E24A232A3001B652C /* DataStructs.swift */; };
 		FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */; };
 		FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC688592489554800A0279D /* BackgroundTaskAudio.swift */; };
+		A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; };
 		FC5A5C3D2497B229009C550E /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = FC5A5C3C2497B229009C550E /* Config.xcconfig */; };
 		FC7CE518248ABE37001F83B8 /* Siri_Alert_Calibration_Needed.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4A9248ABE2B001F83B8 /* Siri_Alert_Calibration_Needed.caf */; };
 		FC7CE519248ABE37001F83B8 /* Rise_And_Shine.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4AA248ABE2B001F83B8 /* Rise_And_Shine.caf */; };
@@ -875,6 +876,7 @@
 		FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = "<group>"; };
 		FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryKeyPath.swift; sourceTree = "<group>"; };
 		FCC688592489554800A0279D /* BackgroundTaskAudio.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskAudio.swift; sourceTree = "<group>"; };
+		A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = "<group>"; };
 		FCC6885B2489559400A0279D /* blank.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = blank.wav; sourceTree = "<group>"; };
 		FCC6885D24896A6C00A0279D /* silence.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = silence.mp3; sourceTree = "<group>"; };
 		FCC6886624898F8000A0279D /* UserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsValue.swift; sourceTree = "<group>"; };
@@ -1690,6 +1692,7 @@
 				FCC6886A24898FD800A0279D /* ObservationToken.swift */,
 				FCC6886C2489909D00A0279D /* AnyConvertible.swift */,
 				FCC688592489554800A0279D /* BackgroundTaskAudio.swift */,
+				A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */,
 				A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */,
 				FCFEEC9F2488157B00402A7F /* Chart.swift */,
 				FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */,
@@ -2385,6 +2388,7 @@
 				DD493ADD2ACF21E0009A6922 /* Basals.swift in Sources */,
 				FC16A98124996C07003D6245 /* DateTime.swift in Sources */,
 				FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */,
+				A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */,
 				DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */,
 				DDEF50402D479B8A00884336 /* LoopAPNSService.swift in Sources */,
 				DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */,

+ 32 - 0
LoopFollow/Helpers/APNsCredentialValidator.swift

@@ -0,0 +1,32 @@
+// LoopFollow
+// APNsCredentialValidator.swift
+
+import Foundation
+
+/// Validation rules for the APNs credentials the user pastes in
+/// `APNSettingsView`. Used both by the settings UI to surface inline
+/// errors and by `LiveActivitySettingsView` to warn when push-based
+/// updates won't work.
+enum APNsCredentialValidator {
+    /// Apple Key IDs are exactly 10 uppercase alphanumeric characters.
+    static func isValidKeyId(_ keyId: String) -> Bool {
+        guard keyId.count == 10 else { return false }
+        return keyId.allSatisfy { $0.isASCII && ($0.isUppercase || $0.isNumber) }
+    }
+
+    /// A pasted .p8 must contain both PEM markers. We don't try to verify
+    /// the inner base64 here — `LoopAPNSService.validateAndFixAPNSKey`
+    /// already normalizes whitespace and logs deeper warnings, and we
+    /// don't want to reject keys that JWTManager would happily sign.
+    static func isValidApnsKey(_ key: String) -> Bool {
+        let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return false }
+        return trimmed.contains("-----BEGIN PRIVATE KEY-----")
+            && trimmed.contains("-----END PRIVATE KEY-----")
+    }
+
+    /// Convenience for "is the user fully set up to send APNs pushes?"
+    static func isFullyConfigured(keyId: String, apnsKey: String) -> Bool {
+        isValidKeyId(keyId) && isValidApnsKey(apnsKey)
+    }
+}

+ 127 - 11
LoopFollow/LiveActivity/APNSClient.swift

@@ -101,9 +101,135 @@ class APNSClient {
         }
     }
 
+    // MARK: - Send Live Activity Start (push-to-start, iOS 17.2+)
+
+    enum PushToStartResult {
+        case success
+        case rateLimited
+        case tokenInvalid
+        case failed
+    }
+
+    func sendLiveActivityStart(
+        pushToStartToken: String,
+        attributesTitle: String,
+        state: GlucoseLiveActivityAttributes.ContentState,
+        staleDate: Date,
+    ) async -> PushToStartResult {
+        guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else {
+            LogManager.shared.log(category: .apns, message: "APNs failed to generate JWT for Live Activity push-to-start")
+            return .failed
+        }
+
+        let payload = buildStartPayload(attributesTitle: attributesTitle, state: state, staleDate: staleDate)
+
+        let host = apnsHost
+        guard let url = URL(string: "\(host)/3/device/\(pushToStartToken)") else {
+            LogManager.shared.log(category: .apns, message: "APNs invalid URL (push-to-start)", isDebug: true)
+            return .failed
+        }
+
+        let environment = BuildDetails.default.isTestFlightBuild() ? "production" : "sandbox"
+        LogManager.shared.log(
+            category: .apns,
+            message: "APNs push-to-start sending host=\(host) env=\(environment) tokenTail=…\(String(pushToStartToken.suffix(8)))"
+        )
+
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
+        request.setValue("application/json", forHTTPHeaderField: "content-type")
+        request.setValue("\(bundleID).push-type.liveactivity", forHTTPHeaderField: "apns-topic")
+        request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type")
+        request.setValue("10", forHTTPHeaderField: "apns-priority")
+        // 10-minute expiry — long enough to survive a brief connectivity gap
+        // while the glucose reading in the payload is still clinically meaningful.
+        // The stale date (8 h) is too generous: delivering a start with hours-old
+        // glucose data is worse than not starting at all.
+        request.setValue("\(Int(Date().timeIntervalSince1970) + 10 * 60)", forHTTPHeaderField: "apns-expiration")
+        // Collapse key prevents duplicate LA creation if two sends race (e.g., a
+        // refresh tick and a user-initiated restart overlap).
+        request.setValue("\(bundleID).la.start", forHTTPHeaderField: "apns-collapse-id")
+        request.httpBody = payload
+
+        do {
+            let (data, response) = try await URLSession.shared.data(for: request)
+            guard let httpResponse = response as? HTTPURLResponse else {
+                LogManager.shared.log(category: .apns, message: "APNs push-to-start: no HTTP response")
+                return .failed
+            }
+            switch httpResponse.statusCode {
+            case 200:
+                LogManager.shared.log(category: .apns, message: "APNs push-to-start sent successfully")
+                return .success
+            case 403:
+                JWTManager.shared.invalidateCache()
+                LogManager.shared.log(category: .apns, message: "APNs push-to-start JWT rejected (403) — token cache cleared")
+                return .failed
+            case 404, 410:
+                // Push-to-start token rotated or invalid — caller should clear stored token
+                // so the next pushToStartTokenUpdates delivery overwrites it.
+                let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)"
+                LogManager.shared.log(category: .apns, message: "APNs push-to-start token \(reason) — clearing stored token")
+                return .tokenInvalid
+            case 429:
+                LogManager.shared.log(category: .apns, message: "APNs push-to-start rate limited (429)")
+                return .rateLimited
+            default:
+                let responseBody = String(data: data, encoding: .utf8) ?? "empty"
+                LogManager.shared.log(category: .apns, message: "APNs push-to-start failed status=\(httpResponse.statusCode) body=\(responseBody)")
+                return .failed
+            }
+        } catch {
+            LogManager.shared.log(category: .apns, message: "APNs push-to-start error: \(error.localizedDescription)")
+            return .failed
+        }
+    }
+
+    // alert with empty title/body + interruption-level: passive is what
+    // keeps both phone and watch silent during adoption — iOS drops the
+    // start payload entirely if alert is missing, so the keys must be
+    // present even though the strings are empty.
+    private func buildStartPayload(
+        attributesTitle: String,
+        state: GlucoseLiveActivityAttributes.ContentState,
+        staleDate: Date,
+    ) -> Data? {
+        guard let contentStateDict = contentStateDictionary(state: state) else { return nil }
+
+        let payload: [String: Any] = [
+            "aps": [
+                "timestamp": Int(Date().timeIntervalSince1970),
+                "event": "start",
+                "stale-date": Int(staleDate.timeIntervalSince1970),
+                "attributes-type": "GlucoseLiveActivityAttributes",
+                "attributes": ["title": attributesTitle],
+                "content-state": contentStateDict,
+                "alert": [
+                    "title": "",
+                    "body": "",
+                ],
+                "interruption-level": "passive",
+            ],
+        ]
+        return try? JSONSerialization.data(withJSONObject: payload)
+    }
+
     // MARK: - Payload Builder
 
     private func buildPayload(state: GlucoseLiveActivityAttributes.ContentState) -> Data? {
+        guard let contentState = contentStateDictionary(state: state) else { return nil }
+        let payload: [String: Any] = [
+            "aps": [
+                "timestamp": Int(Date().timeIntervalSince1970),
+                "event": "update",
+                "content-state": contentState,
+            ],
+        ]
+        return try? JSONSerialization.data(withJSONObject: payload)
+    }
+
+    private func contentStateDictionary(state: GlucoseLiveActivityAttributes.ContentState) -> [String: Any]? {
         let snapshot = state.snapshot
 
         var snapshotDict: [String: Any] = [
@@ -139,22 +265,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)
     }
 }
 

+ 528 - 12
LoopFollow/LiveActivity/LiveActivityManager.swift

@@ -11,6 +11,18 @@ import UIKit
 import UserNotifications
 
 // Live Activity manager for LoopFollow.
+//
+// iOS 17.2+:        every LA creation (initial start, renewal, forced
+//                   restart) goes through APNs push-to-start. Updates
+//                   ride the same APNs transport. One transport, one
+//                   credential failure mode that surfaces in settings.
+//
+// iOS 16.6 – 17.1:  legacy Activity.request() for everything;
+//                   renewal-failed notification when backgrounded.
+//                   The entry-point `if #available(iOS 17.2, *)` checks
+//                   isolate every iOS 17.2 code path, so the legacy
+//                   helpers can be deleted in one commit when the
+//                   deployment target reaches 17.2.
 
 final class LiveActivityManager {
     static let shared = LiveActivityManager()
@@ -39,6 +51,130 @@ final class LiveActivityManager {
             name: .backgroundAudioFailed,
             object: nil,
         )
+        startPushToStartTokenObservation()
+        startActivityUpdatesObservation()
+    }
+
+    // MARK: - Push-to-start observation (iOS 17.2+)
+
+    /// Observes the type-level push-to-start token (iOS 17.2+) and persists it.
+    /// The token survives app relaunches but is reissued by iOS periodically or when
+    /// the user toggles LA permissions — each new delivery overwrites the stored value.
+    private func startPushToStartTokenObservation() {
+        if #available(iOS 17.2, *) {
+            pushToStartObservationTask?.cancel()
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] pushToStartTokenUpdates observation starting (iOS 17.2+)"
+            )
+            pushToStartObservationTask = Task {
+                var deliveries = 0
+                for await tokenData in Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates {
+                    deliveries += 1
+                    let token = tokenData.map { String(format: "%02x", $0) }.joined()
+                    let previousTail = Storage.shared.laPushToStartToken.value.isEmpty
+                        ? "nil"
+                        : String(Storage.shared.laPushToStartToken.value.suffix(8))
+                    let tail = String(token.suffix(8))
+                    let changed = tail != previousTail
+                    Storage.shared.laPushToStartToken.value = token
+                    LogManager.shared.log(
+                        category: .general,
+                        message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")"
+                    )
+                }
+                LogManager.shared.log(
+                    category: .general,
+                    message: "[LA] pushToStartTokenUpdates stream ended after \(deliveries) deliveries — no further tokens will arrive"
+                )
+            }
+        } else {
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] pushToStartTokenUpdates unavailable (iOS <17.2) — push-to-start will never fire"
+            )
+        }
+    }
+
+    /// Observes new Activity creations. When an activity is started by
+    /// push-to-start (iOS 17.2+), the app discovers it through this stream and
+    /// adopts it via the same bind/update path as an app-initiated start.
+    private func startActivityUpdatesObservation() {
+        activityUpdatesObservationTask?.cancel()
+        LogManager.shared.log(
+            category: .general,
+            message: "[LA] activityUpdates observation starting"
+        )
+        activityUpdatesObservationTask = Task { [weak self] in
+            var deliveries = 0
+            for await activity in Activity<GlucoseLiveActivityAttributes>.activityUpdates {
+                deliveries += 1
+                let incomingID = activity.id
+                LogManager.shared.log(
+                    category: .general,
+                    message: "[LA] activityUpdates delivery #\(deliveries) id=\(incomingID) — dispatching to MainActor"
+                )
+                await MainActor.run {
+                    self?.adoptPushToStartActivity(activity)
+                }
+            }
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] activityUpdates stream ended after \(deliveries) deliveries — push-to-start adoption will no longer work until app relaunch"
+            )
+        }
+    }
+
+    @MainActor
+    private func adoptPushToStartActivity(_ activity: Activity<GlucoseLiveActivityAttributes>) {
+        // 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<GlucoseLiveActivityAttributes>.activities.count
+        let staleDate = activity.content.staleDate
+        let staleDesc = staleDate.map { String(format: "%.0f", $0.timeIntervalSinceNow) + "s" } ?? "nil"
+        let incomingSeq = activity.content.state.seq
+        LogManager.shared.log(
+            category: .general,
+            message: "[LA] adopt: id=\(activity.id) seq=\(incomingSeq) staleIn=\(staleDesc) totalActivities=\(totalActivities) (\(delayDescription))"
+        )
+        lastPushToStartSuccessAt = nil
+        pushToStartSendsWithoutAdoption = 0
+        // The new LA is confirmed — clear any post-send backoff so a legitimate
+        // near-term renewal isn't silently blocked by the 5-minute base interval.
+        Storage.shared.laPushToStartBackoff.value = 0
+
+        // If we already have a current activity and this is a different one, it's likely
+        // the new push-to-start LA replacing an old one. End the old, then bind the new.
+        if let old = current, old.id != activity.id {
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] activityUpdates: replacing old=\(old.id) with new=\(activity.id)"
+            )
+            let oldActivity = old
+            Task {
+                await oldActivity.end(nil, dismissalPolicy: .immediate)
+            }
+        } else {
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] activityUpdates: adopting new activity id=\(activity.id) (no prior current)"
+            )
+        }
+        // Fresh deadline — push-to-start-initiated LAs reset the 8-hour clock.
+        Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold
+        Storage.shared.laRenewalFailed.value = false
+        cancelRenewalFailedNotification()
+        dismissedByUser = false
+        bind(to: activity, logReason: "push-to-start-adopt")
     }
 
     /// Fires before the app loses focus (lock screen, home button, etc.).
@@ -111,26 +247,46 @@ final class LiveActivityManager {
         let renewBy = Storage.shared.laRenewBy.value
         let now = Date().timeIntervalSince1970
         let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
+        let stuckSends = pushToStartSendsWithoutAdoption
+        let pushToStartLooksStuck = stuckSends >= LiveActivityManager.pushToStartForceRestartThreshold
         let appState = UIApplication.shared.applicationState.rawValue
         let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
 
         LogManager.shared.log(
             category: .general,
-            message: "[LA] foreground: appState=\(appState), activities=\(existing), 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), pushToStartSendsWithoutAdoption=\(stuckSends)"
         )
 
-        guard renewalFailed || overlayIsShowing else {
+        guard renewalFailed || overlayIsShowing || pushToStartLooksStuck else {
             LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)")
             return
         }
 
+        if pushToStartLooksStuck {
+            // Reset the counter now so we don't re-trigger on every foreground
+            // entry until the next round of silently-failed sends actually
+            // builds up again. The restart itself ends the current LA and
+            // starts a fresh one, which (per Apple's docs) should cause iOS to
+            // emit a new pushToStartToken — the workaround for FB21158660.
+            pushToStartSendsWithoutAdoption = 0
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] foreground: push-to-start looks stuck (sendsWithoutAdoption=\(stuckSends) ≥ \(LiveActivityManager.pushToStartForceRestartThreshold)) — forcing local restart to nudge token rotation"
+            )
+        } else {
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))"
+            )
+        }
+
         // 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] foreground: scheduling restart on next didBecomeActive (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))"
+            message: "[LA] foreground: scheduling restart on next didBecomeActive"
         )
     }
 
@@ -243,6 +399,25 @@ final class LiveActivityManager {
     static let renewalWarning: TimeInterval = 30 * 60
     static let extensionLivenessGrace: TimeInterval = 15 * 60
 
+    /// Base backoff after a 429 for push-to-start; doubled on each subsequent 429,
+    /// capped at `pushToStartMaxBackoff`. Reset to base after a successful send.
+    private static let pushToStartBaseBackoff: TimeInterval = 300 // 5 min
+    private static let pushToStartMaxBackoff: TimeInterval = 3600 // 60 min
+    /// When a successful APNs push-to-start does not result in an `activityUpdates`
+    /// adoption, count those orphaned sends. After this threshold, the next
+    /// foreground entry forces a local restart to nudge iOS to issue a new
+    /// pushToStartToken — Apple FB21158660 workaround. Set to 4 (not 2) to avoid
+    /// false positives on slow connections where the activityUpdates delivery lags.
+    private static let pushToStartForceRestartThreshold: Int = 4
+    /// Polling timeout for the push-to-start token to arrive after a fresh install.
+    /// `pushToStartTokenUpdates` typically delivers within a couple of seconds.
+    private static let pushToStartTokenWaitTimeout: TimeInterval = 5
+    private static let pushToStartTokenPollInterval: TimeInterval = 0.5
+    /// Delay before the single automatic retry when the push-to-start token is not
+    /// yet available after the initial wait. The token is almost always en route
+    /// and arrives within a few seconds of the first request.
+    private static let pushToStartTokenRetryDelay: TimeInterval = 10
+
     private(set) var current: Activity<GlucoseLiveActivityAttributes>?
     private var stateObserverTask: Task<Void, Never>?
     private var updateTask: Task<Void, Never>?
@@ -265,9 +440,28 @@ 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<Void, Never>?
+    /// Observes `Activity<>.activityUpdates` so activities started out-of-band
+    /// (push-to-start) are adopted automatically.
+    private var activityUpdatesObservationTask: Task<Void, Never>?
+    /// Timestamp of the last successful push-to-start APNs dispatch. Used to log
+    /// the delay until iOS delivers the new activity via `activityUpdates`. If
+    /// adoption never happens, a growing gap here is the fingerprint.
+    private var lastPushToStartSuccessAt: Date?
+    /// Number of consecutive successful push-to-start APNs sends that have NOT
+    /// been followed by an `activityUpdates` adoption. When this reaches
+    /// `pushToStartForceRestartThreshold`, the next foreground entry forces a
+    /// local restart even outside the renewal window — ending the existing LA
+    /// and starting a fresh one is the only known way to nudge iOS to issue a
+    /// new `pushToStartToken` when the current one has gone silent
+    /// (Apple FB21158660).
+    private var pushToStartSendsWithoutAdoption: Int = 0
 
     // MARK: - Public API
 
+    @MainActor
     func startIfNeeded() {
         let authorized = ActivityAuthorizationInfo().areActivitiesEnabled
         let existingCount = Activity<GlucoseLiveActivityAttributes>.activities.count
@@ -281,6 +475,40 @@ final class LiveActivityManager {
             return
         }
 
+        if #available(iOS 17.2, *) {
+            // iOS 17.2+ uses push-to-start for every creation path. If an
+            // activity is already running and not stale we adopt/reuse it
+            // (covers warm starts where the LA survived a relaunch); only
+            // truly new starts dispatch APNs.
+            if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
+                let renewBy = Storage.shared.laRenewBy.value
+                let now = Date().timeIntervalSince1970
+                let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
+                let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
+                let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
+                if !needsRestart {
+                    bind(to: existing, logReason: "reuse")
+                    Storage.shared.laRenewalFailed.value = false
+                    return
+                }
+                LogManager.shared.log(
+                    category: .general,
+                    message: "[LA] existing activity is stale on startIfNeeded (iOS 17.2+) — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
+                )
+                attemptPushToStartCreate(reason: "user-start", oldActivity: existing)
+                return
+            }
+            attemptPushToStartCreate(reason: "user-start", oldActivity: nil)
+        } else {
+            startIfNeededLegacy()
+        }
+    }
+
+    /// Pre-17.2 path (iOS 16.6 – 17.1). Identical to dev's `startIfNeeded` —
+    /// Activity.request() for everything. Removable when the deployment target
+    /// reaches 17.2.
+    @MainActor
+    private func startIfNeededLegacy() {
         if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
             // Before reusing, check whether this activity needs a restart. This covers cold
             // starts (app was killed while the overlay was showing — willEnterForeground is
@@ -307,7 +535,7 @@ final class LiveActivityManager {
 
                 Task {
                     await existing.end(nil, dismissalPolicy: .immediate)
-                    await MainActor.run { self.startIfNeeded() }
+                    await MainActor.run { self.startIfNeededLegacy() }
                 }
                 return
             }
@@ -320,9 +548,6 @@ final class LiveActivityManager {
         do {
             let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
 
-            // Prefer a freshly built snapshot so all extended fields are populated.
-            // Fall back to the persisted store (covers cold-start with real data),
-            // then to a zero seed (true first-ever launch with no data yet).
             let provider = StorageCurrentGlucoseStateProvider()
             let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider)
                 ?? GlucoseSnapshotStore.shared.load()
@@ -435,6 +660,11 @@ final class LiveActivityManager {
         dismissedByUser = false
         Storage.shared.laRenewBy.value = 0
         Storage.shared.laRenewalFailed.value = false
+        // The user explicitly asked for a fresh LA — clear any push-to-start
+        // backoff that would otherwise rate-limit the Restart button silently.
+        Storage.shared.laLastPushToStartAt.value = 0
+        Storage.shared.laPushToStartBackoff.value = 0
+        pushToStartSendsWithoutAdoption = 0
         LALivenessStore.clear()
         cancelRenewalFailedNotification()
         current = nil
@@ -453,6 +683,7 @@ final class LiveActivityManager {
         }
     }
 
+    @MainActor
     func startFromCurrentState(cleanupOrphans: Bool = false) {
         guard Storage.shared.laEnabled.value, !dismissedByUser else { return }
 
@@ -490,8 +721,6 @@ final class LiveActivityManager {
 
     /// Requests a fresh Live Activity to replace the current one when the renewal
     /// deadline has passed, working around Apple's 8-hour maximum LA lifetime.
-    /// The new LA is requested FIRST — the old one is only ended if that succeeds,
-    /// so the user keeps live data if Activity.request() throws.
     /// Returns true if renewal was performed (caller should return early).
     private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool {
         guard let oldActivity = current else { return false }
@@ -502,6 +731,27 @@ final class LiveActivityManager {
         let overdueBy = Date().timeIntervalSince1970 - renewBy
         LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA")
 
+        if #available(iOS 17.2, *) {
+            // iOS 17.2+: renewal goes through push-to-start. The dispatch hops
+            // to MainActor and returns immediately; adoption (or failure) lands
+            // in the observer. Return true so performRefresh stops processing
+            // this tick.
+            Task { @MainActor [weak self] in
+                self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot)
+            }
+            return true
+        } else {
+            return attemptLegacyRenewal(snapshot: snapshot, oldActivity: oldActivity)
+        }
+    }
+
+    /// Pre-17.2 renewal (iOS 16.6 – 17.1): foreground Activity.request, mark
+    /// renewal-failed if it throws. Removable when the deployment target
+    /// reaches 17.2.
+    private func attemptLegacyRenewal(
+        snapshot: GlucoseSnapshot,
+        oldActivity: Activity<GlucoseLiveActivityAttributes>
+    ) -> Bool {
         let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
         let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
 
@@ -560,6 +810,242 @@ final class LiveActivityManager {
         }
     }
 
+    // MARK: - Push-to-start (iOS 17.2+)
+
+    /// Single creation path for iOS 17.2+. Handles initial start, renewal, and
+    /// forced restart. Verifies token + APNs credentials, applies backoff, then
+    /// dispatches the APNs push-to-start call. The old activity is only ended
+    /// after a confirmed successful send, preserving it if the send fails.
+    /// Adoption is delivered via the `activityUpdates` observer —
+    /// `handlePushToStartResult` only updates backoff/state.
+    @available(iOS 17.2, *)
+    @MainActor
+    private func attemptPushToStartCreate(
+        reason: String,
+        oldActivity: Activity<GlucoseLiveActivityAttributes>?,
+        snapshot: GlucoseSnapshot? = nil
+    ) {
+        // Validate APNs credentials up-front — push-to-start is the ONLY transport
+        // on iOS 17.2+, so missing/invalid creds mean the LA will never display.
+        let keyId = Storage.shared.lfKeyId.value
+        let apnsKey = Storage.shared.lfApnsKey.value
+        guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else {
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] push-to-start (\(reason)) blocked — APNs credentials missing or invalid (keyId valid=\(APNsCredentialValidator.isValidKeyId(keyId)), apnsKey valid=\(APNsCredentialValidator.isValidApnsKey(apnsKey)))"
+            )
+            scheduleApnsCredentialsMissingNotification()
+            return
+        }
+
+        let now = Date().timeIntervalSince1970
+        let lastAt = Storage.shared.laLastPushToStartAt.value
+        let backoff = Storage.shared.laPushToStartBackoff.value
+        if lastAt > 0, now < lastAt + backoff {
+            let wait = Int(lastAt + backoff - now)
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] push-to-start (\(reason)) rate-limited: next allowed in \(wait)s (backoff=\(Int(backoff))s)"
+            )
+            return
+        }
+
+        // Build snapshot if caller didn't supply one (initial start path).
+        let workingSnapshot: GlucoseSnapshot = {
+            if let snapshot { return snapshot }
+            let provider = StorageCurrentGlucoseStateProvider()
+            return GlucoseSnapshotBuilder.build(from: provider)
+                ?? GlucoseSnapshotStore.shared.load()
+                ?? GlucoseSnapshot(
+                    glucose: 0,
+                    delta: 0,
+                    trend: .unknown,
+                    updatedAt: Date(),
+                    iob: nil,
+                    cob: nil,
+                    projected: nil,
+                    unit: .mgdl,
+                    isNotLooping: false,
+                )
+        }()
+
+        Task { [weak self] in
+            guard let self else { return }
+            await self.dispatchPushToStart(
+                reason: reason,
+                oldActivity: oldActivity,
+                snapshot: workingSnapshot
+            )
+        }
+    }
+
+    @available(iOS 17.2, *)
+    private func dispatchPushToStart(
+        reason: String,
+        oldActivity: Activity<GlucoseLiveActivityAttributes>?,
+        snapshot: GlucoseSnapshot,
+        isRetry: Bool = false
+    ) async {
+        // Wait briefly for the push-to-start token to arrive — covers the
+        // fresh-install case where the user toggles LA on before iOS has
+        // delivered the first token via pushToStartTokenUpdates.
+        var token = Storage.shared.laPushToStartToken.value
+        if token.isEmpty {
+            let pollIntervalNs = UInt64(LiveActivityManager.pushToStartTokenPollInterval * 1_000_000_000)
+            let maxAttempts = Int(LiveActivityManager.pushToStartTokenWaitTimeout / LiveActivityManager.pushToStartTokenPollInterval)
+            for attempt in 1 ... maxAttempts {
+                try? await Task.sleep(nanoseconds: pollIntervalNs)
+                token = Storage.shared.laPushToStartToken.value
+                if !token.isEmpty {
+                    LogManager.shared.log(
+                        category: .general,
+                        message: "[LA] push-to-start (\(reason)) token arrived after \(attempt) poll(s)"
+                    )
+                    break
+                }
+            }
+        }
+        guard !token.isEmpty else {
+            if isRetry {
+                // Token still absent after retry — give up and notify the user.
+                LogManager.shared.log(
+                    category: .general,
+                    message: "[LA] push-to-start (\(reason)) aborted — no token after retry (iOS hasn't issued one yet)"
+                )
+                await MainActor.run { self.schedulePushToStartTokenMissingNotification() }
+            } else {
+                // Token likely en route — wait briefly and make a single automatic
+                // retry before surfacing an error to the user.
+                LogManager.shared.log(
+                    category: .general,
+                    message: "[LA] push-to-start (\(reason)) no token after \(Int(LiveActivityManager.pushToStartTokenWaitTimeout))s — retrying in \(Int(LiveActivityManager.pushToStartTokenRetryDelay))s"
+                )
+                try? await Task.sleep(nanoseconds: UInt64(LiveActivityManager.pushToStartTokenRetryDelay * 1_000_000_000))
+                await dispatchPushToStart(reason: reason, oldActivity: oldActivity, snapshot: snapshot, isRetry: true)
+            }
+            return
+        }
+
+        // Record attempt time up-front so two refresh ticks can't double-fire.
+        await MainActor.run {
+            Storage.shared.laLastPushToStartAt.value = Date().timeIntervalSince1970
+        }
+
+        let nextSeq = await MainActor.run { () -> Int in
+            self.seq += 1
+            return self.seq
+        }
+        let freshSnapshot = snapshot.withRenewalOverlay(false)
+        let state = GlucoseLiveActivityAttributes.ContentState(
+            snapshot: freshSnapshot,
+            seq: nextSeq,
+            reason: reason,
+            producedAt: Date(),
+        )
+        let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
+
+        let tail = String(token.suffix(8))
+        LogManager.shared.log(
+            category: .general,
+            message: "[LA] push-to-start (\(reason)) firing token=…\(tail) seq=\(nextSeq) staleIn=\(Int(staleDate.timeIntervalSinceNow))s"
+        )
+
+        let sendStart = Date()
+        let result = await APNSClient.shared.sendLiveActivityStart(
+            pushToStartToken: token,
+            attributesTitle: "LoopFollow",
+            state: state,
+            staleDate: staleDate,
+        )
+        let elapsedMs = Int(Date().timeIntervalSince(sendStart) * 1000)
+        LogManager.shared.log(
+            category: .general,
+            message: "[LA] push-to-start (\(reason)) APNs round-trip result=\(result) elapsed=\(elapsedMs)ms"
+        )
+
+        // End the old activity only after a confirmed successful send — if the
+        // send fails the user keeps their existing LA rather than losing data
+        // with nothing to replace it.
+        if result == .success, let oldActivity {
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] push-to-start (\(reason)) send succeeded — ending oldActivity=\(oldActivity.id)"
+            )
+            await oldActivity.end(nil, dismissalPolicy: .immediate)
+        }
+
+        await MainActor.run {
+            self.handlePushToStartResult(result, reason: reason)
+        }
+    }
+
+    @available(iOS 17.2, *)
+    @MainActor
+    private func handlePushToStartResult(
+        _ result: APNSClient.PushToStartResult,
+        reason: String
+    ) {
+        switch result {
+        case .success:
+            // Adoption of the new LA runs via `activityUpdates` observation,
+            // which ends the old activity, resets the renewal deadline and
+            // clears `laRenewalFailed`. Apply base backoff so refresh ticks
+            // between now and adoption don't re-fire push-to-start.
+            Storage.shared.laPushToStartBackoff.value = LiveActivityManager.pushToStartBaseBackoff
+            lastPushToStartSuccessAt = Date()
+            pushToStartSendsWithoutAdoption += 1
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] push-to-start (\(reason)) succeeded — awaiting activityUpdates to adopt new LA (backoff=\(Int(LiveActivityManager.pushToStartBaseBackoff))s, sendsWithoutAdoption=\(pushToStartSendsWithoutAdoption))"
+            )
+        case .rateLimited:
+            let currentBackoff = Storage.shared.laPushToStartBackoff.value
+            let next = min(
+                LiveActivityManager.pushToStartMaxBackoff,
+                max(LiveActivityManager.pushToStartBaseBackoff, currentBackoff * 2)
+            )
+            Storage.shared.laPushToStartBackoff.value = next
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] push-to-start (\(reason)) 429 — backoff raised to \(Int(next))s"
+            )
+            if reason == "renew" { markRenewalFailedFromBackground() }
+        case .tokenInvalid:
+            // Clear the stored token so the next `pushToStartTokenUpdates`
+            // delivery overwrites it. Reset backoff — no point holding off
+            // while we wait for iOS to reissue.
+            Storage.shared.laPushToStartToken.value = ""
+            Storage.shared.laPushToStartBackoff.value = 0
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] push-to-start (\(reason)) token invalid — cleared, awaiting new token"
+            )
+            if reason == "renew" { markRenewalFailedFromBackground() }
+        case .failed:
+            let currentBackoff = Storage.shared.laPushToStartBackoff.value
+            if currentBackoff < LiveActivityManager.pushToStartBaseBackoff {
+                Storage.shared.laPushToStartBackoff.value = LiveActivityManager.pushToStartBaseBackoff
+            }
+            if reason == "renew" { markRenewalFailedFromBackground() }
+        }
+    }
+
+    /// Background renewal couldn't restart the LA via push-to-start (rate-limited,
+    /// invalid token, etc.). Mark the state so the renewal overlay shows on the
+    /// lock screen, and post a local notification on the first failure so the
+    /// user knows to foreground the app.
+    private func markRenewalFailedFromBackground() {
+        let isFirstFailure = !Storage.shared.laRenewalFailed.value
+        Storage.shared.laRenewalFailed.value = true
+        LogManager.shared.log(
+            category: .general,
+            message: "[LA] push-to-start renewal failed — renewal marked failed"
+        )
+        if isFirstFailure {
+            scheduleRenewalFailedNotification()
+        }
+    }
+
     private func performRefresh(reason: String) {
         let provider = StorageCurrentGlucoseStateProvider()
         guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else {
@@ -615,9 +1101,11 @@ final class LiveActivityManager {
             return
         }
         if isAppVisibleForLiveActivityStart() {
-            startIfNeeded()
-            if current != nil {
-                update(snapshot: snapshot, reason: reason)
+            Task { @MainActor in
+                self.startIfNeeded()
+                if self.current != nil {
+                    self.update(snapshot: snapshot, reason: reason)
+                }
             }
         } else {
             LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true)
@@ -770,6 +1258,8 @@ final class LiveActivityManager {
     // MARK: - Renewal Notifications
 
     private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed"
+    private static let apnsCredentialsNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.apns.missing"
+    private static let pushToStartTokenNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.token.missing"
 
     private func scheduleRenewalFailedNotification() {
         let content = UNMutableNotificationContent()
@@ -796,6 +1286,32 @@ final class LiveActivityManager {
         UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
     }
 
+    private func scheduleApnsCredentialsMissingNotification() {
+        let content = UNMutableNotificationContent()
+        content.title = "Live Activity Setup Needed"
+        content.body = "APNs credentials are missing or invalid. Configure them in Settings → APN."
+        content.sound = .default
+        let request = UNNotificationRequest(
+            identifier: LiveActivityManager.apnsCredentialsNotificationID,
+            content: content,
+            trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false),
+        )
+        UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
+    }
+
+    private func schedulePushToStartTokenMissingNotification() {
+        let content = UNMutableNotificationContent()
+        content.title = "Live Activity Could Not Start"
+        content.body = "Live Activity could not start — try again in a moment."
+        content.sound = .default
+        let request = UNNotificationRequest(
+            identifier: LiveActivityManager.pushToStartTokenNotificationID,
+            content: content,
+            trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false),
+        )
+        UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
+    }
+
     private func attachStateObserver(to activity: Activity<GlucoseLiveActivityAttributes>) {
         stateObserverTask?.cancel()
         stateObserverTask = Task {

+ 13 - 3
LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift

@@ -134,9 +134,19 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
         #if targetEnvironment(macCatalyst)
             return false
         #else
-            let renewBy = Storage.shared.laRenewBy.value
-            let now = Date().timeIntervalSince1970
-            return renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
+            // iOS 17.2+ renews silently via push-to-start at the deadline, so the
+            // pre-emptive 30-minute "tap to update" overlay would be misleading
+            // during normal operation. Only show it once renewal has actually
+            // failed (no token, bad creds, rate-limited) — that is genuinely
+            // user-actionable. iOS 16.x keeps the time-based warning because
+            // renewal there requires the user to foreground the app.
+            if #available(iOS 17.2, *) {
+                return Storage.shared.laRenewalFailed.value
+            } else {
+                let renewBy = Storage.shared.laRenewBy.value
+                let now = Date().timeIntervalSince1970
+                return renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
+            }
         #endif
     }
 }

+ 28 - 1
LoopFollow/LiveActivitySettingsView.swift

@@ -10,16 +10,37 @@
         @State private var restartConfirmed = false
         @State private var slots: [LiveActivitySlotOption] = LAAppGroupSettings.slots()
         @State private var smallWidgetSlot: LiveActivitySlotOption = LAAppGroupSettings.smallWidgetSlot()
+        @State private var keyId: String = Storage.shared.lfKeyId.value
+        @State private var apnsKey: String = Storage.shared.lfApnsKey.value
 
         private let slotLabels = ["Top left", "Top right", "Bottom left", "Bottom right"]
 
+        private var apnsConfigured: Bool {
+            APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey)
+        }
+
         var body: some View {
             Form {
-                Section(header: Text("Live Activity")) {
+                Section(
+                    header: Text("Live Activity"),
+                    footer: Text("Live Activity updates require APNs credentials. Configure them in Settings → APN.")
+                ) {
                     Toggle("Enable Live Activity", isOn: $laEnabled)
                 }
 
                 if laEnabled {
+                    if !apnsConfigured {
+                        Section {
+                            Label {
+                                Text("APNs credentials are missing or invalid — Live Activity updates will not work. Open Settings → APN to fix.")
+                                    .font(.callout)
+                            } icon: {
+                                Image(systemName: "exclamationmark.triangle.fill")
+                                    .foregroundColor(.orange)
+                            }
+                        }
+                    }
+
                     Section {
                         Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") {
                             LiveActivityManager.shared.forceRestart()
@@ -63,6 +84,12 @@
             .onReceive(Storage.shared.laEnabled.$value) { newValue in
                 if newValue != laEnabled { laEnabled = newValue }
             }
+            .onReceive(Storage.shared.lfKeyId.$value) { newValue in
+                if newValue != keyId { keyId = newValue }
+            }
+            .onReceive(Storage.shared.lfApnsKey.$value) { newValue in
+                if newValue != apnsKey { apnsKey = newValue }
+            }
             .onChange(of: laEnabled) { newValue in
                 Storage.shared.laEnabled.value = newValue
                 if newValue {

+ 12 - 0
LoopFollow/Log/LogManager.swift

@@ -2,6 +2,7 @@
 // LogManager.swift
 
 import Foundation
+import UIKit
 
 class LogManager {
     static let shared = LogManager()
@@ -115,6 +116,16 @@ class LogManager {
             let expirationHeaderString = buildDetails.expirationHeaderString
             let isMacApp = buildDetails.isMacApp()
             let isSimulatorBuild = buildDetails.isSimulatorBuild()
+            let osLabel: String
+            let osVersion: String
+            if isMacApp {
+                osLabel = "macOS"
+                let v = ProcessInfo.processInfo.operatingSystemVersion
+                osVersion = "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
+            } else {
+                osLabel = "iOS"
+                osVersion = UIDevice.current.systemVersion
+            }
 
             // Assemble header information
             var headerLines = [String]()
@@ -124,6 +135,7 @@ class LogManager {
             }
             headerLines.append("Built: \(formattedBuildDate)")
             headerLines.append("Branch: \(branchAndSha)")
+            headerLines.append("\(osLabel): \(osVersion)")
 
             let headerMessage = headerLines.joined(separator: ", ") + "\n"
             let logMessage = formattedLogMessage(for: .general, message: headerMessage)

+ 50 - 10
LoopFollow/Settings/APNSettingsView.swift

@@ -7,26 +7,59 @@ struct APNSettingsView: View {
     @State private var keyId: String = Storage.shared.lfKeyId.value
     @State private var apnsKey: String = Storage.shared.lfApnsKey.value
 
+    private var keyIdValid: Bool {
+        APNsCredentialValidator.isValidKeyId(keyId)
+    }
+
+    private var apnsKeyValid: Bool {
+        APNsCredentialValidator.isValidApnsKey(apnsKey)
+    }
+
     var body: some View {
         Form {
             Section(header: Text("LoopFollow APNs Credentials")) {
-                HStack {
-                    Text("APNS Key ID")
-                    TogglableSecureInput(
-                        placeholder: "Enter APNS Key ID",
-                        text: $keyId,
-                        style: .singleLine
-                    )
+                VStack(alignment: .leading, spacing: 4) {
+                    HStack {
+                        Text("APNS Key ID")
+                        TogglableSecureInput(
+                            placeholder: "Enter APNS Key ID",
+                            text: $keyId,
+                            style: .singleLine
+                        )
+                        if !keyId.isEmpty {
+                            Image(systemName: keyIdValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
+                                .foregroundColor(keyIdValid ? .green : .orange)
+                                .accessibilityLabel(keyIdValid ? "Valid Key ID" : "Invalid Key ID")
+                        }
+                    }
+                    if !keyId.isEmpty, !keyIdValid {
+                        Text("Key ID must be exactly 10 uppercase letters or digits.")
+                            .font(.caption)
+                            .foregroundColor(.orange)
+                    }
                 }
 
-                VStack(alignment: .leading) {
-                    Text("APNS Key")
+                VStack(alignment: .leading, spacing: 4) {
+                    HStack {
+                        Text("APNS Key")
+                        Spacer()
+                        if !apnsKey.isEmpty {
+                            Image(systemName: apnsKeyValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
+                                .foregroundColor(apnsKeyValid ? .green : .orange)
+                                .accessibilityLabel(apnsKeyValid ? "Valid APNs key" : "Invalid APNs key")
+                        }
+                    }
                     TogglableSecureInput(
                         placeholder: "Paste APNS Key",
                         text: $apnsKey,
                         style: .multiLine
                     )
                     .frame(minHeight: 110)
+                    if !apnsKey.isEmpty, !apnsKeyValid {
+                        Text("Paste the full .p8 contents — must include the BEGIN PRIVATE KEY and END PRIVATE KEY lines.")
+                            .font(.caption)
+                            .foregroundColor(.orange)
+                    }
                 }
             }
         }
@@ -35,7 +68,14 @@ struct APNSettingsView: View {
         }
         .onChange(of: apnsKey) { newValue in
             let apnsService = LoopAPNSService()
-            Storage.shared.lfApnsKey.value = apnsService.validateAndFixAPNSKey(newValue)
+            let normalized = apnsService.validateAndFixAPNSKey(newValue)
+            Storage.shared.lfApnsKey.value = normalized
+            // Reflect normalization (whitespace fixes etc.) in the field so the
+            // green badge appears when paste added stray whitespace around an
+            // otherwise-valid key.
+            if normalized != newValue {
+                apnsKey = normalized
+            }
         }
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationTitle("APN")

+ 6 - 0
LoopFollow/Storage/Storage.swift

@@ -116,6 +116,9 @@ class Storage {
     var laEnabled = StorageValue<Bool>(key: "laEnabled", defaultValue: false)
     var laRenewBy = StorageValue<TimeInterval>(key: "laRenewBy", defaultValue: 0)
     var laRenewalFailed = StorageValue<Bool>(key: "laRenewalFailed", defaultValue: false)
+    var laPushToStartToken = StorageValue<String>(key: "laPushToStartToken", defaultValue: "")
+    var laLastPushToStartAt = StorageValue<TimeInterval>(key: "laLastPushToStartAt", defaultValue: 0)
+    var laPushToStartBackoff = StorageValue<TimeInterval>(key: "laPushToStartBackoff", defaultValue: 0)
 
     // Graph Settings [BEGIN]
     var showDots = StorageValue<Bool>(key: "showDots", defaultValue: true)
@@ -342,6 +345,9 @@ class Storage {
         laEnabled.reload()
         laRenewBy.reload()
         laRenewalFailed.reload()
+        laPushToStartToken.reload()
+        laLastPushToStartAt.reload()
+        laPushToStartBackoff.reload()
 
         showDots.reload()
         showLines.reload()