| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790 |
- // LoopFollow
- // LiveActivityManager.swift
- // swiftformat:disable indent
- #if !targetEnvironment(macCatalyst)
- @preconcurrency import ActivityKit
- import Foundation
- import os
- import UIKit
- import UserNotifications
- // Live Activity manager for LoopFollow.
- final class LiveActivityManager {
- static let shared = LiveActivityManager()
- private init() {
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(handleForeground),
- name: UIApplication.willEnterForegroundNotification,
- object: nil,
- )
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(handleDidBecomeActive),
- name: UIApplication.didBecomeActiveNotification,
- object: nil,
- )
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(handleWillResignActive),
- name: UIApplication.willResignActiveNotification,
- object: nil,
- )
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(handleBackgroundAudioFailed),
- name: .backgroundAudioFailed,
- object: nil,
- )
- }
- /// Fires before the app loses focus (lock screen, home button, etc.).
- /// Cancels any pending debounced refresh and pushes the latest snapshot
- /// directly to the Live Activity while the app is still foreground-active,
- /// ensuring the LA is up to date the moment the lock screen appears.
- @objc private func handleWillResignActive() {
- guard Storage.shared.laEnabled.value, let activity = current else { return }
- refreshWorkItem?.cancel()
- refreshWorkItem = nil
- let provider = StorageCurrentGlucoseStateProvider()
- guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return }
- LAAppGroupSettings.setThresholds(
- lowMgdl: Storage.shared.lowLine.value,
- highMgdl: Storage.shared.highLine.value,
- )
- GlucoseSnapshotStore.shared.save(snapshot)
- seq += 1
- let nextSeq = seq
- let state = GlucoseLiveActivityAttributes.ContentState(
- snapshot: snapshot,
- seq: nextSeq,
- reason: "resign-active",
- producedAt: Date(),
- )
- let content = ActivityContent(
- state: state,
- staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
- relevanceScore: 100.0,
- )
- Task {
- // Direct ActivityKit update — app is still active at this point.
- await activity.update(content)
- LogManager.shared.log(category: .general, message: "[LA] resign-active flush sent seq=\(nextSeq)", isDebug: true)
- // Also send APNs so the extension receives the latest token-based update.
- if let token = pushToken {
- await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
- }
- }
- }
- @objc private func handleDidBecomeActive() {
- guard Storage.shared.laEnabled.value else { return }
- if skipNextDidBecomeActive {
- LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: skipped (handleForeground owns restart)", isDebug: true)
- skipNextDidBecomeActive = false
- return
- }
- LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: calling startFromCurrentState, dismissedByUser=\(dismissedByUser)", isDebug: true)
- Task { @MainActor in
- self.startFromCurrentState()
- }
- }
- @objc private func handleForeground() {
- guard Storage.shared.laEnabled.value else { return }
- let renewalFailed = Storage.shared.laRenewalFailed.value
- let renewBy = Storage.shared.laRenewBy.value
- let now = Date().timeIntervalSince1970
- let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
- LogManager.shared.log(
- category: .general,
- message: "[LA] foreground: renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now)"
- )
- guard renewalFailed || overlayIsShowing else {
- LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)")
- return
- }
- LogManager.shared.log(
- category: .general,
- message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))"
- )
- skipNextDidBecomeActive = true
- // 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
- // Stop any observers/tasks tied to the previous activity instance. In the
- // current=nil branch below, the old observer can otherwise deliver a late
- // .dismissed and poison dismissedByUser.
- updateTask?.cancel()
- updateTask = nil
- tokenObservationTask?.cancel()
- tokenObservationTask = nil
- stateObserverTask?.cancel()
- stateObserverTask = nil
- pushToken = nil
- // Clear renewal state so the new snapshot does not show the renewal overlay.
- Storage.shared.laRenewBy.value = 0
- Storage.shared.laRenewalFailed.value = false
- cancelRenewalFailedNotification()
- guard let activity = current else {
- LogManager.shared.log(
- category: .general,
- message: "[LA] foreground restart: current=nil (old activity not bound locally), ending all existing LAs before restart"
- )
- current = nil
- Task {
- for activity in Activity<GlucoseLiveActivityAttributes>.activities {
- await activity.end(nil, dismissalPolicy: .immediate)
- }
- await MainActor.run {
- self.dismissedByUser = false
- self.startFromCurrentState(cleanupOrphans: false)
- LogManager.shared.log(
- category: .general,
- message: "[LA] foreground restart: fresh LA started after ending unbound existing activity"
- )
- }
- }
- return
- }
- current = nil
- Task {
- await activity.end(nil, dismissalPolicy: .immediate)
- await MainActor.run {
- self.dismissedByUser = false
- self.startFromCurrentState(cleanupOrphans: false)
- LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry")
- }
- }
- }
- @objc private func handleBackgroundAudioFailed() {
- guard Storage.shared.laEnabled.value, current != nil else { return }
- // The background audio session has permanently failed — the app will lose its
- // background keep-alive. Immediately push the renewal overlay so the user sees
- // "Tap to update" on the lock screen and knows to foreground the app.
- LogManager.shared.log(category: .general, message: "[LA] background audio failed — forcing renewal overlay")
- Storage.shared.laRenewBy.value = Date().timeIntervalSince1970
- refreshFromCurrentState(reason: "audio-session-failed")
- }
- private func shouldRestartBecauseExtensionLooksStuck() -> Bool {
- guard Storage.shared.laEnabled.value else { return false }
- guard !dismissedByUser else { return false }
- guard let activity = current ?? Activity<GlucoseLiveActivityAttributes>.activities.first else {
- return false
- }
- let now = Date().timeIntervalSince1970
- let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false
- if staleDatePassed {
- LogManager.shared.log(
- category: .general,
- message: "[LA] liveness check: staleDate already passed"
- )
- return true
- }
- let expectedSeq = activity.content.state.seq
- let seenSeq = LALivenessStore.lastExtensionSeq
- let lastSeenAt = LALivenessStore.lastExtensionSeenAt
- let lastProducedAt = LALivenessStore.lastExtensionProducedAt
- let extensionHasNeverCheckedIn = lastSeenAt <= 0
- let extensionLooksBehind = seenSeq < expectedSeq
- let noRecentExtensionTouch = extensionHasNeverCheckedIn || (now - lastSeenAt > LiveActivityManager.extensionLivenessGrace)
- LogManager.shared.log(
- category: .general,
- message: "[LA] liveness check: expectedSeq=\(expectedSeq), seenSeq=\(seenSeq), lastSeenAt=\(lastSeenAt), lastProducedAt=\(lastProducedAt), behind=\(extensionLooksBehind), noRecentTouch=\(noRecentExtensionTouch)",
- isDebug: true
- )
- // Conservative rule:
- // only suspect "stuck" if the extension is both behind AND has not checked in recently.
- return extensionLooksBehind && noRecentExtensionTouch
- }
- static let renewalThreshold: TimeInterval = 7.5 * 3600
- static let renewalWarning: TimeInterval = 30 * 60
- static let extensionLivenessGrace: TimeInterval = 15 * 60
- private(set) var current: Activity<GlucoseLiveActivityAttributes>?
- private var stateObserverTask: Task<Void, Never>?
- private var updateTask: Task<Void, Never>?
- private var seq: Int = 0
- private var lastUpdateTime: Date?
- private var pushToken: String?
- private var tokenObservationTask: Task<Void, Never>?
- private var refreshWorkItem: DispatchWorkItem?
- /// Set when the user manually swipes away the LA. Blocks auto-restart until
- /// an explicit user action (Restart button, App Intent) clears it.
- /// In-memory only — resets to false on app relaunch, so a kill + relaunch
- /// starts fresh as expected.
- private var dismissedByUser = false
- /// Set to true immediately before we call activity.end() as part of a planned restart.
- /// Cleared after the restart completes. The state observer checks this flag so that
- /// a .dismissed delivery triggered by our own end() call is never misclassified as a
- /// user swipe — regardless of the order in which the MainActor executes the two writes.
- private var endingForRestart = false
- /// Set by handleForeground() when it takes ownership of the restart sequence.
- /// Prevents handleDidBecomeActive() from racing with an in-flight end+restart.
- private var skipNextDidBecomeActive = false
- // MARK: - Public API
- func startIfNeeded() {
- guard ActivityAuthorizationInfo().areActivitiesEnabled else {
- LogManager.shared.log(category: .general, message: "Live Activity not authorized")
- return
- }
- 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))"
- )
- endingForRestart = true
- dismissedByUser = false
- 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
- }
- do {
- let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
- // Prefer a freshly built snapshot so all extended fields are populated.
- // Fall back to the persisted store (covers cold-start with real data),
- // then to a zero seed (true first-ever launch with no data yet).
- let provider = StorageCurrentGlucoseStateProvider()
- let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider)
- ?? GlucoseSnapshotStore.shared.load()
- ?? GlucoseSnapshot(
- glucose: 0,
- delta: 0,
- trend: .unknown,
- updatedAt: Date(),
- iob: nil,
- cob: nil,
- projected: nil,
- unit: .mgdl,
- isNotLooping: false,
- )
- let initialState = GlucoseLiveActivityAttributes.ContentState(
- snapshot: seedSnapshot,
- seq: 0,
- reason: "start",
- producedAt: Date(),
- )
- let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
- let content = ActivityContent(state: initialState, staleDate: renewDeadline)
- LALivenessStore.clear()
- let activity = try Activity.request(attributes: attributes, content: content, pushType: .token)
- bind(to: activity, logReason: "start-new")
- Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
- Storage.shared.laRenewalFailed.value = false
- LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)")
- } catch {
- LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)")
- }
- }
- /// Called from applicationWillTerminate. Ends the LA synchronously (blocking
- /// up to 3 s) so it clears from the lock screen before the process exits.
- /// Does not clear laEnabled — the user's preference is preserved for relaunch.
- func endOnTerminate() {
- guard let activity = current else { return }
- current = nil
- Storage.shared.laRenewBy.value = 0
- LALivenessStore.clear()
- let semaphore = DispatchSemaphore(value: 0)
- Task.detached {
- await activity.end(nil, dismissalPolicy: .immediate)
- semaphore.signal()
- }
- _ = semaphore.wait(timeout: .now() + 3)
- LogManager.shared.log(category: .general, message: "[LA] ended on app terminate")
- }
- func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) {
- updateTask?.cancel()
- updateTask = nil
- guard let activity = current else { return }
- Task {
- let finalState = GlucoseLiveActivityAttributes.ContentState(
- snapshot: GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot(
- glucose: 0,
- delta: 0,
- trend: .unknown,
- updatedAt: Date(),
- iob: nil,
- cob: nil,
- projected: nil,
- unit: .mgdl,
- isNotLooping: false,
- ),
- seq: seq,
- reason: "end",
- producedAt: Date(),
- )
- let content = ActivityContent(state: finalState, staleDate: nil)
- await activity.end(content, dismissalPolicy: dismissalPolicy)
- LogManager.shared.log(category: .general, message: "Live Activity ended id=\(activity.id)", isDebug: true)
- if current?.id == activity.id {
- current = nil
- Storage.shared.laRenewBy.value = 0
- LALivenessStore.clear()
- }
- }
- }
- /// Ends all running Live Activities and starts a fresh one from the current state.
- /// Intended for the "Restart Live Activity" button and the AppIntent.
- @MainActor
- func forceRestart() {
- guard Storage.shared.laEnabled.value else { return }
- LogManager.shared.log(category: .general, message: "[LA] forceRestart called")
- dismissedByUser = false
- Storage.shared.laRenewBy.value = 0
- Storage.shared.laRenewalFailed.value = false
- LALivenessStore.clear()
- cancelRenewalFailedNotification()
- current = nil
- updateTask?.cancel(); updateTask = nil
- tokenObservationTask?.cancel(); tokenObservationTask = nil
- stateObserverTask?.cancel(); stateObserverTask = nil
- pushToken = nil
- Task {
- for activity in Activity<GlucoseLiveActivityAttributes>.activities {
- await activity.end(nil, dismissalPolicy: .immediate)
- }
- await MainActor.run {
- self.startFromCurrentState(cleanupOrphans: false)
- LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted")
- }
- }
- }
- func startFromCurrentState(cleanupOrphans: Bool = false) {
- guard Storage.shared.laEnabled.value, !dismissedByUser else { return }
- if cleanupOrphans {
- endOrphanedActivities()
- }
- let provider = StorageCurrentGlucoseStateProvider()
- if let snapshot = GlucoseSnapshotBuilder.build(from: provider) {
- LAAppGroupSettings.setThresholds(
- lowMgdl: Storage.shared.lowLine.value,
- highMgdl: Storage.shared.highLine.value,
- )
- LAAppGroupSettings.setDisplayName(
- Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow",
- show: Storage.shared.showDisplayName.value
- )
- GlucoseSnapshotStore.shared.save(snapshot)
- }
- startIfNeeded()
- }
- func refreshFromCurrentState(reason: String) {
- // No LA guard here — Watch and store must update regardless of LA state.
- // LA-specific gating (laEnabled, dismissedByUser) is applied inside performRefresh.
- refreshWorkItem?.cancel()
- let workItem = DispatchWorkItem { [weak self] in
- self?.performRefresh(reason: reason)
- }
- refreshWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem)
- }
- // MARK: - Renewal
- /// Requests a fresh Live Activity to replace the current one when the renewal
- /// deadline has passed, working around Apple's 8-hour maximum LA lifetime.
- /// The new LA is requested FIRST — the old one is only ended if that succeeds,
- /// so the user keeps live data if Activity.request() throws.
- /// Returns true if renewal was performed (caller should return early).
- private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool {
- guard let oldActivity = current else { return false }
- let renewBy = Storage.shared.laRenewBy.value
- guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false }
- let overdueBy = Date().timeIntervalSince1970 - renewBy
- LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA")
- let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
- let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
- // Build the fresh snapshot with showRenewalOverlay: false — the new LA has a
- // fresh deadline so no overlay is needed from the first frame. We pass the
- // deadline as staleDate to ActivityContent below, not to Storage yet; Storage
- // is only updated after Activity.request succeeds so a crash between the two
- // can't leave the deadline permanently stuck in the future.
- let freshSnapshot = snapshot.withRenewalOverlay(false)
- let state = GlucoseLiveActivityAttributes.ContentState(
- snapshot: freshSnapshot,
- seq: seq,
- reason: "renew",
- producedAt: Date(),
- )
- let content = ActivityContent(state: state, staleDate: renewDeadline)
- do {
- let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token)
- Task {
- await oldActivity.end(nil, dismissalPolicy: .immediate)
- }
- updateTask?.cancel()
- updateTask = nil
- tokenObservationTask?.cancel()
- tokenObservationTask = nil
- stateObserverTask?.cancel()
- stateObserverTask = nil
- pushToken = nil
- // Write deadline only on success — avoids a stuck future deadline if we crash
- // between the write and the Activity.request call.
- Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
- bind(to: newActivity, logReason: "renew")
- Storage.shared.laRenewalFailed.value = false
- cancelRenewalFailedNotification()
- GlucoseSnapshotStore.shared.save(freshSnapshot)
- LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)")
- return true
- } catch {
- // Renewal failed — deadline was never written, so no rollback needed.
- let isFirstFailure = !Storage.shared.laRenewalFailed.value
- Storage.shared.laRenewalFailed.value = true
- LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)")
- if isFirstFailure {
- scheduleRenewalFailedNotification()
- }
- return false
- }
- }
- private func performRefresh(reason: String) {
- let provider = StorageCurrentGlucoseStateProvider()
- guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else {
- return
- }
- LogManager.shared.log(category: .general, message: "[LA] refresh g=\(snapshot.glucose) reason=\(reason)", isDebug: true)
- let fingerprint =
- "g=\(snapshot.glucose) d=\(snapshot.delta) t=\(snapshot.trend.rawValue) " +
- "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " +
- "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)"
- LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true)
- // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so.
- if renewIfNeeded(snapshot: snapshot) { return }
- if snapshot.showRenewalOverlay {
- LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible")
- }
- let now = Date()
- let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast)
- let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60
- // Capture dedup result BEFORE saving so the store comparison is valid.
- let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot
- // Store + Watch: always update, independent of LA state.
- LAAppGroupSettings.setThresholds(
- lowMgdl: Storage.shared.lowLine.value,
- highMgdl: Storage.shared.highLine.value,
- )
- GlucoseSnapshotStore.shared.save(snapshot)
- // WatchConnectivityManager.shared.send(snapshot: snapshot)
- // LA update: gated on LA being active, snapshot having changed, and activities enabled.
- guard Storage.shared.laEnabled.value, !dismissedByUser else { return }
- guard !snapshotUnchanged || forceRefreshNeeded else { return }
- guard ActivityAuthorizationInfo().areActivitiesEnabled else {
- return
- }
- if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
- bind(to: existing, logReason: "bind-existing")
- }
- if let _ = current {
- update(snapshot: snapshot, reason: reason)
- return
- }
- if isAppVisibleForLiveActivityStart() {
- startIfNeeded()
- if current != nil {
- update(snapshot: snapshot, reason: reason)
- }
- } else {
- LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true)
- }
- }
- private func isAppVisibleForLiveActivityStart() -> Bool {
- let scenes = UIApplication.shared.connectedScenes
- return scenes.contains { $0.activationState == .foregroundActive }
- }
- func update(snapshot: GlucoseSnapshot, reason: String) {
- if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
- bind(to: existing, logReason: "bind-existing")
- }
- guard let activity = current else { return }
- updateTask?.cancel()
- seq += 1
- let nextSeq = seq
- let activityID = activity.id
- let state = GlucoseLiveActivityAttributes.ContentState(
- snapshot: snapshot,
- seq: nextSeq,
- reason: reason,
- producedAt: Date(),
- )
- updateTask = Task { [weak self] in
- guard let self else { return }
- if activity.activityState == .ended || activity.activityState == .dismissed {
- if current?.id == activityID { current = nil }
- return
- }
- let content = ActivityContent(
- state: state,
- staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
- relevanceScore: 100.0,
- )
- if Task.isCancelled { return }
- // Dual-path update strategy:
- // - Foreground: direct ActivityKit update works reliably.
- // - Background: direct update silently fails due to the audio session
- // limitation. APNs self-push is the only reliable delivery path.
- // Both paths are attempted when applicable; APNs is the authoritative
- // background mechanism.
- let isForeground = await MainActor.run {
- UIApplication.shared.applicationState == .active
- }
- if isForeground {
- await activity.update(content)
- }
- if Task.isCancelled { return }
- guard current?.id == activityID else {
- LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding")
- return
- }
- lastUpdateTime = Date()
- LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true)
- if let token = pushToken {
- await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
- }
- }
- }
- // MARK: - Binding / Lifecycle
- /// Ends any Live Activities of this type that are not the one currently tracked.
- /// Called on app launch to clean up cards left behind by a previous crash.
- private func endOrphanedActivities() {
- for activity in Activity<GlucoseLiveActivityAttributes>.activities {
- guard activity.id != current?.id else { continue }
- let orphanID = activity.id
- Task {
- await activity.end(nil, dismissalPolicy: .immediate)
- LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)")
- }
- }
- }
- private func bind(to activity: Activity<GlucoseLiveActivityAttributes>, logReason: String) {
- if current?.id == activity.id { return }
- current = activity
- dismissedByUser = false
- endingForRestart = false
- attachStateObserver(to: activity)
- LogManager.shared.log(category: .general, message: "Live Activity bound id=\(activity.id) (\(logReason))", isDebug: true)
- observePushToken(for: activity)
- }
- private func observePushToken(for activity: Activity<GlucoseLiveActivityAttributes>) {
- tokenObservationTask?.cancel()
- tokenObservationTask = Task {
- for await tokenData in activity.pushTokenUpdates {
- let token = tokenData.map { String(format: "%02x", $0) }.joined()
- self.pushToken = token
- LogManager.shared.log(category: .general, message: "Live Activity push token received", isDebug: true)
- }
- }
- }
- func handleExpiredToken() {
- end()
- // Activity will restart on next BG refresh via refreshFromCurrentState()
- }
- // MARK: - Renewal Notifications
- private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed"
- private func scheduleRenewalFailedNotification() {
- let content = UNMutableNotificationContent()
- content.title = "Live Activity Expiring"
- content.body = "Live Activity will expire soon. Open LoopFollow to restart."
- content.sound = .default
- let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
- let request = UNNotificationRequest(
- identifier: LiveActivityManager.renewalNotificationID,
- content: content,
- trigger: trigger,
- )
- UNUserNotificationCenter.current().add(request) { error in
- if let error {
- LogManager.shared.log(category: .general, message: "[LA] failed to schedule renewal notification: \(error)")
- }
- }
- LogManager.shared.log(category: .general, message: "[LA] renewal failed notification scheduled")
- }
- private func cancelRenewalFailedNotification() {
- let id = LiveActivityManager.renewalNotificationID
- UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id])
- UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
- }
- private func attachStateObserver(to activity: Activity<GlucoseLiveActivityAttributes>) {
- stateObserverTask?.cancel()
- stateObserverTask = Task {
- 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 {
- current = nil
- // Do NOT clear laRenewBy here. Preserving it means handleForeground()
- // can detect the renewal window on the next foreground event and restart
- // automatically — whether the LA ended normally (.ended) or was
- // system-dismissed (.dismissed). laRenewBy is only set to 0 when:
- // • the user explicitly swipes (below) — renewal intent cancelled
- // • a new LA starts (startIfNeeded writes the new deadline)
- // • handleForeground() clears it synchronously before restarting
- // • the user disables LA or calls forceRestart
- LogManager.shared.log(category: .general, message: "[LA] activity cleared id=\(activity.id) state=\(state)", isDebug: true)
- }
- if state == .dismissed {
- // Three possible sources of .dismissed — only the third blocks restart:
- //
- // (a) endingForRestart: our own end() during a planned restart.
- // Must be checked first: handleForeground() clears laRenewalFailed
- // and laRenewBy synchronously before calling end(), so those flags
- // would read as "no problem" even though we initiated the dismissal.
- //
- // (b) iOS system force-dismiss: either laRenewalFailed is set (our 8-hour
- // renewal logic marked it) or the renewal deadline has already passed
- // (laRenewBy > 0 && now >= laRenewBy). In both cases iOS acted, not
- // the user. laRenewBy is preserved so handleForeground() restarts on
- // the next foreground.
- //
- // (c) User decision: the user explicitly swiped the LA away. Block
- // 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.
- 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)")
- 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 {
- // (c) User decision — cancel renewal intent, block auto-restart.
- dismissedByUser = true
- Storage.shared.laRenewBy.value = 0
- LogManager.shared.log(category: .general, message: "[LA] dismissed by USER (renewBy=\(renewBy), now=\(now)) — laRenewBy cleared, auto-restart BLOCKED until forceRestart")
- }
- }
- }
- }
- }
- }
- }
- #endif
- extension Notification.Name {
- /// Posted when the user taps the Live Activity or Dynamic Island.
- /// Observers navigate to the Home or Snoozer tab as appropriate.
- static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground")
- }
|