Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
44 changes: 42 additions & 2 deletions LoopFollow/LiveActivity/LiveActivityManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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()
}
Expand Down
Loading