Skip to content
Open
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
46 changes: 40 additions & 6 deletions LoopFollow/LiveActivity/LiveActivityManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1093,7 +1093,13 @@ final class LiveActivityManager {
LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)")
return
}
if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
if current == nil,
let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
{
// Skip activities already in .ended/.dismissed — those are corpses
// (typically post-410 ends pending iOS dismissal). Binding to them
// would clear endingForRestart and turn the eventual iOS dismissal
// into a misclassified user swipe.
bind(to: existing, logReason: "bind-existing")
}
if let _ = current {
Expand All @@ -1118,7 +1124,9 @@ final class LiveActivityManager {
}

func update(snapshot: GlucoseSnapshot, reason: String) {
if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
if current == nil,
let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
{
bind(to: existing, logReason: "bind-existing")
}

Expand Down Expand Up @@ -1249,10 +1257,20 @@ final class LiveActivityManager {
)
// Mark as system-initiated so the `.dismissed` delivered by end()
// is not classified as a user swipe — that would set dismissedByUser=true
// and block the auto-restart promised by the comment below.
// and block the restart kicked off below.
endingForRestart = true
end()
// Activity will restart on next BG refresh via refreshFromCurrentState()

// Waiting for the next BG refresh is unreliable: end() nulls `current`
// and clears laRenewBy, so renewIfNeeded short-circuits and performRefresh's
// bind-existing path rebinds to the just-ended activity — clearing
// endingForRestart and turning the eventual iOS dismissal into a misclassified
// user swipe. Drive the restart synchronously instead.
if #available(iOS 17.2, *) {
Task { @MainActor [weak self] in
self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
}
}
}

// MARK: - Renewal Notifications
Expand Down Expand Up @@ -1318,7 +1336,13 @@ final class LiveActivityManager {
for await state in activity.activityStateUpdates {
LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true)
if state == .ended || state == .dismissed {
if current?.id == activity.id {
// Capture whether this delivery is for the activity we currently track
// BEFORE clearing `current` below. The classifier needs this signal to
// distinguish a real user swipe of the foreground LA from a late
// .dismissed delivered by a stale observer for an activity we already
// ended programmatically.
let wasCurrentActivity = current?.id == activity.id
if wasCurrentActivity {
current = nil
// Do NOT clear laRenewBy here. Preserving it means handleForeground()
// can detect the renewal window on the next foreground event and restart
Expand Down Expand Up @@ -1348,17 +1372,27 @@ final class LiveActivityManager {
// auto-restart until forceRestart() is called. Clear laRenewBy so
// handleForeground() does NOT re-enter the renewal path on the next
// foreground — the renewal intent is cancelled by the user's choice.
//
// Gated on `wasCurrentActivity`: the user can only swipe the
// foreground LA. A .dismissed for an activity we no longer track is a
// stale observer (the activity was ended programmatically and iOS is
// just now cleaning up) — must not latch dismissedByUser=true.
let now = Date().timeIntervalSince1970
let renewBy = Storage.shared.laRenewBy.value
let renewalFailed = Storage.shared.laRenewalFailed.value
let pastDeadline = renewBy > 0 && now >= renewBy
LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), renewBy=\(renewBy), now=\(now)")
LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), wasCurrent=\(wasCurrentActivity), renewBy=\(renewBy), now=\(now)")
if endingForRestart {
// (a) Our own restart — do nothing, Task handles the rest.
LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action")
} else if renewalFailed || pastDeadline {
// (b) iOS system force-dismiss — allow auto-restart on next foreground.
LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground")
} else if !wasCurrentActivity {
// (d) Stale observer for an activity we no longer track (e.g. a
// post-410 end whose iOS-side dismissal landed hours later).
// Not a user swipe — no flags to set.
LogManager.shared.log(category: .general, message: "[LA] dismissed by stale observer (id=\(activity.id) is not current) — no action")
} else {
// (c) User decision — cancel renewal intent, block auto-restart.
dismissedByUser = true
Expand Down
Loading