diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 831395f02..f09e7b297 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -231,12 +231,21 @@ extension AppDelegate: UNUserNotificationCenterDelegate { willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - // Log the notification - let userInfo = notification.request.content.userInfo - let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted() - LogManager.shared.log(category: .general, message: "Will present notification: keys=\(userInfoKeys)") + let content = notification.request.content + let userInfoKeys = content.userInfo.keys.compactMap { $0 as? String }.sorted() + LogManager.shared.log( + category: .general, + message: "Will present notification: keys=\(userInfoKeys), interruption=\(content.interruptionLevel.rawValue), title=\(content.title.isEmpty ? "empty" : "set"), body=\(content.body.isEmpty ? "empty" : "set")" + ) + + // Suppress notifications iOS routes here that we never intended to surface: + // the Live Activity push-to-start uses interruption-level: passive with empty + // title/body and must not produce a banner or sound when LF is foregrounded. + if content.interruptionLevel == .passive || (content.title.isEmpty && content.body.isEmpty) { + completionHandler([]) + return + } - // Show the notification even when app is in foreground completionHandler([.banner, .sound, .badge]) } } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 3a58d12e1..be2b6f659 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/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() }