From 923d24282e40da3af48a0547edea27139da910b2 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 26 Mar 2026 05:57:29 -0400 Subject: [PATCH 1/2] Fix LA not refreshing on foreground after stale overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startIfNeeded() unconditionally reused any existing activity, which meant that on cold start (app killed while stale overlay was showing) willEnterForeground is never sent, handleForeground never runs, and viewDidAppear → startFromCurrentState → startIfNeeded just rebinds to the stale activity — leaving the overlay visible. Fix: before reusing an existing activity in startIfNeeded(), check whether its staleDate has passed or the renewal window is open. If so, end it (awaited) and call startIfNeeded() again so a fresh activity with a new 7.5h deadline is started. Also add cancelRenewalFailedNotification() to handleForeground() so the "Live Activity Expiring" system notification is dismissed whenever the foreground restart path fires, not only via forceRestart(). Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 5b8f13bbf..9d62140fd 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -120,6 +120,7 @@ final class LiveActivityManager { // new LA is started computes showRenewalOverlay = false. Storage.shared.laRenewBy.value = 0 Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() guard let activity = current else { startFromCurrentState() @@ -189,6 +190,28 @@ final class LiveActivityManager { } if let existing = Activity.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 + // never sent, so handleForeground never runs) and any other path that lands here + // without first going through handleForeground. + 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 { + LogManager.shared.log(category: .general, message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))") + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + Task { + await existing.end(nil, dismissalPolicy: .immediate) + await MainActor.run { self.startIfNeeded() } + } + return + } + bind(to: existing, logReason: "reuse") Storage.shared.laRenewalFailed.value = false return From 814ba3714e4b9ae728a1a9dc898c05d88c458d3e Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:55:17 -0400 Subject: [PATCH 2/2] Fix stale LA dismissed by iOS incorrectly blocking auto-restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When iOS dismisses a Live Activity because its staleDate passed (background stale overlay case), laRenewalFailed is false, so the state observer's else branch fired and set dismissedByUser=true — permanently blocking all auto-restart paths (startFromCurrentState has guard !dismissedByUser). Fix 1: attachStateObserver now checks staleDatePassed alongside laRenewalFailed; both are iOS-initiated dismissals that should allow auto-restart. Fix 2: handleForeground() Task resets dismissedByUser=false before calling startFromCurrentState(), guarding against the race where the state observer fires .dismissed during our own end() call before its Task cancellation takes effect. Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 9d62140fd..abf9c6518 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -142,6 +142,10 @@ final class LiveActivityManager { // writing a new laRenewBy deadline. await activity.end(nil, dismissalPolicy: .immediate) await MainActor.run { + // Reset dismissedByUser in case the state observer fired .dismissed during + // our own end() call (before its Task cancellation took effect) and + // incorrectly set it to true — startFromCurrentState guards on this flag. + self.dismissedByUser = false // startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false // since laRenewBy is 0), saves it to the store, then calls startIfNeeded() // which finds no existing activity and requests a fresh LA with a new deadline. @@ -635,10 +639,14 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) } if state == .dismissed { - if Storage.shared.laRenewalFailed.value { - // iOS force-dismissed after 8-hour limit with a failed renewal. - // Allow auto-restart when the user opens the app. - LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS after expiry — auto-restart enabled") + // Distinguish system-initiated dismissal from a user swipe. + // iOS dismisses the activity when (a) the renewal limit was reached + // with a failed renewal, or (b) the staleDate passed and iOS decided + // to remove the activity. In both cases auto-restart is appropriate. + // Only a true user swipe (activity still fresh) should block restart. + let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false + if Storage.shared.laRenewalFailed.value || staleDatePassed { + LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS (renewalFailed=\(Storage.shared.laRenewalFailed.value), staleDatePassed=\(staleDatePassed)) — auto-restart enabled") } else { // User manually swiped away the LA. Block auto-restart until // the user explicitly restarts via button or App Intent.