Skip to content

Commit 39d3d39

Browse files
bjorkertmarionbarker
authored andcommitted
Recover Live Activity after APNs 410 token expiry (#657)
A 6.1.0 user reported the Live Activity vanishing and refusing to come back without a manual Restart. Trace: APNs returned 410 on the per- activity push token at 04:42; handleExpiredToken ended the activity but the eventual iOS .dismissed (4 h later, under the default dismissal policy) was classified as a user swipe and locked dismissedByUser=true. Root cause is two cooperating bugs around an app-initiated end(): - end() nulls `current` and clears laRenewBy. handleExpiredToken's comment said "Activity will restart on next BG refresh via refreshFromCurrentState()", but renewIfNeeded short-circuits when current is nil and performRefresh's bind-existing path rebinds to the just-ended activity. bind() then clears endingForRestart, so the late .dismissed reads as renewBy=0 / renewalFailed=false / endingForRestart= false — branch (c) "USER" in the classifier. - The classifier had no way to recognize a stale observer firing for an activity the app no longer tracks. Fixes: - handleExpiredToken drives the restart synchronously on iOS 17.2+ (attemptPushToStartCreate "expired-token"), so the orphaned post-410 state is short-lived and adoption of the fresh activity cancels the old observer. - performRefresh / update bind-existing only to activities in .active state. Binding to an .ended/.dismissed corpse would clear endingForRestart and re-attach an observer that only ever delivers .dismissed. - .dismissed classifier gains branch (d): if the dismissed activity is not the one we currently track, log and take no action — only the foreground LA can be user-swiped, so a stale-observer delivery for an already-replaced activity must not latch dismissedByUser=true.
1 parent 17582b0 commit 39d3d39

1 file changed

Lines changed: 40 additions & 6 deletions

File tree

LoopFollow/LiveActivity/LiveActivityManager.swift

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,7 +1133,13 @@ final class LiveActivityManager {
11331133
LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)")
11341134
return
11351135
}
1136-
if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
1136+
if current == nil,
1137+
let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
1138+
{
1139+
// Skip activities already in .ended/.dismissed — those are corpses
1140+
// (typically post-410 ends pending iOS dismissal). Binding to them
1141+
// would clear endingForRestart and turn the eventual iOS dismissal
1142+
// into a misclassified user swipe.
11371143
bind(to: existing, logReason: "bind-existing")
11381144
}
11391145
if let _ = current {
@@ -1158,7 +1164,9 @@ final class LiveActivityManager {
11581164
}
11591165

11601166
func update(snapshot: GlucoseSnapshot, reason: String) {
1161-
if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
1167+
if current == nil,
1168+
let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
1169+
{
11621170
bind(to: existing, logReason: "bind-existing")
11631171
}
11641172

@@ -1289,10 +1297,20 @@ final class LiveActivityManager {
12891297
)
12901298
// Mark as system-initiated so the `.dismissed` delivered by end()
12911299
// is not classified as a user swipe — that would set dismissedByUser=true
1292-
// and block the auto-restart promised by the comment below.
1300+
// and block the restart kicked off below.
12931301
endingForRestart = true
12941302
end()
1295-
// Activity will restart on next BG refresh via refreshFromCurrentState()
1303+
1304+
// Waiting for the next BG refresh is unreliable: end() nulls `current`
1305+
// and clears laRenewBy, so renewIfNeeded short-circuits and performRefresh's
1306+
// bind-existing path rebinds to the just-ended activity — clearing
1307+
// endingForRestart and turning the eventual iOS dismissal into a misclassified
1308+
// user swipe. Drive the restart synchronously instead.
1309+
if #available(iOS 17.2, *) {
1310+
Task { @MainActor [weak self] in
1311+
self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
1312+
}
1313+
}
12961314
}
12971315

12981316
// MARK: - Renewal Notifications
@@ -1358,7 +1376,13 @@ final class LiveActivityManager {
13581376
for await state in activity.activityStateUpdates {
13591377
LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true)
13601378
if state == .ended || state == .dismissed {
1361-
if current?.id == activity.id {
1379+
// Capture whether this delivery is for the activity we currently track
1380+
// BEFORE clearing `current` below. The classifier needs this signal to
1381+
// distinguish a real user swipe of the foreground LA from a late
1382+
// .dismissed delivered by a stale observer for an activity we already
1383+
// ended programmatically.
1384+
let wasCurrentActivity = current?.id == activity.id
1385+
if wasCurrentActivity {
13621386
current = nil
13631387
// Do NOT clear laRenewBy here. Preserving it means handleForeground()
13641388
// can detect the renewal window on the next foreground event and restart
@@ -1388,17 +1412,27 @@ final class LiveActivityManager {
13881412
// auto-restart until forceRestart() is called. Clear laRenewBy so
13891413
// handleForeground() does NOT re-enter the renewal path on the next
13901414
// foreground — the renewal intent is cancelled by the user's choice.
1415+
//
1416+
// Gated on `wasCurrentActivity`: the user can only swipe the
1417+
// foreground LA. A .dismissed for an activity we no longer track is a
1418+
// stale observer (the activity was ended programmatically and iOS is
1419+
// just now cleaning up) — must not latch dismissedByUser=true.
13911420
let now = Date().timeIntervalSince1970
13921421
let renewBy = Storage.shared.laRenewBy.value
13931422
let renewalFailed = Storage.shared.laRenewalFailed.value
13941423
let pastDeadline = renewBy > 0 && now >= renewBy
1395-
LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), renewBy=\(renewBy), now=\(now)")
1424+
LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), wasCurrent=\(wasCurrentActivity), renewBy=\(renewBy), now=\(now)")
13961425
if endingForRestart {
13971426
// (a) Our own restart — do nothing, Task handles the rest.
13981427
LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action")
13991428
} else if renewalFailed || pastDeadline {
14001429
// (b) iOS system force-dismiss — allow auto-restart on next foreground.
14011430
LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground")
1431+
} else if !wasCurrentActivity {
1432+
// (d) Stale observer for an activity we no longer track (e.g. a
1433+
// post-410 end whose iOS-side dismissal landed hours later).
1434+
// Not a user swipe — no flags to set.
1435+
LogManager.shared.log(category: .general, message: "[LA] dismissed by stale observer (id=\(activity.id) is not current) — no action")
14021436
} else {
14031437
// (c) User decision — cancel renewal intent, block auto-restart.
14041438
dismissedByUser = true

0 commit comments

Comments
 (0)