From f402f86de8c91f171b5b0750a816fcebf79eaad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 6 May 2026 15:27:47 +0200 Subject: [PATCH] Suppress silent LA notifications and prevent stale foreground-restart Three independent fixes for Live Activity behaviour exposed by a 6.1.0 report of audible notifications + a gratuitous restart: 1. AppDelegate's UNUserNotificationCenterDelegate willPresent handler returned [.banner, .sound, .badge] for every notification. The Live Activity push-to-start payload is intentionally silent (interruption- level: passive, empty title/body), so anything iOS routes through willPresent while the app is foregrounded would still produce sound. Now bails out for passive notifications and for ones with empty title/body. Intentional alerts (renewal-failed, APNs missing, push- to-start token missing, alarms) all carry non-empty title/body and default .active interruption level, so they continue to surface. 2. pendingForegroundRestart could survive a background renewal: 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 a freshly-renewed LA. adoptPushToStartActivity now clears the latch on every adoption, and performForegroundRestart re-checks the conditions that triggered the latch and bails if they no longer hold. 3. The deferred-foreground-restart path went through startIfNeeded and was logged as reason="user-start", which made the gratuitous restart look like a user action. Threaded a single-shot reason override so the deferred path tags its push-to-start as reason="deferred-foreground-restart". Also expanded the willPresent log line with interruption level and title/body presence so future reports can confirm whether iOS is routing LA push-to-start payloads through willPresent. --- LoopFollow/Application/AppDelegate.swift | 19 +++++--- .../LiveActivity/LiveActivityManager.swift | 44 ++++++++++++++++++- 2 files changed, 56 insertions(+), 7 deletions(-) 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() }