Преглед изворни кода

Recover Live Activity after APNs 410 token expiry (#657)

A 6.1.0 user reported the Live Activity vanishing and refusing to come
back without a manual Restart. Trace: APNs returned 410 on the per-
activity push token at 04:42; handleExpiredToken ended the activity but
the eventual iOS .dismissed (4 h later, under the default dismissal
policy) was classified as a user swipe and locked dismissedByUser=true.

Root cause is two cooperating bugs around an app-initiated end():

- end() nulls `current` and clears laRenewBy. handleExpiredToken's
  comment said "Activity will restart on next BG refresh via
  refreshFromCurrentState()", but renewIfNeeded short-circuits when
  current is nil and performRefresh's bind-existing path rebinds to the
  just-ended activity. bind() then clears endingForRestart, so the late
  .dismissed reads as renewBy=0 / renewalFailed=false / endingForRestart=
  false — branch (c) "USER" in the classifier.

- The classifier had no way to recognize a stale observer firing for an
  activity the app no longer tracks.

Fixes:

- handleExpiredToken drives the restart synchronously on iOS 17.2+
  (attemptPushToStartCreate "expired-token"), so the orphaned post-410
  state is short-lived and adoption of the fresh activity cancels the
  old observer.

- performRefresh / update bind-existing only to activities in
  .active state. Binding to an .ended/.dismissed corpse would clear
  endingForRestart and re-attach an observer that only ever delivers
  .dismissed.

- .dismissed classifier gains branch (d): if the dismissed activity is
  not the one we currently track, log and take no action — only the
  foreground LA can be user-swiped, so a stale-observer delivery for an
  already-replaced activity must not latch dismissedByUser=true.
Jonas Björkert пре 2 месеци
родитељ
комит
439b38990f
1 измењених фајлова са 40 додато и 6 уклоњено
  1. 40 6
      LoopFollow/LiveActivity/LiveActivityManager.swift

+ 40 - 6
LoopFollow/LiveActivity/LiveActivityManager.swift

@@ -1133,7 +1133,13 @@ final class LiveActivityManager {
             LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)")
             return
         }
-        if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
+        if current == nil,
+           let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
+        {
+            // Skip activities already in .ended/.dismissed — those are corpses
+            // (typically post-410 ends pending iOS dismissal). Binding to them
+            // would clear endingForRestart and turn the eventual iOS dismissal
+            // into a misclassified user swipe.
             bind(to: existing, logReason: "bind-existing")
         }
         if let _ = current {
@@ -1158,7 +1164,9 @@ final class LiveActivityManager {
     }
 
     func update(snapshot: GlucoseSnapshot, reason: String) {
-        if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
+        if current == nil,
+           let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
+        {
             bind(to: existing, logReason: "bind-existing")
         }
 
@@ -1289,10 +1297,20 @@ final class LiveActivityManager {
         )
         // 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.
+        // and block the restart kicked off below.
         endingForRestart = true
         end()
-        // Activity will restart on next BG refresh via refreshFromCurrentState()
+
+        // Waiting for the next BG refresh is unreliable: end() nulls `current`
+        // and clears laRenewBy, so renewIfNeeded short-circuits and performRefresh's
+        // bind-existing path rebinds to the just-ended activity — clearing
+        // endingForRestart and turning the eventual iOS dismissal into a misclassified
+        // user swipe. Drive the restart synchronously instead.
+        if #available(iOS 17.2, *) {
+            Task { @MainActor [weak self] in
+                self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
+            }
+        }
     }
 
     // MARK: - Renewal Notifications
@@ -1358,7 +1376,13 @@ final class LiveActivityManager {
             for await state in activity.activityStateUpdates {
                 LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true)
                 if state == .ended || state == .dismissed {
-                    if current?.id == activity.id {
+                    // Capture whether this delivery is for the activity we currently track
+                    // BEFORE clearing `current` below. The classifier needs this signal to
+                    // distinguish a real user swipe of the foreground LA from a late
+                    // .dismissed delivered by a stale observer for an activity we already
+                    // ended programmatically.
+                    let wasCurrentActivity = current?.id == activity.id
+                    if wasCurrentActivity {
                         current = nil
                         // Do NOT clear laRenewBy here. Preserving it means handleForeground()
                         // can detect the renewal window on the next foreground event and restart
@@ -1388,17 +1412,27 @@ final class LiveActivityManager {
                         //     auto-restart until forceRestart() is called. Clear laRenewBy so
                         //     handleForeground() does NOT re-enter the renewal path on the next
                         //     foreground — the renewal intent is cancelled by the user's choice.
+                        //
+                        // Gated on `wasCurrentActivity`: the user can only swipe the
+                        // foreground LA. A .dismissed for an activity we no longer track is a
+                        // stale observer (the activity was ended programmatically and iOS is
+                        // just now cleaning up) — must not latch dismissedByUser=true.
                         let now = Date().timeIntervalSince1970
                         let renewBy = Storage.shared.laRenewBy.value
                         let renewalFailed = Storage.shared.laRenewalFailed.value
                         let pastDeadline = renewBy > 0 && now >= renewBy
-                        LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), renewBy=\(renewBy), now=\(now)")
+                        LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), wasCurrent=\(wasCurrentActivity), renewBy=\(renewBy), now=\(now)")
                         if endingForRestart {
                             // (a) Our own restart — do nothing, Task handles the rest.
                             LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action")
                         } else if renewalFailed || pastDeadline {
                             // (b) iOS system force-dismiss — allow auto-restart on next foreground.
                             LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground")
+                        } else if !wasCurrentActivity {
+                            // (d) Stale observer for an activity we no longer track (e.g. a
+                            //     post-410 end whose iOS-side dismissal landed hours later).
+                            //     Not a user swipe — no flags to set.
+                            LogManager.shared.log(category: .general, message: "[LA] dismissed by stale observer (id=\(activity.id) is not current) — no action")
                         } else {
                             // (c) User decision — cancel renewal intent, block auto-restart.
                             dismissedByUser = true