Skip to content

Commit fc40566

Browse files
committed
Merge PR #615: Fix Live Activity restart classification, foreground race, troubleshooting logs
2 parents 7a9f440 + 82bc68b commit fc40566

2 files changed

Lines changed: 93 additions & 59 deletions

File tree

LoopFollow/LiveActivity/LiveActivityManager.swift

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,18 @@ final class LiveActivityManager {
8787

8888
@objc private func handleDidBecomeActive() {
8989
guard Storage.shared.laEnabled.value else { return }
90-
if skipNextDidBecomeActive {
91-
LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: skipped (handleForeground owns restart)", isDebug: true)
92-
skipNextDidBecomeActive = false
90+
let appState = UIApplication.shared.applicationState.rawValue
91+
let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
92+
if pendingForegroundRestart {
93+
pendingForegroundRestart = false
94+
LogManager.shared.log(
95+
category: .general,
96+
message: "[LA] didBecomeActive: running deferred foreground restart (appState=\(appState), activities=\(existing))"
97+
)
98+
performForegroundRestart()
9399
return
94100
}
95-
LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: calling startFromCurrentState, dismissedByUser=\(dismissedByUser)", isDebug: true)
101+
LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: startFromCurrentState (appState=\(appState), activities=\(existing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser))", isDebug: true)
96102
Task { @MainActor in
97103
self.startFromCurrentState()
98104
}
@@ -105,24 +111,30 @@ final class LiveActivityManager {
105111
let renewBy = Storage.shared.laRenewBy.value
106112
let now = Date().timeIntervalSince1970
107113
let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
114+
let appState = UIApplication.shared.applicationState.rawValue
115+
let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
108116

109117
LogManager.shared.log(
110118
category: .general,
111-
message: "[LA] foreground: renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now)"
119+
message: "[LA] foreground: appState=\(appState), activities=\(existing), renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now)"
112120
)
113121

114122
guard renewalFailed || overlayIsShowing else {
115123
LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)")
116124
return
117125
}
118126

127+
// willEnterForegroundNotification fires before the scene reaches
128+
// foregroundActive — Activity.request() returns `visibility` during
129+
// this window. Defer the actual restart to didBecomeActive.
130+
pendingForegroundRestart = true
119131
LogManager.shared.log(
120132
category: .general,
121-
message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))"
133+
message: "[LA] foreground: scheduling restart on next didBecomeActive (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))"
122134
)
135+
}
123136

124-
skipNextDidBecomeActive = true
125-
137+
private func performForegroundRestart() {
126138
// Mark restart intent BEFORE clearing storage flags, so any late .dismissed
127139
// from the old activity is never misclassified as a user swipe.
128140
endingForRestart = true
@@ -249,14 +261,22 @@ final class LiveActivityManager {
249261
/// a .dismissed delivery triggered by our own end() call is never misclassified as a
250262
/// user swipe — regardless of the order in which the MainActor executes the two writes.
251263
private var endingForRestart = false
252-
/// Set by handleForeground() when it takes ownership of the restart sequence.
253-
/// Prevents handleDidBecomeActive() from racing with an in-flight end+restart.
254-
private var skipNextDidBecomeActive = false
264+
/// Set by handleForeground() when the renewal window has been detected.
265+
/// The actual end+restart is run from handleDidBecomeActive() because
266+
/// Activity.request() returns `visibility` during willEnterForeground.
267+
private var pendingForegroundRestart = false
255268

256269
// MARK: - Public API
257270

258271
func startIfNeeded() {
259-
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
272+
let authorized = ActivityAuthorizationInfo().areActivitiesEnabled
273+
let existingCount = Activity<GlucoseLiveActivityAttributes>.activities.count
274+
LogManager.shared.log(
275+
category: .general,
276+
message: "[LA] startIfNeeded: authorized=\(authorized), activities=\(existingCount), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), laEnabled=\(Storage.shared.laEnabled.value)",
277+
isDebug: true
278+
)
279+
guard authorized else {
260280
LogManager.shared.log(category: .general, message: "Live Activity not authorized")
261281
return
262282
}
@@ -335,7 +355,12 @@ final class LiveActivityManager {
335355
Storage.shared.laRenewalFailed.value = false
336356
LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)")
337357
} catch {
338-
LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)")
358+
let ns = error as NSError
359+
let scene = isAppVisibleForLiveActivityStart()
360+
LogManager.shared.log(
361+
category: .general,
362+
message: "Live Activity failed to start: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), sceneActive=\(scene), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
363+
)
339364
}
340365
}
341366

