Skip to content
Merged
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
39 changes: 35 additions & 4 deletions LoopFollow/LiveActivity/LiveActivityManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ final class LiveActivityManager {
// new LA is started computes showRenewalOverlay = false.
Storage.shared.laRenewBy.value = 0
Storage.shared.laRenewalFailed.value = false
cancelRenewalFailedNotification()

guard let activity = current else {
startFromCurrentState()
Expand All @@ -141,6 +142,10 @@ final class LiveActivityManager {
// writing a new laRenewBy deadline.
await activity.end(nil, dismissalPolicy: .immediate)
await MainActor.run {
// Reset dismissedByUser in case the state observer fired .dismissed during
// our own end() call (before its Task cancellation took effect) and
// incorrectly set it to true — startFromCurrentState guards on this flag.
self.dismissedByUser = false
// startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false
// since laRenewBy is 0), saves it to the store, then calls startIfNeeded()
// which finds no existing activity and requests a fresh LA with a new deadline.
Expand Down Expand Up @@ -189,6 +194,28 @@ final class LiveActivityManager {
}

if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
// Before reusing, check whether this activity needs a restart. This covers cold
// starts (app was killed while the overlay was showing — willEnterForeground is
// never sent, so handleForeground never runs) and any other path that lands here
// without first going through handleForeground.
let renewBy = Storage.shared.laRenewBy.value
let now = Date().timeIntervalSince1970
let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed

if needsRestart {
LogManager.shared.log(category: .general, message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))")
Storage.shared.laRenewBy.value = 0
Storage.shared.laRenewalFailed.value = false
cancelRenewalFailedNotification()
Task {
await existing.end(nil, dismissalPolicy: .immediate)
await MainActor.run { self.startIfNeeded() }
}
return
}

bind(to: existing, logReason: "reuse")
Storage.shared.laRenewalFailed.value = false
return
Expand Down Expand Up @@ -612,10 +639,14 @@ final class LiveActivityManager {
LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true)
}
if state == .dismissed {
if Storage.shared.laRenewalFailed.value {
// iOS force-dismissed after 8-hour limit with a failed renewal.
// Allow auto-restart when the user opens the app.
LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS after expiry — auto-restart enabled")
// Distinguish system-initiated dismissal from a user swipe.
// iOS dismisses the activity when (a) the renewal limit was reached
// with a failed renewal, or (b) the staleDate passed and iOS decided
// to remove the activity. In both cases auto-restart is appropriate.
// Only a true user swipe (activity still fresh) should block restart.
let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false
if Storage.shared.laRenewalFailed.value || staleDatePassed {
LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS (renewalFailed=\(Storage.shared.laRenewalFailed.value), staleDatePassed=\(staleDatePassed)) — auto-restart enabled")
} else {
// User manually swiped away the LA. Block auto-restart until
// the user explicitly restarts via button or App Intent.
Expand Down