Skip to content
Open
Show file tree
Hide file tree
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
17 changes: 9 additions & 8 deletions LoopFollow/LiveActivity/LiveActivityManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -437,7 +436,10 @@ final class LiveActivityManager {
private var lastUpdateTime: Date?
private var pushToken: String?
private var tokenObservationTask: Task<Void, Never>?
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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions LoopFollow/LiveActivity/RefreshCoalescer.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
96 changes: 96 additions & 0 deletions Tests/RefreshCoalescerTests.swift
Original file line number Diff line number Diff line change
@@ -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"])
}
}