@@ -344,6 +369,10 @@ final class LiveActivityManager {
344369
/// Does not clear laEnabled — the user's preference is preserved for relaunch.
345370
func endOnTerminate() {
346371
guard let activity = current else { return }
372+
// Flag the end as system-initiated so the state observer does not
373+
// classify the resulting `.dismissed` as a user swipe (laRenewBy is
374+
// cleared below, which would otherwise make pastDeadline=false).
375+
endingForRestart = true
347376
current = nil
348377
Storage.shared.laRenewBy.value = 0
349378
LALivenessStore.clear()
@@ -399,6 +428,10 @@ final class LiveActivityManager {
399428
func forceRestart() {
400429
guard Storage.shared.laEnabled.value else { return }
401430
LogManager.shared.log(category: .general, message: "[LA] forceRestart called")
431+
// Mark as system-initiated so any residual `.dismissed` delivered from
432+
// the cancelled state observer stream cannot flip dismissedByUser=true
433+
// and spoil the freshly started LA.
434+
endingForRestart = true
402435
dismissedByUser = false
403436
Storage.shared.laRenewBy.value = 0
404437
Storage.shared.laRenewalFailed.value = false
@@ -515,7 +548,11 @@ final class LiveActivityManager {
515548
// Renewal failed — deadline was never written, so no rollback needed.
516549
let isFirstFailure = !Storage.shared.laRenewalFailed.value
517550
Storage.shared.laRenewalFailed.value = true
518-
LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)")
551+
let ns = error as NSError
552+
LogManager.shared.log(
553+
category: .general,
554+
message: "[LA] renewal failed, keeping existing LA: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
555+
)
519556
if isFirstFailure {
520557
scheduleRenewalFailedNotification()
521558
}
@@ -557,9 +594,17 @@ final class LiveActivityManager {
557594
// WatchConnectivityManager.shared.send(snapshot: snapshot)
558595

559596
// LA update: gated on LA being active, snapshot having changed, and activities enabled.
560-
guard Storage.shared.laEnabled.value, !dismissedByUser else { return }
597+
if !Storage.shared.laEnabled.value {
598+
LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — laEnabled=false reason=\(reason)", isDebug: true)
599+
return
600+
}
601+
if dismissedByUser {
602+
LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — dismissedByUser=true reason=\(reason)")
603+
return
604+
}
561605
guard !snapshotUnchanged || forceRefreshNeeded else { return }
562606
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
607+
LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)")
563608
return
564609
}
565610
if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
@@ -632,6 +677,12 @@ final class LiveActivityManager {
632677

633678
if isForeground {
634679
await activity.update(content)
680+
} else {
681+
LogManager.shared.log(
682+
category: .general,
683+
message: "[LA] update seq=\(nextSeq) — app backgrounded, direct ActivityKit update skipped, relying on APNs",
684+
isDebug: true
685+
)
635686
}
636687

637688
if Task.isCancelled { return }
@@ -646,6 +697,11 @@ final class LiveActivityManager {
646697

647698
if let token = pushToken {
648699
await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
700+
} else {
701+
LogManager.shared.log(
702+
category: .general,
703+
message: "[LA] update seq=\(nextSeq) reason=\(reason) — no push token yet, APNs skipped"
704+
)
649705
}
650706
}
651707
}
@@ -668,25 +724,45 @@ final class LiveActivityManager {
668724
private func bind(to activity: Activity<GlucoseLiveActivityAttributes>, logReason: String) {
669725
if current?.id == activity.id { return }
670726
current = activity
727+
let wasEndingForRestart = endingForRestart
671728
dismissedByUser = false
672729
endingForRestart = false
673730
attachStateObserver(to: activity)
674-
LogManager.shared.log(category: .general, message: "Live Activity bound id=\(activity.id) (\(logReason))", isDebug: true)
731+
LogManager.shared.log(
732+
category: .general,
733+
message: "Live Activity bound id=\(activity.id) state=\(activity.activityState) (\(logReason)) — endingForRestart cleared (was \(wasEndingForRestart))",
734+
isDebug: true
735+
)
675736
observePushToken(for: activity)
676737
}
677738

678739
private func observePushToken(for activity: Activity<GlucoseLiveActivityAttributes>) {
679740
tokenObservationTask?.cancel()
741+
let activityID = activity.id
680742
tokenObservationTask = Task {
681743
for await tokenData in activity.pushTokenUpdates {
682744
let token = tokenData.map { String(format: "%02x", $0) }.joined()
745+
let previousTail = self.pushToken.map { String($0.suffix(8)) } ?? "nil"
746+
let tail = String(token.suffix(8))
683747
self.pushToken = token
684-
LogManager.shared.log(category: .general, message: "Live Activity push token received", isDebug: true)
748+
LogManager.shared.log(
749+
category: .general,
750+
message: "[LA] push token received id=\(activityID) token=…\(tail) (prev=…\(previousTail))"
751+
)
685752
}
686753
}
687754
}
688755

689756
func handleExpiredToken() {
757+
let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
758+
LogManager.shared.log(
759+
category: .general,
760+
message: "[LA] handleExpiredToken: current=\(current?.id ?? "nil"), activities=\(existing), dismissedByUser=\(dismissedByUser) — marking endingForRestart and ending"
761+
)
762+
// Mark as system-initiated so the `.dismissed` delivered by end()
763+
// is not classified as a user swipe — that would set dismissedByUser=true
764+
// and block the auto-restart promised by the comment below.
765+
endingForRestart = true
690766
end()
691767
// Activity will restart on next BG refresh via refreshFromCurrentState()
692768
}

LoopFollow/Settings/LiveActivitySettingsView.swift

Lines changed: 0 additions & 42 deletions
This file was deleted.

0 commit comments

Comments
 (0)