Explorar o código

Prevent stale foreground-restart latch from tearing down freshly renewed LA (#656)

pendingForegroundRestart could outlive the condition that triggered it:
a brief foreground entry while the renewal overlay was up latched the
intent, the user backgrounded before didBecomeActive ran, the background
renewal then replaced the LA, and the next foreground entry minutes later
fired the deferred restart against an already-fresh LA.

- adoptPushToStartActivity clears pendingForegroundRestart on every
  adoption — a freshly-adopted LA resolves the renewal-window condition
  that latched the intent.
- performForegroundRestart re-checks renewalFailed / overlayShowing /
  pushToStartLooksStuck before tearing down; bails if none still hold.
- Deferred-foreground-restart push-to-start is tagged
  reason="deferred-foreground-restart" via a single-shot
  nextStartReasonOverride, so the stale-latch event is no longer
  indistinguishable from a real user start in logs.
Jonas Björkert hai 2 meses
pai
achega
ee48e3eb15
Modificáronse 1 ficheiros con 42 adicións e 2 borrados
  1. 42 2
      LoopFollow/LiveActivity/LiveActivityManager.swift

+ 42 - 2
LoopFollow/LiveActivity/LiveActivityManager.swift

@@ -174,6 +174,17 @@ final class LiveActivityManager {
         Storage.shared.laRenewalFailed.value = false
         cancelRenewalFailedNotification()
         dismissedByUser = false
+        // A fresh LA invalidates any latched foreground-restart intent — the
+        // condition that prompted the latch (overlay showing / renewal failed)
+        // is resolved by adoption itself, so a deferred restart on the next
+        // didBecomeActive would needlessly tear down the just-adopted LA.
+        if pendingForegroundRestart {
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] adoption clears stale pendingForegroundRestart (LA already replaced via push-to-start)"
+            )
+            pendingForegroundRestart = false
+        }
         bind(to: activity, logReason: "push-to-start-adopt")
     }
 
@@ -291,10 +302,30 @@ final class LiveActivityManager {
     }
 
     private func performForegroundRestart() {
+        // Re-check the conditions that latched the intent. The latch can outlive its
+        // trigger — e.g. if the user briefly foregrounds the app while the renewal
+        // overlay is up, then backgrounds before didBecomeActive runs, the background
+        // renewal can replace the LA before the next foreground entry. By the time
+        // didBecomeActive eventually fires, the freshly-renewed LA is healthy and a
+        // restart would be gratuitous.
+        let renewalFailed = Storage.shared.laRenewalFailed.value
+        let renewBy = Storage.shared.laRenewBy.value
+        let now = Date().timeIntervalSince1970
+        let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
+        let pushToStartLooksStuck = pushToStartSendsWithoutAdoption >= LiveActivityManager.pushToStartForceRestartThreshold
+        guard renewalFailed || overlayIsShowing || pushToStartLooksStuck else {
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] deferred foreground restart skipped — conditions no longer hold (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), pushToStartLooksStuck=\(pushToStartLooksStuck))"
+            )
+            return
+        }
+
         // Mark restart intent BEFORE clearing storage flags, so any late .dismissed
         // from the old activity is never misclassified as a user swipe.
         endingForRestart = true
         dismissedByUser = false
+        nextStartReasonOverride = "deferred-foreground-restart"
 
         // Stop any observers/tasks tied to the previous activity instance. In the
         // current=nil branch below, the old observer can otherwise deliver a late
@@ -458,6 +489,12 @@ final class LiveActivityManager {
     /// new `pushToStartToken` when the current one has gone silent
     /// (Apple FB21158660).
     private var pushToStartSendsWithoutAdoption: Int = 0
+    /// Single-shot override for the next push-to-start reason tag. Consumed by
+    /// `startIfNeeded`. Lets the deferred-foreground-restart path tag its
+    /// push-to-start with a distinct label instead of "user-start", which made
+    /// the 8:25 stale-latch event indistinguishable from a real user start in
+    /// the log.
+    private var nextStartReasonOverride: String?
 
     // MARK: - Public API
 
@@ -475,6 +512,9 @@ final class LiveActivityManager {
             return
         }
 
+        let startReason = nextStartReasonOverride ?? "user-start"
+        nextStartReasonOverride = nil
+
         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
@@ -495,10 +535,10 @@ final class LiveActivityManager {
                     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)
+                attemptPushToStartCreate(reason: startReason, oldActivity: existing)
                 return
             }
-            attemptPushToStartCreate(reason: "user-start", oldActivity: nil)
+            attemptPushToStartCreate(reason: startReason, oldActivity: nil)
         } else {
             startIfNeededLegacy()
         }