diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 125ba4e09..b310b2fe3 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -176,8 +176,7 @@ final class LiveActivityManager { @objc private func handleWillResignActive() { guard Storage.shared.laEnabled.value, let activity = current else { return } - refreshWorkItem?.cancel() - refreshWorkItem = nil + refreshCoalescer.cancel() let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } @@ -437,7 +436,10 @@ final class LiveActivityManager { private var lastUpdateTime: Date? private var pushToken: String? private var tokenObservationTask: Task? - private var refreshWorkItem: DispatchWorkItem? + private lazy var refreshCoalescer = RefreshCoalescer(delay: 20) { [weak self] reason in + self?.performRefresh(reason: reason) + } + /// 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 @@ -640,12 +642,11 @@ final class LiveActivityManager { 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) + guard Thread.isMainThread else { + DispatchQueue.main.async { self.refreshFromCurrentState(reason: reason) } + return } - refreshWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem) + refreshCoalescer.request(reason: reason) } // MARK: - Renewal diff --git a/LoopFollow/LiveActivity/RefreshCoalescer.swift b/LoopFollow/LiveActivity/RefreshCoalescer.swift new file mode 100644 index 000000000..85af21c95 --- /dev/null +++ b/LoopFollow/LiveActivity/RefreshCoalescer.swift @@ -0,0 +1,54 @@ +// LoopFollow +// RefreshCoalescer.swift + +import Foundation + +/// Coalesces refresh requests into one trailing call per window. +/// +/// The first request schedules the call; requests that arrive while it is +/// pending join it without moving its deadline. A steady stream of requests +/// therefore flushes once per window. Not thread-safe: use from one queue. +final class RefreshCoalescer { + typealias Scheduler = (TimeInterval, @escaping () -> Void) -> Void + + private let delay: TimeInterval + private let scheduler: Scheduler + private let action: (String) -> Void + private var pendingReasons: [String] = [] + private var generation = 0 + + init( + delay: TimeInterval, + scheduler: @escaping Scheduler = { delay, work in + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + }, + action: @escaping (String) -> Void + ) { + self.delay = delay + self.scheduler = scheduler + self.action = action + } + + var isPending: Bool { !pendingReasons.isEmpty } + + func request(reason: String) { + let wasPending = isPending + if !pendingReasons.contains(reason) { + pendingReasons.append(reason) + } + guard !wasPending else { return } + + let scheduledGeneration = generation + scheduler(delay) { [weak self] in + guard let self, self.generation == scheduledGeneration else { return } + let reason = self.pendingReasons.joined(separator: ",") + self.cancel() + self.action(reason) + } + } + + func cancel() { + pendingReasons.removeAll() + generation += 1 + } +} diff --git a/Tests/RefreshCoalescerTests.swift b/Tests/RefreshCoalescerTests.swift new file mode 100644 index 000000000..046287b08 --- /dev/null +++ b/Tests/RefreshCoalescerTests.swift @@ -0,0 +1,96 @@ +// LoopFollow +// RefreshCoalescerTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct RefreshCoalescerTests { + final class ManualScheduler { + var scheduled: [(delay: TimeInterval, work: () -> Void)] = [] + + func schedule(_ delay: TimeInterval, _ work: @escaping () -> Void) { + scheduled.append((delay, work)) + } + + func runAll() { + let pending = scheduled + scheduled.removeAll() + pending.forEach { $0.work() } + } + } + + @Test("first request schedules one call after the delay") + func firstRequestSchedules() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + + #expect(scheduler.scheduled.count == 1) + #expect(scheduler.scheduled.first?.delay == 20) + #expect(fired.isEmpty) + scheduler.runAll() + #expect(fired == ["bg"]) + #expect(!coalescer.isPending) + } + + @Test("repeated requests while pending keep the original deadline") + func repeatedRequestsDoNotReschedule() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + for _ in 0 ..< 10 { + coalescer.request(reason: "bg") + } + coalescer.request(reason: "overrideChanged") + + #expect(scheduler.scheduled.count == 1) + scheduler.runAll() + #expect(fired == ["bg,overrideChanged"]) + } + + @Test("a request after a flush opens a new window") + func requestAfterFlushSchedulesAgain() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + scheduler.runAll() + coalescer.request(reason: "bg") + + #expect(scheduler.scheduled.count == 1) + scheduler.runAll() + #expect(fired == ["bg", "bg"]) + } + + @Test("cancel drops the pending call") + func cancelDropsPending() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + coalescer.cancel() + #expect(!coalescer.isPending) + scheduler.runAll() + #expect(fired.isEmpty) + } + + @Test("a request after cancel fires only once") + func requestAfterCancelFiresOnce() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + coalescer.cancel() + coalescer.request(reason: "notLooping") + scheduler.runAll() + + #expect(fired == ["notLooping"]) + } +}