diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8b1c217f3..9d5005aa1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v5 - name: Cache SwiftFormat build - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: BuildTools/.build key: ${{ runner.os }}-swiftformat-${{ hashFiles('BuildTools/Package.resolved', 'BuildTools/Package.swift') }} diff --git a/.github/workflows/warn_main_pr.yml b/.github/workflows/warn_main_pr.yml index 7d79ebb53..3f918f8f8 100644 --- a/.github/workflows/warn_main_pr.yml +++ b/.github/workflows/warn_main_pr.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Comment on PR - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | await github.rest.issues.createComment({ diff --git a/Config.xcconfig b/Config.xcconfig index 275e32170..04259f731 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.0 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.0 diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index a08bab90f..8acc2859c 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -17,6 +17,10 @@ final class BGChartInteraction: ObservableObject { /// when the user pans back into history, re-armed when they return to the edge. @Published var followLatest: Bool = true + /// Set once the main chart has done its initial scroll-to-now; kept out of + /// view @State so a chart remount (see BGChartView) keeps the user's place. + var hasInitializedViewport = false + init() { let seconds = Self.visibleSeconds(forScale: Storage.shared.chartScaleX.value) visibleSeconds = seconds @@ -107,6 +111,8 @@ final class BGChartModel: ObservableObject { } @Published var bg: [BGPoint] = [] + /// Scrub timeline on the BG cadence; rebuilt together with `bg`. + private(set) var scrubSlots = BGChartScrubSlots(readingDates: []) @Published var bgRuns: [BGRun] = [] @Published var yesterday: [BGPoint] = [] @Published var prediction: [BGPoint] = [] @@ -153,6 +159,7 @@ final class BGChartModel: ObservableObject { @Published var now: Date = .init() @Published var diaMarkers: [Date] = [] @Published var midnightMarkers: [Date] = [] + @Published var priorDayTimeMarkers: [Date] = [] @Published var thirtyMinMark: Date? = nil @Published var ninetyMinMark: Date? = nil @@ -175,6 +182,7 @@ final class BGChartModel: ObservableObject { @Published var show90Min: Bool = false @Published var showMidnight: Bool = false @Published var smallGraphTreatments: Bool = true + @Published var showPriorDayTime: Bool = false private static let doseFormatter: NumberFormatter = { let nf = NumberFormatter() @@ -407,6 +415,15 @@ final class BGChartModel: ObservableObject { show90Min = Storage.shared.show90MinLine.value showMidnight = Storage.shared.showMidnightLines.value smallGraphTreatments = Storage.shared.smallGraphTreatments.value + showPriorDayTime = Storage.shared.showPriorDayTimeLines.value + + // Advanced-settings visibility toggles. The Nightscout controllers + // collect the data regardless (it also feeds the info rows), so hidden + // kinds are dropped here at render time. + let showBasal = Storage.shared.graphBasal.value + let showBolus = Storage.shared.graphBolus.value + let showCarbs = Storage.shared.graphCarbs.value + let showOtherTreatments = Storage.shared.graphOtherTreatments.value let isLoop = Storage.shared.device.value == "Loop" overrideColor = isLoop ? .green : .purple @@ -418,6 +435,7 @@ final class BGChartModel: ObservableObject { let maxDisplay = globalVariables.maxDisplayGlucose func clampSgv(_ sgv: Int) -> Double { Double(min(max(sgv, minDisplay), maxDisplay)) } + scrubSlots = BGChartScrubSlots(readingDates: vc.bgData.map { Date(timeIntervalSince1970: $0.date) }) bg = vc.bgData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: colorFor($0.sgv, thresholds: thresholds)) } bgRuns = Self.makeRuns(bg) @@ -436,7 +454,7 @@ final class BGChartModel: ObservableObject { cobPrediction = vc.cobPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) } uamPrediction = vc.uamPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) } - let bolusPoints = vc.bolusData.map { + let bolusPoints = (showBolus ? vc.bolusData : []).map { let dose = self.formatDose($0.value) return TreatmentPoint( date: Date(timeIntervalSince1970: $0.date), @@ -446,7 +464,7 @@ final class BGChartModel: ObservableObject { pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" ) } - carbs = Self.spread(vc.carbData.map { + carbs = Self.spread((showCarbs ? vc.carbData : []).map { let grams = Int($0.value) var label = "\(grams)" if $0.absorptionTime > 0, Storage.shared.showAbsorption.value { @@ -460,7 +478,7 @@ final class BGChartModel: ObservableObject { pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" ) }, minGap: Spread.carbGap, maxShift: Spread.carbShift) - let smbPoints = vc.smbData.map { + let smbPoints = (showBolus ? vc.smbData : []).map { let dose = self.formatDose($0.value) return TreatmentPoint( date: Date(timeIntervalSince1970: $0.date), @@ -471,7 +489,7 @@ final class BGChartModel: ObservableObject { ) } (boluses, smbs) = Self.spreadTogether(bolusPoints, smbPoints, minGap: Spread.bolusGap, maxShift: Spread.bolusShift) - bgChecks = vc.bgCheckData.map { + bgChecks = (showOtherTreatments ? vc.bgCheckData : []).map { TreatmentPoint( date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), @@ -480,16 +498,16 @@ final class BGChartModel: ObservableObject { pillText: "BG Check\n\(Localizer.toDisplayUnits(String($0.sgv)))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" ) } - suspends = vc.suspendGraphData.map { + suspends = (showOtherTreatments ? vc.suspendGraphData : []).map { TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Suspend\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))") } - resumes = vc.resumeGraphData.map { + resumes = (showOtherTreatments ? vc.resumeGraphData : []).map { TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Resume\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))") } - sensorStarts = vc.sensorStartGraphData.map { + sensorStarts = (showOtherTreatments ? vc.sensorStartGraphData : []).map { TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Sensor Start\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))") } - notes = vc.noteGraphData.map { + notes = (showOtherTreatments ? vc.noteGraphData : []).map { TreatmentPoint( date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), @@ -499,12 +517,12 @@ final class BGChartModel: ObservableObject { ) } - basalScheduled = vc.basalScheduleData.map { + basalScheduled = (showBasal ? vc.basalScheduleData : []).map { ScheduledBasalPoint(date: Date(timeIntervalSince1970: $0.date), rate: $0.basalRate) } var steps: [BasalStep] = [] - let sortedBasal = vc.basalData.sorted { $0.date < $1.date } + let sortedBasal = (showBasal ? vc.basalData : []).sorted { $0.date < $1.date } for i in 0 ..< sortedBasal.count { let start = sortedBasal[i].date let end = i + 1 < sortedBasal.count @@ -523,7 +541,7 @@ final class BGChartModel: ObservableObject { let yTop = maxBG - 5 let yBottom = maxBG - 25 - overrides = vc.overrideGraphData.map { + overrides = (showOtherTreatments ? vc.overrideGraphData : []).map { let overrideName = $0.reason.trimmingCharacters(in: .whitespacesAndNewlines) let displayName = overrideName.isEmpty ? "Override" : overrideName return BandRect( @@ -535,7 +553,7 @@ final class BGChartModel: ObservableObject { pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" ) } - tempTargets = vc.tempTargetGraphData.map { + tempTargets = (showOtherTreatments ? vc.tempTargetGraphData : []).map { let target = $0.correctionRange.first.map { String($0) } ?? "" // Temp targets render at the BG level they target (±5 mg/dL); // only overrides live in the top strip. @@ -584,6 +602,17 @@ final class BGChartModel: ObservableObject { } return cal }() + + // Mark the current local time on each prior day. Calendar arithmetic + // preserves the displayed time of day across daylight-saving changes. + var priorDayTimes: [Date] = [] + var priorDayTime = calendar.date(byAdding: .day, value: -1, to: currentNow) + while let marker = priorDayTime, marker > domainStart { + priorDayTimes.append(marker) + priorDayTime = calendar.date(byAdding: .day, value: -1, to: marker) + } + priorDayTimeMarkers = priorDayTimes + var cursor = calendar.startOfDay(for: domainStart) while cursor <= domainEnd { if cursor >= domainStart { diff --git a/LoopFollow/Charts/BGChartScrubSlots.swift b/LoopFollow/Charts/BGChartScrubSlots.swift new file mode 100644 index 000000000..6bd1e9887 --- /dev/null +++ b/LoopFollow/Charts/BGChartScrubSlots.swift @@ -0,0 +1,148 @@ +// LoopFollow +// BGChartScrubSlots.swift + +import Foundation + +/// Five-minute grid, phase-locked to the readings, that the scrub indicator +/// snaps to. +/// +/// The grid is walked backwards from the newest reading. Each step lands on +/// the reading nearest the expected mark when one lies within half a cadence +/// of it, and on a virtual mark otherwise, so drift is absorbed and gaps are +/// crossed at exactly the cadence. The grid continues before the first and +/// after the last mark. Each mark owns the time between the midpoints to its +/// neighbours, so the blocks tile the timeline and every reading and +/// treatment belongs to exactly one mark. +struct BGChartScrubSlots { + struct Slot: Equatable { + let date: Date + /// Index into `readingDates` for a mark placed on a reading; nil for a + /// virtual mark. + let readingIndex: Int? + /// Owned time, inclusive at the start and exclusive at the end. + let blockStart: Date + let blockEnd: Date + + var isReading: Bool { readingIndex != nil } + + func contains(_ date: Date) -> Bool { + date >= blockStart && date < blockEnd + } + } + + static let cadence: TimeInterval = 5 * 60 + + /// Every reading timestamp, ascending. + let readingDates: [Date] + /// Grid marks inside the data range, ascending. + private let slotDates: [Date] + private let slotReadingIndices: [Int?] + + init(readingDates: [Date]) { + let sorted = readingDates.sorted() + self.readingDates = sorted + + var dates: [Date] = [] + var indices: [Int?] = [] + if let first = sorted.first, let newest = sorted.last { + let cadence = Self.cadence + var cursor = newest + var cursorIndex = sorted.count - 1 + dates.append(cursor) + indices.append(cursorIndex) + while first < cursor.addingTimeInterval(-cadence / 2) { + let target = cursor.addingTimeInterval(-cadence) + if let hit = Self.nearestReading(in: sorted, before: cursorIndex, to: target, within: cadence / 2) { + cursor = sorted[hit] + cursorIndex = hit + dates.append(cursor) + indices.append(hit) + } else { + cursor = target + dates.append(cursor) + indices.append(nil) + } + } + dates.reverse() + indices.reverse() + } + slotDates = dates + slotReadingIndices = indices + } + + /// Index of the reading nearest `target` among those strictly before index + /// `limit`, if it lies within `tolerance` of the target. + private static func nearestReading(in sorted: [Date], before limit: Int, to target: Date, within tolerance: TimeInterval) -> Int? { + guard limit > 0 else { return nil } + // First index in 0 ..< limit whose date is >= target. + var low = 0 + var high = limit + while low < high { + let mid = (low + high) / 2 + if sorted[mid] < target { low = mid + 1 } else { high = mid } + } + var best: Int? + for candidate in [low - 1, low] where candidate >= 0 && candidate < limit { + let distance = abs(sorted[candidate].timeIntervalSince(target)) + if distance <= tolerance, best.map({ distance < abs(sorted[$0].timeIntervalSince(target)) }) ?? true { + best = candidate + } + } + return best + } + + /// The mark whose block contains `date`. + func slot(containing date: Date) -> Slot { + let cadence = Self.cadence + guard let firstSlot = slotDates.first, let lastSlot = slotDates.last else { + return virtualSlot(origin: Date(timeIntervalSince1970: 0), nearestTo: date) + } + if date < firstSlot.addingTimeInterval(-cadence / 2) { + return virtualSlot(origin: firstSlot, nearestTo: date) + } + if date >= lastSlot.addingTimeInterval(cadence / 2) { + return virtualSlot(origin: lastSlot, nearestTo: date) + } + + // Index of the last mark at or before `date`; the midpoint to its + // successor decides between the two. + var low = 0 + var high = slotDates.count - 1 + while low < high { + let mid = (low + high + 1) / 2 + if slotDates[mid] <= date { low = mid } else { high = mid - 1 } + } + var i = low + if date < firstSlot { + i = 0 + } else if i + 1 < slotDates.count, date >= midpoint(slotDates[i], slotDates[i + 1]) { + i += 1 + } + return gridSlot(at: i) + } + + private func gridSlot(at i: Int) -> Slot { + let cadence = Self.cadence + let date = slotDates[i] + let blockStart = i > 0 ? midpoint(slotDates[i - 1], date) : date.addingTimeInterval(-cadence / 2) + let blockEnd = i + 1 < slotDates.count ? midpoint(date, slotDates[i + 1]) : date.addingTimeInterval(cadence / 2) + return Slot(date: date, readingIndex: slotReadingIndices[i], blockStart: blockStart, blockEnd: blockEnd) + } + + /// Virtual mark on the grid anchored at `origin`; a halfway date belongs + /// to the later mark, matching the half-open blocks. + private func virtualSlot(origin: Date, nearestTo date: Date) -> Slot { + let cadence = Self.cadence + let k = (date.timeIntervalSince(origin) / cadence + 0.5).rounded(.down) + return Slot( + date: origin.addingTimeInterval(k * cadence), + readingIndex: nil, + blockStart: origin.addingTimeInterval((k - 0.5) * cadence), + blockEnd: origin.addingTimeInterval((k + 0.5) * cadence) + ) + } + + private func midpoint(_ a: Date, _ b: Date) -> Date { + a.addingTimeInterval(b.timeIntervalSince(a) / 2) + } +} diff --git a/LoopFollow/Charts/BGChartStubs.swift b/LoopFollow/Charts/BGChartStubs.swift index ba8714a82..700821a66 100644 --- a/LoopFollow/Charts/BGChartStubs.swift +++ b/LoopFollow/Charts/BGChartStubs.swift @@ -22,6 +22,15 @@ extension MainViewController { func updateBGGraphSettings() { chartModel.rebuild() + + // Re-route the stored predBGs in case the prediction style + // (cone/lines) changed; rebuild() alone only redraws what was + // already routed. + updateOpenAPSPredictionDisplay() + + // The yesterday overlay is built during the BG fetch and needs an + // extra day of history, so reload the BG window when it's toggled. + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date()) } private func recomputeTopBG() { diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index e7aa43b3c..74f9ec03e 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -44,15 +44,6 @@ private enum BGChartConfig { /// How long after the last navigation in history before a data tick pulls /// the chart back to "now". static let autoFollowPause: TimeInterval = 5 * 60 - /// Max distance between the scrub date and an anchor for it to be selected. - static let selectionTolerance: TimeInterval = 20 * 60 - /// Half-width (pt) of the scrub capture band: treatments whose symbol is - /// within this screen distance of the finger join the pill alongside the - /// (ever-present) nearest BG reading. - static let scrubCaptureRadius: CGFloat = 22 - /// Time cap on the capture band, so wide zooms — where a finger-width - /// covers hours — don't sweep far-away treatments into the pill. - static let scrubCaptureMaxSeconds: TimeInterval = 5 * 60 /// Screen-space radius (pt) within which a tap selects a mark. static let tapHitRadius: CGFloat = 30 } @@ -74,11 +65,22 @@ struct BGChartView: View { let model: BGChartModel let config: Config + /// Remount key. A system-cancelled touch can wedge SwiftUI's gesture graph + /// for this subtree; bumping this on foregrounding rebuilds the gesture + /// attachments while BGChartInteraction preserves the viewport. + @State private var gestureMountEpoch = 0 + var body: some View { - if config == .small { - SmallBGChart(model: model, interaction: model.interaction) - } else { - MainBGChart(model: model, interaction: model.interaction) + Group { + if config == .small { + SmallBGChart(model: model, interaction: model.interaction) + } else { + MainBGChart(model: model, interaction: model.interaction) + } + } + .id(gestureMountEpoch) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + gestureMountEpoch &+= 1 } } } @@ -102,13 +104,21 @@ private struct MainBGChart: View { @ObservedObject var model: BGChartModel @ObservedObject var interaction: BGChartInteraction - @State private var didInitialize = false - /// Rendered slice of the domain. The canvas covers only this window /// (visible ± `renderWindowPadFactor` viewports), bounding canvas width /// and per-layout cost no matter how long the data domain grows. - @State private var renderWindowStart = Date() - @State private var renderWindowEnd = Date().addingTimeInterval(3600) + @State private var renderWindowStart: Date + @State private var renderWindowEnd: Date + + init(model: BGChartModel, interaction: BGChartInteraction) { + _model = ObservedObject(wrappedValue: model) + _interaction = ObservedObject(wrappedValue: interaction) + // Seed the render window around the current viewport so a remount's + // first frame draws in place. + let pad = BGChartConfig.renderWindowPadFactor * interaction.visibleSeconds + _renderWindowStart = State(initialValue: interaction.scrollPosition.addingTimeInterval(-pad)) + _renderWindowEnd = State(initialValue: interaction.scrollPosition.addingTimeInterval(interaction.visibleSeconds + pad)) + } /// Plot area of the static axis overlay, in shell coordinates. The /// selection overlay uses it for its value-to-pixel maps. @@ -265,15 +275,19 @@ private struct MainBGChart: View { updateRenderWindow() } .onAppear { - if !didInitialize { - didInitialize = true + if !interaction.hasInitializedViewport { + interaction.hasInitializedViewport = true scrollToNow(animated: false) - updateRenderWindow(force: true) + } else if !interaction.followLatest { + // Remounted while in history: re-arm the auto-return pause. + autoFollowPausedUntil = Date().addingTimeInterval(BGChartConfig.autoFollowPause) } + updateRenderWindow(force: true) } .onDisappear { momentumTask?.cancel() - inspectHoldTask?.cancel() + momentumTask = nil + resetGestureState() } } @@ -394,6 +408,13 @@ private struct MainBGChart: View { .onChanged { value in momentumTask?.cancel() momentumTask = nil + // A touch's first event has zero translation and precedes any + // pinch, so gesture state still set here was leaked by a + // system-cancelled touch (its onEnded never fired) and would + // swallow this and every later touch. + if value.translation == .zero, hasLeakedGestureState { + resetGestureState() + } guard !isPinching else { inspectHoldTask?.cancel() if selection != nil { selection = nil } @@ -454,6 +475,24 @@ private struct MainBGChart: View { } } + private var hasLeakedGestureState: Bool { + pinchAnchor != nil || pinchScale != 1 || isInspectLatched + || touchDownTime != nil || panBaseline != nil || selection != nil + } + + private func resetGestureState() { + inspectHoldTask?.cancel() + inspectHoldTask = nil + pinchAnchor = nil + pinchScale = 1 + isInspectLatched = false + touchDownTime = nil + panBaseline = nil + selection = nil + lastTouchLocation = nil + lastHapticAnchorDate = nil + } + /// Arms the inspect hold: after `inspectHoldDelay`, if the touch is still /// down and has neither become a pan nor a pinch, latch into inspect mode /// at the finger's last known position — with a haptic so the mode change is felt. @@ -484,9 +523,8 @@ private struct MainBGChart: View { interaction.visibleSeconds * TimeInterval(fraction) ) selection = date - // A featherlight tick whenever the indicator snaps to a different item. - let captureWindow = scrubCaptureWindow(viewportWidth: viewportWidth) - if let anchor = selectionAnchor(for: date, captureWindow: captureWindow), anchor.date != lastHapticAnchorDate { + // A featherlight tick whenever the indicator snaps to a different slot. + if let anchor = selectionAnchor(for: date), anchor.date != lastHapticAnchorDate { lastHapticAnchorDate = anchor.date scrubHaptic.selectionChanged() scrubHaptic.prepare() @@ -640,14 +678,14 @@ private struct MainBGChart: View { let texts: [String] } - /// Feeds every treatment mark to `body` as (drawnDate, value, pillText). - /// Single source for both the scrub lookup and the tap hit test. - private func forEachTreatmentAnchor(_ body: (Date, Double, String) -> Void) { + /// Feeds every treatment mark to `body`. Single source for both the scrub + /// lookup and the tap hit test. + private func forEachTreatmentAnchor(_ body: (BGChartModel.TreatmentPoint) -> Void) { for group in [model.boluses, model.carbs, model.smbs, model.bgChecks, model.notes, model.suspends, model.resumes, model.sensorStarts] { for t in group { - body(t.drawnDate, t.sgv, t.pillText) + body(t) } } } @@ -691,76 +729,45 @@ private struct MainBGChart: View { return nil } - /// Seconds of chart time covered by `scrubCaptureRadius` at the current - /// zoom, bounded by `scrubCaptureMaxSeconds`. - private func scrubCaptureWindow(viewportWidth: CGFloat) -> TimeInterval { - min( - BGChartConfig.scrubCaptureMaxSeconds, - TimeInterval(BGChartConfig.scrubCaptureRadius / viewportWidth) * interaction.visibleSeconds - ) - } - - /// Scrub lookup (time-only). Collects everything under the finger instead - /// of picking a single winner: every treatment inside the capture window - /// joins the pill, and the nearest BG reading always does — so treatments - /// and glucose readings can never hide one another. The indicator snaps - /// to the nearest collected item; the pill stacks them all (treatments in - /// drawn order, BG last). - private func selectionAnchor(for selected: Date, captureWindow: TimeInterval) -> SelectionAnchor? { - struct Item { - let date: Date - let value: Double - let text: String - let distance: TimeInterval - } - - var captured: [Item] = [] - var nearestTreatment: Item? - forEachTreatmentAnchor { date, value, text in - let item = Item(date: date, value: value, text: text, distance: abs(date.timeIntervalSince(selected))) - if item.distance <= captureWindow { - captured.append(item) + /// Scrub lookup (time-only). The finger resolves to the grid mark whose + /// block contains the scrub time (see BGChartScrubSlots); the indicator + /// stands on the mark. The pill stacks every treatment in the block, then + /// every BG reading in it, then any band at the mark, so it is constant + /// across the block. The indicator's height comes from the reading nearest + /// the mark, else the nearest treatment, else the band; an empty block + /// shows nothing. + private func selectionAnchor(for selected: Date) -> SelectionAnchor? { + let slot = model.scrubSlots.slot(containing: selected) + let mark = slot.date + + var treatments: [BGChartModel.TreatmentPoint] = [] + forEachTreatmentAnchor { t in + if slot.contains(t.date) { treatments.append(t) } + } + treatments.sort { $0.date < $1.date } + let readings = model.bg.filter { slot.contains($0.date) } + + var texts = treatments.map(\.pillText) + readings.map(bgPillText) + let bandTexts = bandPillTexts(at: mark) + texts += bandTexts + + func distanceToMark(_ date: Date) -> TimeInterval { abs(date.timeIntervalSince(mark)) } + + var value: Double? + if let reading = readings.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { + value = reading.value + } else if let nearest = treatments.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { + value = nearest.sgv + } else if !bandTexts.isEmpty { + if let band = model.overrides.first(where: { mark >= $0.start && mark <= $0.end }) + ?? model.tempTargets.first(where: { mark >= $0.start && mark <= $0.end }) + { + value = (band.yTop + band.yBottom) / 2 } - if item.distance < (nearestTreatment?.distance ?? .greatestFiniteMagnitude) { - nearestTreatment = item - } - } - captured.sort { $0.date < $1.date } - - var nearestBG: Item? - for p in model.bg { - let d = abs(p.date.timeIntervalSince(selected)) - if d < (nearestBG?.distance ?? .greatestFiniteMagnitude) { - nearestBG = Item(date: p.date, value: p.value, text: bgPillText(for: p), distance: d) - } - } - - var items = captured - if let nearestBG, nearestBG.distance <= BGChartConfig.selectionTolerance { - items.append(nearestBG) - } - if let primary = items.min(by: { $0.distance < $1.distance }) { - let texts = items.map(\.text) + bandPillTexts(at: selected) - return SelectionAnchor(date: primary.date, value: primary.value, texts: texts) - } - - // Nothing under the finger. Reach for the nearest treatment (data gaps - // leave treatments without BG neighbors), then for a band (any height) - // at the scrub time. - if let nearestTreatment, nearestTreatment.distance <= BGChartConfig.selectionTolerance { - let texts = [nearestTreatment.text] + bandPillTexts(at: selected) - return SelectionAnchor(date: nearestTreatment.date, value: nearestTreatment.value, texts: texts) - } - for band in model.overrides where selected >= band.start && selected <= band.end { - let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: selected, value: midY, texts: [band.pillText]) - } - for band in model.tempTargets where selected >= band.start && selected <= band.end { - let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: selected, value: midY, texts: [band.pillText]) } - return nil + guard let value else { return nil } + return SelectionAnchor(date: mark, value: value, texts: texts) } /// Tap hit test (screen-space, 2D). Treatments take priority, then BG @@ -781,7 +788,7 @@ private struct MainBGChart: View { } } - forEachTreatmentAnchor(consider) + forEachTreatmentAnchor { consider($0.drawnDate, $0.sgv, $0.pillText) } if best == nil { for p in model.bg { consider(p.date, p.value, bgPillText(for: p)) @@ -806,9 +813,9 @@ private struct MainBGChart: View { } /// The anchor the overlay should show: a live scrub wins over a sticky tap. - private func activeAnchor(viewportWidth: CGFloat) -> SelectionAnchor? { + private func activeAnchor() -> SelectionAnchor? { if isInspectLatched, let selected = selection { - return selectionAnchor(for: selected, captureWindow: scrubCaptureWindow(viewportWidth: viewportWidth)) + return selectionAnchor(for: selected) } return tapped } @@ -874,7 +881,7 @@ private struct MainBGChart: View { /// there is no manual line splitting. @ViewBuilder private func selectionOverlay(viewportWidth: CGFloat) -> some View { - if plotFrame.height > 0, let anchor = activeAnchor(viewportWidth: viewportWidth) { + if plotFrame.height > 0, let anchor = activeAnchor() { let x = xPosition(for: anchor.date, viewportWidth: viewportWidth) if x >= 0, x <= viewportWidth { let y = yPosition(forValue: anchor.value) @@ -936,6 +943,11 @@ private struct SmallBGChart: View { .gesture( DragGesture(minimumDistance: 0) .onChanged { value in + // Zero translation = fresh touch; clear a scrub flag + // leaked by a system-cancelled touch. + if value.translation == .zero { + isScrubbing = false + } let distance = hypot(value.translation.width, value.translation.height) if isScrubbing || distance >= BGChartConfig.inspectMovementTolerance { isScrubbing = true @@ -1042,6 +1054,9 @@ private struct BGChartCanvas: View, Equatable { if showTreatments { treatmentMarks } + if model.showPriorDayTime { + priorDayTimeRuleMarks + } if !isSmall { ruleMarks } else if model.showMidnight { @@ -1409,6 +1424,15 @@ private struct BGChartCanvas: View, Equatable { } } + @ChartContentBuilder + private var priorDayTimeRuleMarks: some ChartContent { + ForEach(model.priorDayTimeMarkers.filter { $0 >= windowStart && $0 <= windowEnd }, id: \.self) { d in + RuleMark(x: .value("same time on prior day", d)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: isSmall ? [2, 2] : [2, 5])) + .foregroundStyle(Color.orange.opacity(isSmall ? 1 : 0.5)) + } + } + @ChartContentBuilder private var ruleMarks: some ChartContent { RuleMark(y: .value("low", model.lowLine)) diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index 8b844c983..b4cd22680 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -64,14 +64,14 @@ class BackgroundAlertManager { func scheduleBackgroundAlert(force: Bool = false) { guard isAlertScheduled, Storage.shared.backgroundRefreshType.value != .none else { return } - // Throttle execution if not forced: only run once every 10 seconds. - if !force { - let now = Date() - if let lastDate = lastScheduleDate, now.timeIntervalSince(lastDate) < 10 { - return - } - lastScheduleDate = now + // Throttle execution if not forced: only run once every 10 seconds. A forced + // run stamps the date too, so the next tick doesn't immediately repeat the + // remove-and-re-add it just performed. + let now = Date() + if !force, let lastDate = lastScheduleDate, now.timeIntervalSince(lastDate) < 10 { + return } + lastScheduleDate = now removeDeliveredNotifications() diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 89126e6b1..4cd3a65a1 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -112,16 +112,31 @@ extension MainViewController { Storage.shared.lastLoopTime.value = lastPumpTime } - if let reservoirData = lastPumpRecord["reservoir"] as? Double { - latestPumpVolume = reservoirData - infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U", numericValue: reservoirData) - Storage.shared.lastPumpReservoirU.value = reservoirData - } else { + let reservoir = PumpReservoirResolver.resolve( + reservoir: lastPumpRecord["reservoir"] as? Double, + pumpID: lastPumpRecord["pumpID"] as? String, + manufacturer: lastPumpRecord["manufacturer"] as? String, + model: lastPumpRecord["model"] as? String, + cache: Storage.shared.pumpReservoirCache.value, + now: Date() + ) + Storage.shared.pumpReservoirCache.value = reservoir.cache + + switch reservoir.state { + case let .units(units): + latestPumpVolume = units + infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", units) + "U", numericValue: units) + Storage.shared.lastPumpReservoirU.value = units + case .aboveReportingLimit: // Pumps that only report "50+" get treated as exactly 50, both // for the volume alarm and for the info row's coloring. latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0) Storage.shared.lastPumpReservoirU.value = nil + case .unknown: + // The row stays cleared, which the info table renders as an em dash. + latestPumpVolume = nil + Storage.shared.lastPumpReservoirU.value = nil } } diff --git a/LoopFollow/Controllers/Nightscout/PumpReservoir.swift b/LoopFollow/Controllers/Nightscout/PumpReservoir.swift new file mode 100644 index 000000000..ad843a296 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/PumpReservoir.swift @@ -0,0 +1,104 @@ +// LoopFollow +// PumpReservoir.swift + +import Foundation + +/// What is known about one pump's reservoir, carried between device status records. +struct PumpReservoirCache: Codable, Equatable { + struct Reading: Codable, Equatable { + let units: Double + let date: Date + } + + let pumpID: String + /// When this pump first appeared in a device status record LoopFollow fetched. + let pumpSince: Date + var reading: Reading? +} + +/// What a device status record says about the reservoir. +enum PumpReservoirState: Equatable { + /// An exact volume, from the record itself or from a recent reading for the same pump. + case units(Double) + /// A pump that reports a volume only once it drops below 50U, and is above it. + case aboveReportingLimit + /// No volume to show. + case unknown +} + +enum PumpReservoirResolver { + /// Omnipod reports the reservoir in only some of the device status records it uploads + /// while the pod is below 50U. A reading carries across those gaps for this long. + static let maxReadingAge: TimeInterval = 30 * 60 + + /// A volume reported within this long of a pump first appearing can still be the + /// previous pod's final reading, so it says nothing about the pump now on. + static let pumpSettleTime: TimeInterval = 15 * 60 + + struct Resolution: Equatable { + let state: PumpReservoirState + /// What to keep for the next record, `nil` to store nothing. + let cache: PumpReservoirCache? + } + + static func resolve( + reservoir: Double?, + pumpID: String?, + manufacturer: String?, + model: String?, + cache storedCache: PumpReservoirCache?, + now: Date + ) -> Resolution { + let withoutReading: PumpReservoirState = reportsVolumeOnlyWhenLow(manufacturer: manufacturer, model: model) + ? .aboveReportingLimit + : .unknown + + // A reading is carried between records only when the uploader names the pump it + // came from, so that a pod change discards it. + guard let pumpID = identifiedPump(pumpID) else { + guard let reservoir else { return Resolution(state: withoutReading, cache: nil) } + return Resolution(state: .units(reservoir), cache: nil) + } + + var cache = storedCache?.pumpID == pumpID + ? storedCache! + : PumpReservoirCache(pumpID: pumpID, pumpSince: now, reading: nil) + + if let reservoir { + cache.reading = PumpReservoirCache.Reading(units: reservoir, date: now) + return Resolution(state: .units(reservoir), cache: cache) + } + + if let reading = cache.reading { + let age = now.timeIntervalSince(reading.date) + if age >= 0, age <= maxReadingAge { + return Resolution(state: .units(reading.units), cache: cache) + } + if reading.date.timeIntervalSince(cache.pumpSince) >= pumpSettleTime { + // The pump has reported a volume long enough after coming online for that + // to be its own, so it is below its reporting limit and the only thing + // missing is a fresh number. A pump first seen mid-pod has no such gap, + // so its first reading is treated as a pod change's and dropped below. + return Resolution(state: .unknown, cache: cache) + } + cache.reading = nil + } + + return Resolution(state: withoutReading, cache: cache) + } + + private static func identifiedPump(_ pumpID: String?) -> String? { + let trimmed = pumpID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // Loop reports "Unknown" while no pod is paired. + guard !trimmed.isEmpty, trimmed != "Unknown" else { return nil } + return trimmed + } + + private static func reportsVolumeOnlyWhenLow(manufacturer: String?, model: String?) -> Bool { + let pump = [manufacturer, model].compactMap { $0 }.joined(separator: " ").lowercased() + // An uploader that names no pump cannot be told apart from one that reports its + // volume only when low, so it gets the same reading. + guard !pump.isEmpty else { return true } + return pump.contains("insulet") || pump.contains("omnipod") || pump.contains("dash") + } +} diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index b628737b4..14e9e391b 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -4,6 +4,39 @@ import Foundation extension MainViewController { + private struct TreatmentOccurrence: Hashable { + let id: String + let eventType: String? + let date: Date? + } + + /// Nightscout duplicates can have different MongoDB `_id` values. Trio FPU siblings share an `id` but have distinct + /// times, so deduplicate by logical occurrence rather than `id`. + static func deduplicatedTreatmentEntries(_ entries: [[String: AnyObject]]) -> [[String: AnyObject]] { + var seenOccurrences = Set() + return entries.filter { entry in + guard let id = entry["id"] as? String, !id.isEmpty else { return true } + let eventType = entry["eventType"] as? String + let occurrence = TreatmentOccurrence( + id: id, + eventType: eventType, + date: treatmentOccurrenceDate(entry, eventType: eventType) + ) + return seenOccurrences.insert(occurrence).inserted + } + } + + private static func treatmentOccurrenceDate(_ entry: [String: AnyObject], eventType: String?) -> Date? { + let rawDate: String? + switch eventType { + case "Pump Site Change", "Site Change", "Sensor Start", "Insulin Change": + rawDate = entry["created_at"] as? String + default: + rawDate = (entry["timestamp"] as? String) ?? (entry["created_at"] as? String) + } + return rawDate.flatMap(NightscoutUtils.parseDate) + } + // NS Treatments Web Call // Downloads Basal, Bolus, Carbs, BG Check, Notes, Overrides func WebLoadNSTreatments() { @@ -35,12 +68,7 @@ extension MainViewController { // Process and split out treatments to individual tasks func updateTreatments(entries: [[String: AnyObject]]) { - // Deduplicate entries by "id" field (Trio/Loop UUID) - var seenIDs = Set() - let uniqueEntries = entries.filter { entry in - guard let id = entry["id"] as? String else { return true } - return seenIDs.insert(id).inserted - } + let uniqueEntries = Self.deduplicatedTreatmentEntries(entries) var tempBasal: [[String: AnyObject]] = [] var bolus: [[String: AnyObject]] = [] diff --git a/LoopFollow/Controllers/SpeakBGIntents.swift b/LoopFollow/Controllers/SpeakBGIntents.swift new file mode 100644 index 000000000..995df2071 --- /dev/null +++ b/LoopFollow/Controllers/SpeakBGIntents.swift @@ -0,0 +1,49 @@ +// LoopFollow +// SpeakBGIntents.swift + +import AppIntents + +struct EnableSpeakBGIntent: AppIntent { + static var title: LocalizedStringResource = "Turn On Speak BG" + static var description = IntentDescription("Turns on Speak BG so LoopFollow reads glucose values aloud.") + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try SpeakBGIntentSupport.set(true) + return .result(dialog: "Speak BG is now on.") + } +} + +struct DisableSpeakBGIntent: AppIntent { + static var title: LocalizedStringResource = "Turn Off Speak BG" + static var description = IntentDescription("Turns off Speak BG so LoopFollow stops reading glucose values aloud.") + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try SpeakBGIntentSupport.set(false) + return .result(dialog: "Speak BG is now off.") + } +} + +enum SpeakBGIntentSupport { + /// Storage writes are memory-only during a suspected before-first-unlock + /// launch, so the change would be lost on hydration. + @MainActor + static func set(_ enabled: Bool) throws { + guard !StorageReadiness.isSuppressingWrites else { + throw SpeakBGIntentError.storageUnavailable + } + Storage.shared.speakBG.value = enabled + } +} + +enum SpeakBGIntentError: Error, CustomLocalizedStringResourceConvertible { + case storageUnavailable + + var localizedStringResource: LocalizedStringResource { + switch self { + case .storageUnavailable: + return "LoopFollow can't change Speak BG until your iPhone has been unlocked once after restarting." + } + } +} diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index ab2b42e67..b1a6427b6 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -3,6 +3,7 @@ import BackgroundTasks import Foundation +import UIKit class BackgroundRefreshManager { static let shared = BackgroundRefreshManager() @@ -10,6 +11,18 @@ class BackgroundRefreshManager { private let taskIdentifier = "\(Bundle.main.bundleIdentifier ?? "com.loopfollow").audiorefresh" + /// Spacing for the routine health check. iOS treats this as a floor and + /// schedules on its own budget, so the effective interval is longer. + private let refreshInterval: TimeInterval = 15 * 60 + + /// Serialises the read-modify-write around the pending request, so a routine + /// request can't land on top of an immediate one. + private let queue = DispatchQueue(label: "com.LoopFollow.BackgroundRefreshQueue") + + /// True while the pending request asks for the earliest window iOS will give. + /// Guarded by `queue`. + private var immediateRequested = false + func register() { BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in guard let refreshTask = task as? BGAppRefreshTask else { return } @@ -20,47 +33,147 @@ class BackgroundRefreshManager { private func handleRefreshTask(_ task: BGAppRefreshTask) { LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask fired") - // Guard against double setTaskCompleted if expiration fires while the - // main-queue block is in-flight (Apple documents this as a programming error). + // Guard against double setTaskCompleted (Apple documents this as a programming + // error). The restart below keeps the task open for seconds, so expiration and + // the main-queue block genuinely race for the flag and it needs a lock. + let lock = NSLock() var completed = false + let claim: () -> Bool = { + lock.lock() + defer { lock.unlock() } + guard !completed else { return false } + completed = true + return true + } + let complete: (Bool) -> Void = { success in + guard claim() else { return } + task.setTaskCompleted(success: success) + } task.expirationHandler = { - guard !completed else { return } - completed = true LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask expired") - task.setTaskCompleted(success: false) - self.scheduleRefresh() + complete(false) + } + + // This task exists only to revive the Silent Tune keep-alive. Reading the mode + // is safe before storage is confirmed readable: the default is `.silentTune`, + // so an unhydrated read keeps the check armed rather than cancelling it. + guard !StorageReadiness.ready.value || Storage.shared.backgroundRefreshType.value == .silentTune else { + LogManager.shared.log(category: .taskScheduler, message: "Background refresh no longer needed for the current mode; cancelling") + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier) + queue.async { self.immediateRequested = false } + complete(true) + return + } + + // Queue the successor before doing any work, so an early expiration or a + // crash still leaves a pending request behind. + queue.async { + self.immediateRequested = false + self.submit(earliestBeginDate: Date(timeIntervalSinceNow: self.refreshInterval)) } DispatchQueue.main.async { - guard !completed else { return } - completed = true - if let mainVC = self.getMainViewController() { - if !mainVC.backgroundTask.player.isPlaying { - LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart") - mainVC.backgroundTask.stopBackgroundTask() - mainVC.backgroundTask.startBackgroundTask() - LogManager.shared.log(category: .taskScheduler, message: "audio restart initiated") - } else { - LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed", isDebug: true) + guard let backgroundTask = MainViewController.shared?.backgroundTask else { + LogManager.shared.log(category: .taskScheduler, message: "No main view controller yet; nothing to check") + complete(true) + return + } + guard !backgroundTask.isPlaying else { + // Full level: `.taskScheduler` debug lines are dropped before the file + // write, and a healthy check should leave a trace of its own. + LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed") + self.armBackgroundAlerts() + TaskScheduler.shared.checkTasksNow() + complete(true) + return + } + + LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart") + // The task must stay open until the restart resolves: completing it here + // lets iOS suspend the app, and a pending retry would then not run until + // something else resumes the process — minutes or hours later. + backgroundTask.restartAudio(reason: "BGAppRefreshTask") { success in + LogManager.shared.log( + category: .taskScheduler, + message: success ? "audio restart succeeded" : "audio restart failed" + ) + // Only on success: a failed restart means suspension is imminent, and + // dispatching fetches that cannot finish helps nothing. + if success { + self.armBackgroundAlerts() + TaskScheduler.shared.checkTasksNow() } + complete(success) } - self.scheduleRefresh() - task.setTaskCompleted(success: true) } } + /// Clears any delivered "App inactive" notification and re-arms the 6/12/18 minute + /// alerts from this moment. A process launched into the background never ran + /// `appMovedToBackground`, so this is the only place its alerts are armed. + private func armBackgroundAlerts() { + // The task fires while backgrounded, but its work lands on the main queue and + // the user may have opened the app in between. Alerts belong only to a + // backgrounded app. + guard UIApplication.shared.applicationState == .background else { return } + BackgroundAlertManager.shared.startBackgroundAlert() + } + + /// Requests the routine health check, leaving an existing pending request alone + /// when it would run at least as soon. Every background transition calls this, so + /// the earliest pending request is the one that survives. func scheduleRefresh() { + let desired = Date(timeIntervalSinceNow: refreshInterval) + BGTaskScheduler.shared.getPendingTaskRequests { [weak self] pending in + guard let self else { return } + self.queue.async { + // Category `.general`: LogManager drops `.taskScheduler` debug lines + // before the file write, and these belong in a shared log. + guard !self.immediateRequested else { + LogManager.shared.log(category: .general, message: "Keeping the pending immediate refresh request", isDebug: true) + return + } + if let existing = pending.first(where: { $0.identifier == self.taskIdentifier }) { + guard let existingDate = existing.earliestBeginDate else { return } + guard existingDate > desired else { + LogManager.shared.log(category: .general, message: "Refresh already pending at \(existingDate); leaving it", isDebug: true) + return + } + } + self.submit(earliestBeginDate: desired) + } + } + } + + /// Requests the earliest window iOS is willing to give, used when the audio + /// keep-alive has been lost and a background refresh is the only route back to + /// running code. + func scheduleImmediateRefresh() { + queue.async { + // The flag tracks what is actually pending. A submit that throws — as it + // does when Background App Refresh is switched off — must not leave the + // routine check suppressed behind a request that was never accepted. + self.immediateRequested = self.submit(earliestBeginDate: nil) + LogManager.shared.log( + category: .taskScheduler, + message: self.immediateRequested + ? "Requested the earliest possible background refresh" + : "Could not request a background refresh; no recovery window is pending" + ) + } + } + + @discardableResult + private func submit(earliestBeginDate: Date?) -> Bool { let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) - request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + request.earliestBeginDate = earliestBeginDate do { try BGTaskScheduler.shared.submit(request) + return true } catch { LogManager.shared.log(category: .taskScheduler, message: "Failed to schedule BGAppRefreshTask: \(error)") + return false } } - - private func getMainViewController() -> MainViewController? { - MainViewController.shared - } } diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 25aa6b3c8..cfbe5c0e1 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -2,30 +2,175 @@ // BackgroundTaskAudio.swift import AVFoundation +import UIKit +/// Keeps the app running in the background by looping a silent audio file. +/// +/// The audio session is the only background-execution claim Silent Tune has, so +/// losing it means the process is suspended within seconds and no app code — +/// including any retry timer — runs again until iOS resumes the app. Every +/// reactivation attempt therefore runs inside a `UIApplication` background-task +/// assertion, which grants runtime independently of the audio claim, and the +/// attempts are bounded to stay inside that assertion's budget. class BackgroundTask { // MARK: - Vars var player = AVAudioPlayer() - private var retryCount = 0 - private let maxRetries = 3 + /// True while the silent loop actually holds the background-audio claim. + var isPlaying: Bool { player.isPlaying } + + /// Attempts spread over `retryInterval`, sized to fit a background-task + /// assertion (~30s) and a `BGAppRefreshTask` window with room to spare. + private let maxAttempts = 10 + private let retryInterval: TimeInterval = 2.0 + + /// Delay before the first attempt after an interruption ends, letting the + /// interrupting app (e.g. Clock alarm) fully release the audio session. + /// Without it `setActive(true)` races with the alarm and fails with + /// `AVAudioSession.ErrorCode.cannotInterruptOthers` (560557684). + private let postInterruptionDelay: TimeInterval = 0.5 + + /// Window after an interruption begins in which a matching `.ended` supersedes + /// the recovery. Longer than `postInterruptionDelay` so a blip's own restart + /// lands first; short enough that a real claim loss is addressed promptly. + private let interruptionSettleDelay: TimeInterval = 1.0 + + private var recoveryWorkItem: DispatchWorkItem? + private var assertionID: UIBackgroundTaskIdentifier = .invalid + + /// Callers waiting on the outcome. A caller holding a `BGAppRefreshTask` open + /// must always hear back so it can complete the task, so a sequence that + /// supersedes another inherits its waiters. + private var pendingCompletions: [(Bool) -> Void] = [] + + /// Per-sequence diagnostics: how long recovery has been running, how many + /// attempts it took, and the last session error. A first-attempt success stays + /// quiet; anything slower reports what it cost. + private var sequenceStart: Date? + private var attemptsMade = 0 + private var lastFailureCode: Int? + + /// Set when a sequence runs out of attempts, so the eventual recovery is reported + /// at full level however it arrives. + private var lastSequenceGaveUp = false + + /// True while the active sequence was started by an interruption beginning. + /// Exhausting the attempts there is expected for any interrupter that outlasts + /// the assertion (a phone call), and iOS still commonly heals it by delivering + /// `.ended`, so that case must not be announced as a failed keep-alive. + private var startedByInterruption = false // MARK: - Methods func startBackgroundTask() { - NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) + attachObservers() + onMain { self.recover(after: 0, reason: "start") } + } + + /// Idempotent. A process launched into the background by `BGAppRefreshTask` never + /// sees a backgrounding transition, so the keep-alive attaches these wherever it + /// starts. + private func attachObservers() { + removeObservers() NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) - retryCount = 0 - playAudio() + // A route disappearing pauses the player without any interruption notification, + // and a media services reset invalidates the session and player outright — + // neither is observable through `interruptionNotification`. + NotificationCenter.default.addObserver(self, selector: #selector(audioRouteChanged), name: AVAudioSession.routeChangeNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(mediaServicesWereReset), name: AVAudioSession.mediaServicesWereResetNotification, object: nil) } func stopBackgroundTask() { + removeObservers() + onMain { + self.cancelRecovery() + self.player.stop() + // Reached only from the foreground transition: with the app open, the + // next backgrounding is a clean start. + self.lastSequenceGaveUp = false + LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) + } + } + + /// Reactivates the silent loop, retrying until it plays or the attempt budget + /// is spent, and reports the outcome. Runtime is held by a background-task + /// assertion for the whole sequence, so the retries survive the loss of the + /// audio claim that made them necessary. + /// - Parameter completion: Called on the main queue with the final state. + func restartAudio(reason: String, completion: ((Bool) -> Void)? = nil) { + attachObservers() + onMain { + self.player.stop() + self.recover(after: 0, reason: reason, completion: completion) + } + } + + private func removeObservers() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) - player.stop() - LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) + NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: AVAudioSession.mediaServicesWereResetNotification, object: nil) + } + + // MARK: - Route and media services handling + + @objc private func audioRouteChanged(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) + else { return } + + let previous = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription + let route = "reason=\(describe(reason)) from=\(portTypes(previous)) to=\(portTypes(AVAudioSession.sharedInstance().currentRoute))" + + switch reason { + case .oldDeviceUnavailable, .newDeviceAvailable: + LogManager.shared.log(category: .general, message: "[LA] Audio route changed, restarting silent audio: \(route)") + // CarPlay and Bluetooth transitions emit a burst of route changes; each + // supersedes the last, so the ladder runs once against the settled route. + onMain { self.recover(after: self.interruptionSettleDelay, reason: "route change") } + + case .categoryChange: + // `playAudio` sets the category itself, and an alarm takes over the + // session the same way. Both make this reason unsafe to act on. + LogManager.shared.log(category: .general, message: "[LA] Audio route changed, ignoring: \(route)", isDebug: true) + + default: + // Recorded for diagnosis without acting. + LogManager.shared.log(category: .general, message: "[LA] Audio route changed, no action: \(route)") + } } + @objc private func mediaServicesWereReset(_: Notification) { + LogManager.shared.log(category: .general, message: "[LA] Media services were reset — session and player are invalid, rebuilding") + // `playAudio` reconfigures the category, reactivates, and creates a fresh + // player, which is the recovery Apple prescribes for a reset. + onMain { self.recover(after: self.interruptionSettleDelay, reason: "media services reset") } + } + + /// Port types only — `portName` carries the user's accessory name, which must not + /// reach a shared log. + private func portTypes(_ route: AVAudioSessionRouteDescription?) -> String { + guard let route, !route.outputs.isEmpty else { return "none" } + return route.outputs.map { $0.portType.rawValue }.joined(separator: "+") + } + + private func describe(_ reason: AVAudioSession.RouteChangeReason) -> String { + switch reason { + case .newDeviceAvailable: "newDeviceAvailable" + case .oldDeviceUnavailable: "oldDeviceUnavailable" + case .categoryChange: "categoryChange" + case .override: "override" + case .wakeFromSleep: "wakeFromSleep" + case .noSuitableRouteForCategory: "noSuitableRouteForCategory" + case .routeConfigurationChange: "routeConfigurationChange" + case .unknown: "unknown" + @unknown default: "other" + } + } + + // MARK: - Interruption handling + @objc private func interruptedAudio(_ notification: Notification) { guard notification.name == AVAudioSession.interruptionNotification, let userInfo = notification.userInfo, @@ -35,7 +180,19 @@ class BackgroundTask { switch type { case .began: - LogManager.shared.log(category: .general, message: "[LA] Silent audio session interrupted (began)") + let reason = (userInfo[AVAudioSessionInterruptionReasonKey] as? UInt) + .flatMap { AVAudioSession.InterruptionReason(rawValue: $0) } + LogManager.shared.log( + category: .general, + message: "[LA] Silent audio session interrupted (began), reason=\(describe(reason)), otherAudioPlaying=\(AVAudioSession.sharedInstance().isOtherAudioPlaying)" + ) + // iOS delivers `.ended` only if the app is still running, and the lost + // audio claim means suspension is imminent, so recovery cannot wait for + // it. The delay is a supersede window: a brief interrupter's `.ended` + // arrives well inside it and cancels this work, so momentary blips stay + // quiet. Work that does run is therefore a reliable signal that the + // claim is really gone, whatever `player.isPlaying` reports. + onMain { self.recover(after: self.interruptionSettleDelay, reason: "interruption began", startedByInterruption: true) } case .ended: if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { @@ -44,47 +201,223 @@ class BackgroundTask { LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, attempting restart anyway") } } - LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in 0.5s") - retryCount = 0 - // Brief delay to let the interrupting app (e.g. Clock alarm) fully release the audio - // session before we attempt to reactivate. Without this, setActive(true) races with - // the alarm and fails with AVAudioSession.ErrorCode.cannotInterruptOthers (560557684). - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.playAudio() - } + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in \(postInterruptionDelay)s") + onMain { self.recover(after: self.postInterruptionDelay, reason: "interruption ended") } @unknown default: break } } - private func playAudio() { - let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)" + private func describe(_ reason: AVAudioSession.InterruptionReason?) -> String { + switch reason { + case .default: "default" + case .builtInMicMuted: "builtInMicMuted" + case .none: "unknown" + @unknown default: "other" + } + } + + // MARK: - Recovery + + /// Runs one bounded recovery sequence, superseding any sequence already in flight. + private func recover(after delay: TimeInterval, reason: String, startedByInterruption: Bool = false, completion: ((Bool) -> Void)? = nil) { + // Waiters from the in-flight sequence inherit this sequence's outcome. + recoveryWorkItem?.cancel() + recoveryWorkItem = nil + if let completion { + pendingCompletions.append(completion) + } + self.startedByInterruption = startedByInterruption + if sequenceStart == nil { + sequenceStart = Date() + attemptsMade = 0 + lastFailureCode = nil + } + + // `player.isPlaying` reports true for a while after the session is taken, so + // recovery runs unconditionally. Reattempting against a playing player is + // harmless: `playAudio` activates the session before touching `player`, leaving + // a working one untouched when an attempt fails. + // + // The assertion is taken before the delay so the first attempt is covered too. + beginAssertion() + + guard delay > 0 else { + attempt(1, of: reason) + return + } + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.recoveryWorkItem = nil + self.attempt(1, of: reason) + } + recoveryWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } + + private func attempt(_ number: Int, of reason: String) { + attemptsMade = number + if startedByInterruption, number == 1 { + // Reached only when the settle window elapsed without an `.ended`, so the + // interrupter holds the session and the app may be suspended before the + // ladder finishes. + BackgroundRefreshManager.shared.scheduleImmediateRefresh() + } + if playAudio(attempt: number, reason: reason) { + finishRecovery(success: true) + return + } + + guard number < maxAttempts else { + LogManager.shared.log( + category: .general, + message: "Silent audio recovery gave up after \(number) attempts over \(elapsedDescription()) (\(reason)), last error: \(Self.describeSessionError(lastFailureCode ?? 0))" + ) + lastSequenceGaveUp = true + if !startedByInterruption { + NotificationCenter.default.post(name: .backgroundAudioFailed, object: nil) + } + // The attempts are spent and there is no audio claim left, so a background + // refresh is the only remaining route back to running code. + BackgroundRefreshManager.shared.scheduleImmediateRefresh() + finishRecovery(success: false) + return + } + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.recoveryWorkItem = nil + self.attempt(number + 1, of: reason) + } + recoveryWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work) + } + + /// - Returns: True when the silent loop is confirmed playing. + private func playAudio(attempt: Int, reason: String) -> Bool { do { - let bundle = Bundle.main.path(forResource: "blank", ofType: "wav") - let alertSound = URL(fileURLWithPath: bundle!) + guard let path = Bundle.main.path(forResource: "blank", ofType: "wav") else { + LogManager.shared.log(category: .general, message: "playAudio failed: blank.wav missing from bundle") + return false + } try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers) try AVAudioSession.sharedInstance().setActive(true) - try player = AVAudioPlayer(contentsOf: alertSound) + player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) // Play audio forever by setting num of loops to -1 player.numberOfLoops = -1 player.volume = 0.01 player.prepareToPlay() player.play() - retryCount = 0 - LogManager.shared.log(category: .general, message: "Silent audio playing (\(attemptDesc))", isDebug: true) - } catch { - LogManager.shared.log(category: .general, message: "playAudio failed (\(attemptDesc)), error: \(error)") - if retryCount < maxRetries { - retryCount += 1 - LogManager.shared.log(category: .general, message: "playAudio scheduling retry \(retryCount)/\(maxRetries) in 2s") - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - self?.playAudio() - } + guard player.isPlaying else { + LogManager.shared.log(category: .general, message: "playAudio: play() did not start the player (attempt \(attempt)/\(maxAttempts), \(reason))") + return false + } + if attempt > 1 || lastFailureCode != nil || lastSequenceGaveUp { + // A recovery following a logged failure reports itself, so the log + // always says whether the failure resolved. `lastFailureCode` survives + // a supersede, so the elapsed figure spans the whole window. + LogManager.shared.log(category: .general, message: "Silent audio playing again after \(attempt) attempt(s) over \(elapsedDescription()) (\(reason))") } else { - LogManager.shared.log(category: .general, message: "playAudio failed after \(maxRetries) retries — posting BackgroundAudioFailed") - NotificationCenter.default.post(name: .backgroundAudioFailed, object: nil) + LogManager.shared.log(category: .general, message: "Silent audio playing (\(reason))", isDebug: true) } + lastSequenceGaveUp = false + return true + } catch { + let code = (error as NSError).code + // Every attempt against the same holder reports the same code; log the + // first and any change, so a 10-attempt ladder can't bury the log. + let isNewFailure = code != lastFailureCode + lastFailureCode = code + LogManager.shared.log( + category: .general, + message: "playAudio failed (attempt \(attempt)/\(maxAttempts), \(reason)), code \(code) \(Self.describeSessionError(code)): \(error.localizedDescription)", + isDebug: !isNewFailure + ) + return false + } + } + + private func elapsedDescription() -> String { + guard let start = sequenceStart else { return "unknown" } + return String(format: "%.1fs", Date().timeIntervalSince(start)) + } + + private func finishRecovery(success: Bool) { + recoveryWorkItem = nil + let completions = pendingCompletions + pendingCompletions = [] + startedByInterruption = false + sequenceStart = nil + attemptsMade = 0 + lastFailureCode = nil + endAssertion() + for completion in completions { + completion(success) + } + } + + private func cancelRecovery() { + recoveryWorkItem?.cancel() + finishRecovery(success: player.isPlaying) + } + + // MARK: - Runtime assertion + + /// Holds runtime while the audio claim is gone, so queued retries actually run. + private func beginAssertion() { + guard assertionID == .invalid else { return } + // UIKit invokes the expiration handler on the main thread, which is the only + // queue that touches the recovery state. + assertionID = UIApplication.shared.beginBackgroundTask(withName: "SilentAudioRecovery") { [weak self] in + guard let self else { return } + LogManager.shared.log( + category: .general, + message: "Silent audio recovery assertion expired after \(self.attemptsMade) attempts over \(self.elapsedDescription()); the app is about to be suspended without an audio claim" + ) + self.lastSequenceGaveUp = true + // A success ends the assertion, so reaching expiration means the claim was + // never re-established — arm the net without consulting `player.isPlaying`. + BackgroundRefreshManager.shared.scheduleImmediateRefresh() + self.cancelRecovery() + } + } + + private func endAssertion() { + guard assertionID != .invalid else { return } + UIApplication.shared.endBackgroundTask(assertionID) + assertionID = .invalid + } + + // MARK: - Helpers + + private func onMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } + + /// `AVAudioSession.ErrorCode` values are four-character codes; the raw number + /// alone is unreadable in a shared log. + static func describeSessionError(_ code: Int) -> String { + switch AVAudioSession.ErrorCode(rawValue: code) { + case .cannotInterruptOthers: "cannotInterruptOthers" + case .siriIsRecording: "siriIsRecording" + case .cannotStartPlaying: "cannotStartPlaying" + case .cannotStartRecording: "cannotStartRecording" + case .insufficientPriority: "insufficientPriority" + case .resourceNotAvailable: "resourceNotAvailable" + case .mediaServicesFailed: "mediaServicesFailed" + case .isBusy: "isBusy" + case .incompatibleCategory: "incompatibleCategory" + case .expiredSession: "expiredSession" + case .sessionNotActive: "sessionNotActive" + case .badParam: "badParam" + case .none: "unspecified" + default: "other" } } } diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index e92ee5778..5a25afdae 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -40,6 +40,18 @@ shortTitle: "Restart Live Activity", systemImageName: "dot.radiowaves.left.and.right" ) + AppShortcut( + intent: EnableSpeakBGIntent(), + phrases: ["Turn on Speak BG in \(.applicationName)"], + shortTitle: "Turn On Speak BG", + systemImageName: "speaker.wave.2" + ) + AppShortcut( + intent: DisableSpeakBGIntent(), + phrases: ["Turn off Speak BG in \(.applicationName)"], + shortTitle: "Turn Off Speak BG", + systemImageName: "speaker.slash" + ) } } #endif diff --git a/LoopFollow/Log/LogArchiver.swift b/LoopFollow/Log/LogArchiver.swift new file mode 100644 index 000000000..a68d61f89 --- /dev/null +++ b/LoopFollow/Log/LogArchiver.swift @@ -0,0 +1,36 @@ +// LoopFollow +// LogArchiver.swift + +import Foundation + +enum LogArchiver { + /// Copies `files` into a folder named `archiveName` and returns a zip archive + /// of that folder in the temporary directory. + static func zip(files: [URL], archiveName: String) throws -> URL { + let fileManager = FileManager.default + let staging = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let folder = staging.appendingPathComponent(archiveName, isDirectory: true) + try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: staging) } + + for file in files { + try fileManager.copyItem(at: file, to: folder.appendingPathComponent(file.lastPathComponent)) + } + + let destination = fileManager.temporaryDirectory.appendingPathComponent("\(archiveName).zip") + try? fileManager.removeItem(at: destination) + + var coordinationError: NSError? + var copyError: Error? + NSFileCoordinator().coordinate(readingItemAt: folder, options: .forUploading, error: &coordinationError) { zipURL in + do { + try fileManager.copyItem(at: zipURL, to: destination) + } catch { + copyError = error + } + } + if let coordinationError { throw coordinationError } + if let copyError { throw copyError } + return destination + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 2cc834fc3..09222270c 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -270,7 +270,7 @@ struct LoopAPNSBolusView: View { switch alertType { case .success: return Alert( - title: Text("Success"), + title: Text("Command Sent"), message: Text(alertMessage), dismissButton: .default(Text("OK")) { presentationMode.wrappedValue.dismiss() @@ -409,7 +409,7 @@ struct LoopAPNSBolusView: View { } // Mark TOTP code as used TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) - self.alertMessage = "Insulin sent successfully!" + self.alertMessage = RemoteCommandMessage.sent self.alertType = .success LogManager.shared.log( category: .apns, diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 32db0f9c9..537fa29d2 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -455,7 +455,7 @@ struct LoopAPNSCarbsView: View { switch alertType { case .success: return Alert( - title: Text("Success"), + title: Text("Command Sent"), message: Text(alertMessage), dismissButton: .default(Text("OK")) { presentationMode.wrappedValue.dismiss() @@ -571,9 +571,7 @@ struct LoopAPNSCarbsView: View { } // Mark TOTP code as used TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) - let timeFormatter = DateFormatter() - timeFormatter.timeStyle = .short - self.alertMessage = "Carbs sent successfully for \(timeFormatter.string(from: adjustedConsumedDate))!" + self.alertMessage = RemoteCommandMessage.sent self.alertType = .success LogManager.shared.log( category: .apns, diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 2a13dd081..38dc4fb23 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -682,7 +682,7 @@ class LoopAPNSService { "alert": alertText, ] - if let duration = duration, duration > 0 { + if let duration = duration { payload["override-duration-minutes"] = Int(duration / 60) } diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift index 4d29aa6f6..6ed39e5bc 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift @@ -215,10 +215,11 @@ struct OverrideActivationModal: View { // Initialize state based on preset duration if preset.duration == 0 { - // Indefinite override - allow user to choose + // Indefinite override defaults to indefinite. _enableIndefinitely = State(initialValue: true) + _durationHours = State(initialValue: 1.0) } else { - // Override with predefined duration - use preset duration + // Predefined-duration override defaults to the preset duration, but remains editable. _enableIndefinitely = State(initialValue: false) _durationHours = State(initialValue: preset.duration / 3600) } @@ -251,69 +252,57 @@ struct OverrideActivationModal: View { .foregroundColor(.secondary) } - // Only show duration for overrides with predefined duration - if preset.duration != 0 { - Text("Duration: \(preset.durationDescription)") - .font(.subheadline) - .foregroundColor(.secondary) - } + Text("Preset: \(preset.durationDescription)") + .font(.subheadline) + .foregroundColor(.secondary) } .padding(.top) Spacer() - // Duration Settings (only show for overrides without predefined duration) - if preset.duration == 0 { - VStack(spacing: 16) { - // Duration Input (only show when not indefinite) - if !enableIndefinitely { - VStack(spacing: 8) { - HStack { - Text("Duration") - .font(.headline) - Spacer() - Text(formatDuration(durationHours)) - .font(.headline) - .foregroundColor(.blue) - } - - Slider(value: $durationHours, in: 0.25 ... 24.0, step: 0.25) - .accentColor(.blue) - HStack { - Text("15m") - .font(.caption) - .foregroundColor(.secondary) - .frame(width: 80, alignment: .leading) - Spacer() - Text("24h") - .font(.caption) - .foregroundColor(.secondary) - .frame(width: 80, alignment: .trailing) - } + // Duration Settings (available for all overrides) + VStack(spacing: 16) { + // Duration Input (only show when not indefinite) + if !enableIndefinitely { + VStack(spacing: 8) { + HStack { + Text("Duration") + .font(.headline) + Spacer() + Text(formatDuration(durationHours)) + .font(.headline) + .foregroundColor(.blue) } - .padding(.horizontal) - } - // Indefinitely Toggle - HStack { - Toggle("Enable indefinitely", isOn: $enableIndefinitely) - Spacer() + Slider(value: $durationHours, in: 0.25 ... 24.0, step: 0.25) + .accentColor(.blue) + HStack { + Text("15m") + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .leading) + Spacer() + Text("24h") + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .trailing) + } } .padding(.horizontal) } + + // Indefinitely Toggle + HStack { + Toggle("Enable indefinitely", isOn: $enableIndefinitely) + Spacer() + } + .padding(.horizontal) } // Action Buttons VStack(spacing: 12) { Button(action: { - let duration: TimeInterval? - if preset.duration == 0 { - // For indefinite overrides, use user selection - duration = enableIndefinitely ? nil : (durationHours * 3600) - } else { - // For overrides with predefined duration, use preset duration - duration = preset.duration - } + let duration: TimeInterval? = enableIndefinitely ? 0 : (durationHours * 3600) onActivate(duration) }) { Text("Activate Override") diff --git a/LoopFollow/Remote/RemoteCommandMessage.swift b/LoopFollow/Remote/RemoteCommandMessage.swift new file mode 100644 index 000000000..686734b88 --- /dev/null +++ b/LoopFollow/Remote/RemoteCommandMessage.swift @@ -0,0 +1,6 @@ +// LoopFollow +// RemoteCommandMessage.swift + +enum RemoteCommandMessage { + static let sent = "Remote command sent. Wait for a notification confirming the result." +} diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 7a59c87de..e41ab89ce 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -180,7 +180,7 @@ struct BolusView: View { ) case .statusSuccess: return Alert( - title: Text("Status"), + title: Text("Command Sent"), message: Text(statusMessage ?? ""), dismissButton: .default(Text("OK"), action: { presentationMode.wrappedValue.dismiss() @@ -322,7 +322,7 @@ struct BolusView: View { if sentUnits > 0 { QuickPickBolusesManager.shared.recordBolus(units: sentUnits) } - statusMessage = "Bolus command sent successfully." + statusMessage = RemoteCommandMessage.sent LogManager.shared.log( category: .apns, message: "sendBolusPushNotification succeeded - Bolus: \(InsulinFormatter.shared.string(bolusAmount)) U" diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index 5938735d1..5db2ef14b 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -299,7 +299,7 @@ struct MealView: View { ) case .statusSuccess: return Alert( - title: Text("Status"), + title: Text("Command Sent"), message: Text(statusMessage ?? ""), dismissButton: .default(Text("OK"), action: { presentationMode.wrappedValue.dismiss() @@ -362,7 +362,7 @@ struct MealView: View { bolus: bolusAmount.doubleValue(for: .internationalUnit()) ) } - statusMessage = "Meal command sent successfully." + statusMessage = RemoteCommandMessage.sent LogManager.shared.log( category: .apns, message: "sendMealPushNotification succeeded - Carbs: \(carbs.doubleValue(for: .gram())) g, Protein: \(protein.doubleValue(for: .gram())) g, Fat: \(fat.doubleValue(for: .gram())) g, Bolus: \(bolusAmount.doubleValue(for: .internationalUnit())) U, Scheduled: \(scheduledDate != nil ? formatDate(scheduledDate!) : "now")" diff --git a/LoopFollow/Remote/TRC/OverrideView.swift b/LoopFollow/Remote/TRC/OverrideView.swift index 703201b07..3ef038e03 100644 --- a/LoopFollow/Remote/TRC/OverrideView.swift +++ b/LoopFollow/Remote/TRC/OverrideView.swift @@ -147,7 +147,7 @@ struct OverrideView: View { ) case .statusSuccess: return Alert( - title: Text("Success"), + title: Text("Command Sent"), message: Text(statusMessage ?? ""), dismissButton: .default(Text("OK"), action: { presentationMode.wrappedValue.dismiss() @@ -181,7 +181,7 @@ struct OverrideView: View { DispatchQueue.main.async { self.isLoading = false if success { - self.statusMessage = "Override command sent successfully." + self.statusMessage = RemoteCommandMessage.sent self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendOverridePushNotification succeeded for override: \(override.name)") } else { @@ -201,7 +201,7 @@ struct OverrideView: View { DispatchQueue.main.async { self.isLoading = false if success { - self.statusMessage = "Cancel override command sent successfully." + self.statusMessage = RemoteCommandMessage.sent self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendCancelOverridePushNotification succeeded") } else { diff --git a/LoopFollow/Remote/TRC/TempTargetView.swift b/LoopFollow/Remote/TRC/TempTargetView.swift index ac2fa7303..08d316f35 100644 --- a/LoopFollow/Remote/TRC/TempTargetView.swift +++ b/LoopFollow/Remote/TRC/TempTargetView.swift @@ -189,7 +189,7 @@ struct TempTargetView: View { ) case .statusSuccess: return Alert( - title: Text("Status"), + title: Text("Command Sent"), message: Text(statusMessage ?? ""), dismissButton: .default(Text("OK"), action: { presentationMode.wrappedValue.dismiss() @@ -261,7 +261,7 @@ struct TempTargetView: View { DispatchQueue.main.async { self.isLoading = false if success { - self.statusMessage = "Temp target command successfully sent." + self.statusMessage = RemoteCommandMessage.sent self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendTempTargetPushNotification succeeded with target: \(newHKTarget), duration: \(duration)") } else { @@ -281,7 +281,7 @@ struct TempTargetView: View { DispatchQueue.main.async { self.isLoading = false if success { - self.statusMessage = "Cancel temp target command successfully sent." + self.statusMessage = RemoteCommandMessage.sent self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendCancelTempTargetPushNotification succeeded") } else { diff --git a/LoopFollow/Settings/AdvancedSettingsViewModel.swift b/LoopFollow/Settings/AdvancedSettingsViewModel.swift index 307d91e69..078e62288 100644 --- a/LoopFollow/Settings/AdvancedSettingsViewModel.swift +++ b/LoopFollow/Settings/AdvancedSettingsViewModel.swift @@ -19,24 +19,28 @@ class AdvancedSettingsViewModel: ObservableObject { @Published var graphBasal: Bool { didSet { Storage.shared.graphBasal.value = graphBasal + Observable.shared.chartSettingsChanged.value = true } } @Published var graphBolus: Bool { didSet { Storage.shared.graphBolus.value = graphBolus + Observable.shared.chartSettingsChanged.value = true } } @Published var graphCarbs: Bool { didSet { Storage.shared.graphCarbs.value = graphCarbs + Observable.shared.chartSettingsChanged.value = true } } @Published var graphOtherTreatments: Bool { didSet { Storage.shared.graphOtherTreatments.value = graphOtherTreatments + Observable.shared.chartSettingsChanged.value = true } } diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 07d9d8d91..7efb38c40 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -12,6 +12,7 @@ struct GraphSettingsView: View { @ObservedObject private var show30MinLine = Storage.shared.show30MinLine @ObservedObject private var show90MinLine = Storage.shared.show90MinLine @ObservedObject private var showMidnightLines = Storage.shared.showMidnightLines + @ObservedObject private var showPriorDayTimeLines = Storage.shared.showPriorDayTimeLines @ObservedObject private var showYesterdayLine = Storage.shared.showYesterdayLine @ObservedObject private var smallGraphTreatments = Storage.shared.smallGraphTreatments @@ -50,6 +51,9 @@ struct GraphSettingsView: View { Toggle("Show Midnight Lines", isOn: $showMidnightLines.value) .onChange(of: showMidnightLines.value) { _ in markDirty() } + + Toggle("Show Prior Day Time Lines", isOn: $showPriorDayTimeLines.value) + .onChange(of: showPriorDayTimeLines.value) { _ in markDirty() } } // ── Treatments ─────────────────────────────────────────────── diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 38fb991fb..93fd0fd93 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -176,6 +176,7 @@ enum SettingsRoute: Hashable, Identifiable { SettingsLeaf("Show −90 min Line", ["-90"]), SettingsLeaf("Show Yesterday's BG", ["yesterday"]), SettingsLeaf("Show Midnight Lines"), + SettingsLeaf("Show Prior Day Time Lines", ["prior day", "same time", "yesterday"]), SettingsLeaf("Show Carb/Bolus Values", ["carbs"]), SettingsLeaf("Show Carb Absorption"), SettingsLeaf("Treatments on Small Graph"), diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4876924e2..dd63f6dd6 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -99,6 +99,7 @@ class Storage { // Live Activity extended InfoType data var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) + var pumpReservoirCache = StorageValue(key: "pumpReservoirCache", defaultValue: nil) var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) @@ -128,6 +129,7 @@ class Storage { var show30MinLine = StorageValue(key: "show30MinLine", defaultValue: false) var show90MinLine = StorageValue(key: "show90MinLine", defaultValue: false) var showMidnightLines = StorageValue(key: "showMidnightMarkers", defaultValue: false) + var showPriorDayTimeLines = StorageValue(key: "showPriorDayTimeMarkers", defaultValue: false) var showYesterdayLine = StorageValue(key: "showYesterdayLine", defaultValue: false) var smallGraphTreatments = StorageValue(key: "smallGraphTreatments", defaultValue: true) diff --git a/LoopFollow/Task/TaskScheduler.swift b/LoopFollow/Task/TaskScheduler.swift index b76ac3022..95a2d49ff 100644 --- a/LoopFollow/Task/TaskScheduler.swift +++ b/LoopFollow/Task/TaskScheduler.swift @@ -28,6 +28,24 @@ class TaskScheduler { private var tasks: [TaskID: ScheduledTask] = [:] private var currentTimer: DispatchSourceTimer? + /// When tasks last fired. `minAgoUpdate` reschedules itself at most 60s out, so + /// with runtime this advances at least once a minute; a larger jump means the + /// process was suspended and is the window the background alerts fire in. + private var lastFireDate: Date? + + /// Counterpart to `lastFireDate` that includes time asleep and cannot be moved by + /// a clock correction. The difference between the two measures a clock step. + private var lastFireUptime: UInt64? + + /// Above normal tick jitter, below the 6-minute first background alert. + private let runtimeGapThreshold: TimeInterval = 120 + + /// Queue-confined park tracking. A normal park clears within milliseconds, so a + /// survivor at this age is wedged or was suspended mid-park. + private var parkedSince: Date? + private var parkedReporter: DispatchWorkItem? + private let parkedReportDelay: TimeInterval = 5 + private init() {} // MARK: - Public API @@ -72,6 +90,12 @@ class TaskScheduler { return } + if earliestTask.nextRun == .distantFuture { + noteTimerParked() + } else { + clearTimerParked() + } + let interval = earliestTask.nextRun.timeIntervalSinceNow let safeInterval = max(interval, 0) @@ -90,6 +114,7 @@ class TaskScheduler { BackgroundAlertManager.shared.scheduleBackgroundAlert() let now = Date() + noteRuntimeGap(at: now) for taskID in TaskID.allCases { guard let task = tasks[taskID], task.nextRun <= now else { @@ -108,6 +133,61 @@ class TaskScheduler { } } + /// `fireOverdueTasks` parks a task at `.distantFuture` and its action reschedules + /// it asynchronously, so every task being parked at once is normal for the + /// milliseconds in between. A park outliving that leaves nothing to wake the timer, + /// so it is reported by duration and the routine case stays silent. + private func noteTimerParked() { + guard parkedSince == nil else { return } + let since = Date() + parkedSince = since + let work = DispatchWorkItem { [weak self] in + guard let self, self.parkedSince == since else { return } + LogManager.shared.log( + category: .taskScheduler, + message: "Timer still parked after \(Int(Date().timeIntervalSince(since)))s: every task is awaiting its action to reschedule it" + ) + } + parkedReporter = work + queue.asyncAfter(deadline: .now() + parkedReportDelay, execute: work) + } + + private func clearTimerParked() { + parkedReporter?.cancel() + parkedReporter = nil + parkedSince = nil + } + + /// Records one line per lost-runtime window, giving the length of a background + /// stall directly. + private func noteRuntimeGap(at now: Date) { + // CLOCK_MONOTONIC keeps counting while the device sleeps, so it measures a + // suspension. + let uptime = clock_gettime_nsec_np(CLOCK_MONOTONIC) + defer { + lastFireDate = now + lastFireUptime = uptime + } + guard let last = lastFireDate, let lastUptime = lastFireUptime else { return } + // Boot time is authoritative: a wall-clock correction must not hide a stall. + let gap = Double(uptime &- lastUptime) / 1_000_000_000 + let wallGap = now.timeIntervalSince(last) + guard gap >= runtimeGapThreshold else { return } + // Silent Tune is the only mode whose invariant is continuous runtime, which is + // what this measures. `.none` is meant to be suspended and the Bluetooth modes + // tick at heartbeat cadence, so for both a gap is normal. + guard Storage.shared.backgroundRefreshType.value == .silentTune else { return } + let alerts = BackgroundAlertDuration.allCases + .filter { gap >= $0.rawValue } + .map { "\(Int($0.rawValue / 60))" } + let fired = alerts.isEmpty ? "none" : alerts.joined(separator: "/") + " min" + var message = "Regained runtime after \(Int(gap))s with no scheduler tick; background alerts fired: \(fired)" + if abs(wallGap - gap) >= 5 { + message += "; wall clock moved \(Int(wallGap - gap))s relative to boot time" + } + LogManager.shared.log(category: .taskScheduler, message: message) + } + private func formatTime(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateStyle = .none diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ed1ce880f..06c5e1f76 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -103,7 +103,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var latestLoopStatusString = "" var latestCOB: CarbMetric? var latestBasal = "" - var latestPumpVolume: Double = 50.0 + var latestPumpVolume: Double? var latestIOB: InsulinMetric? var lastOverrideStartTime: TimeInterval = 0 var lastOverrideEndTime: TimeInterval = 0 @@ -579,6 +579,11 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { } @objc func appMovedToBackground() { + LogManager.shared.log( + category: .general, + message: "App moved to background (refreshType=\(Storage.shared.backgroundRefreshType.value.rawValue), lowPowerMode=\(ProcessInfo.processInfo.isLowPowerModeEnabled), backgroundRefreshStatus=\(Self.describe(UIApplication.shared.backgroundRefreshStatus)))" + ) + // Allow screen to turn off UIApplication.shared.isIdleTimerDisabled = false @@ -688,7 +693,18 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { scheduleAllTasks() } + private static func describe(_ status: UIBackgroundRefreshStatus) -> String { + switch status { + case .available: "available" + case .denied: "denied" + case .restricted: "restricted" + @unknown default: "unknown" + } + } + @objc func appCameToForeground() { + LogManager.shared.log(category: .general, message: "App came to foreground") + // BFU recovery (StorageReadiness.recover) is driven by AppDelegate before this // controller exists (the readiness gate), so handleBFUReloadCompleted() above // is a vestigial no-op in the gated flow. diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index 31f3b3915..ed1c8f360 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -287,19 +287,31 @@ struct MoreMenuView: View { } private func presentLogShareSheet(noticeText: String, logFiles: [URL]) { - var items: [Any] = logFiles - if let noticeURL = writeShareNoticeFile(text: noticeText) { - items.insert(noticeURL, at: 0) - } - let avc = UIActivityViewController(activityItems: items, applicationActivities: nil) - UIApplication.shared.topMost?.present(avc, animated: true) - } - - private func writeShareNoticeFile(text: String) -> URL? { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd_HHmm" let timestamp = formatter.string(from: Date()) + var files = logFiles + if let noticeURL = writeShareNoticeFile(text: noticeText, timestamp: timestamp) { + files.insert(noticeURL, at: 0) + } + + DispatchQueue.global(qos: .userInitiated).async { + let items: [Any] + do { + items = try [LogArchiver.zip(files: files, archiveName: "LoopFollow Logs \(timestamp)")] + } catch { + LogManager.shared.log(category: .general, message: "Failed to zip log files, sharing them uncompressed: \(error)") + items = files + } + DispatchQueue.main.async { + let avc = UIActivityViewController(activityItems: items, applicationActivities: nil) + UIApplication.shared.topMost?.present(avc, animated: true) + } + } + } + + private func writeShareNoticeFile(text: String, timestamp: String) -> URL? { let version = AppVersionManager().version() let branchAndSha = BuildDetails.default.branchAndSha diff --git a/Tests/Charts/BGChartScrubSlotsTests.swift b/Tests/Charts/BGChartScrubSlotsTests.swift new file mode 100644 index 000000000..cb4b90360 --- /dev/null +++ b/Tests/Charts/BGChartScrubSlotsTests.swift @@ -0,0 +1,156 @@ +// LoopFollow +// BGChartScrubSlotsTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct BGChartScrubSlotsTests { + private let origin = Date(timeIntervalSince1970: 1_700_000_000) + + private func at(_ minutes: Double) -> Date { + origin.addingTimeInterval(minutes * 60) + } + + private func minutes(_ date: Date) -> Double { + date.timeIntervalSince(origin) / 60 + } + + private func slot(_ slot: BGChartScrubSlots.Slot, isAt minute: Double) -> Bool { + abs(minutes(slot.date) - minute) < 0.001 + } + + @Test("five-minute readings are marks that own the time up to the midpoints") + func denseReadingBlocks() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 10, 15].map(at)) + + let second = slots.slot(containing: at(6)) + #expect(second.readingIndex == 1) + #expect(second.date == at(5)) + #expect(minutes(second.blockStart) == 2.5) + #expect(minutes(second.blockEnd) == 7.5) + + #expect(slots.slot(containing: at(7.4)).readingIndex == 1) + #expect(slots.slot(containing: at(7.5)).readingIndex == 2) + } + + @Test("one-minute readings snap to every fifth reading, anchored at the newest") + func oneMinuteDataSnapsToFiveMinutes() { + let slots = BGChartScrubSlots(readingDates: stride(from: 0.0, through: 22.0, by: 1.0).map(at)) + + #expect(slot(slots.slot(containing: at(22)), isAt: 22)) + #expect(slot(slots.slot(containing: at(16)), isAt: 17)) + #expect(slot(slots.slot(containing: at(13)), isAt: 12)) + #expect(slot(slots.slot(containing: at(1)), isAt: 2)) + + let seventeen = slots.slot(containing: at(16)) + #expect(seventeen.isReading) + #expect(minutes(seventeen.blockStart) == 14.5) + #expect(minutes(seventeen.blockEnd) == 19.5) + } + + @Test("sensor drift keeps every reading on the grid") + func driftFollowsReadings() { + let slots = BGChartScrubSlots(readingDates: [0, 5.1, 10.2, 15.3, 20.4].map(at)) + for (index, minute) in [0, 5.1, 10.2, 15.3, 20.4].enumerated() { + let mark = slots.slot(containing: at(minute)) + #expect(mark.readingIndex == index) + #expect(slot(mark, isAt: minute)) + } + } + + @Test("a straggler close to a grid reading joins its block") + func stragglerJoinsBlock() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 5.2, 10].map(at)) + + let mark = slots.slot(containing: at(5.2)) + #expect(mark.date == at(5)) + #expect(mark.contains(at(5.2))) + #expect(slots.slot(containing: at(10)).date == at(10)) + } + + @Test("a gap is crossed at exactly the cadence from the reading that ends it") + func gapUsesVirtualCadence() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 10, 33, 38, 43].map(at)) + + for minute in [18.0, 23.0, 28.0] { + let mark = slots.slot(containing: at(minute + 1)) + #expect(slot(mark, isAt: minute)) + #expect(mark.readingIndex == nil) + #expect(mark.blockEnd.timeIntervalSince(mark.blockStart) == 300) + } + + // The virtual mark next to the reading that resumes the data shares + // the leftover with it at the midpoint. + let edge = slots.slot(containing: at(14)) + #expect(slot(edge, isAt: 13)) + #expect(minutes(edge.blockStart) == 11.5) + #expect(minutes(edge.blockEnd) == 15.5) + + let resumed = slots.slot(containing: at(9)) + #expect(resumed.readingIndex == 2) + #expect(minutes(resumed.blockStart) == 7.5) + #expect(minutes(resumed.blockEnd) == 11.5) + } + + @Test("a reading just off the grid is used instead of a virtual mark") + func nearReadingBeatsVirtualMark() { + let slots = BGChartScrubSlots(readingDates: [0, 7, 30, 35].map(at)) + + #expect(slot(slots.slot(containing: at(24)), isAt: 25)) + #expect(slot(slots.slot(containing: at(19)), isAt: 20)) + #expect(slot(slots.slot(containing: at(14)), isAt: 15)) + #expect(slot(slots.slot(containing: at(11)), isAt: 10)) + + let seven = slots.slot(containing: at(6)) + #expect(seven.readingIndex == 1) + #expect(slot(seven, isAt: 7)) + + #expect(slots.slot(containing: at(3)).readingIndex == 0) + } + + @Test("the grid continues at the cadence beyond the first and last marks") + func gridExtendsBeyondReadings() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 10].map(at)) + + let last = slots.slot(containing: at(12)) + #expect(last.readingIndex == 2) + #expect(minutes(last.blockEnd) == 12.5) + + let future = slots.slot(containing: at(23)) + #expect(future.readingIndex == nil) + #expect(slot(future, isAt: 25)) + + let past = slots.slot(containing: at(-9)) + #expect(past.readingIndex == nil) + #expect(slot(past, isAt: -10)) + } + + @Test("without readings every instant still resolves to a virtual mark") + func noReadingsUsesVirtualGrid() { + let slots = BGChartScrubSlots(readingDates: []) + let mark = slots.slot(containing: at(7)) + #expect(mark.readingIndex == nil) + #expect(mark.contains(at(7))) + #expect(mark.blockEnd.timeIntervalSince(mark.blockStart) == 300) + } + + @Test("blocks tile the timeline with no overlaps or holes") + func blocksTileTimeline() { + let slots = BGChartScrubSlots(readingDates: [0, 1, 2, 5, 7, 10, 33, 38, 43, 60].map(at)) + var previous: BGChartScrubSlots.Slot? + + for tenth in stride(from: -20.0, through: 80.0, by: 0.1) { + let probe = at(tenth) + let mark = slots.slot(containing: probe) + #expect(mark.contains(probe), "\(tenth) min not inside its own block") + if let previous, previous != mark { + #expect(abs(mark.blockStart.timeIntervalSince(previous.blockEnd)) < 1e-6, "hole or overlap at \(tenth) min") + #expect(previous.date < mark.date) + let spacing = mark.date.timeIntervalSince(previous.date) + #expect(spacing >= 150 && spacing <= 450, "mark spacing \(spacing) s at \(tenth) min") + } + previous = mark + } + } +} diff --git a/Tests/NightscoutTreatmentDeduplicationTests.swift b/Tests/NightscoutTreatmentDeduplicationTests.swift new file mode 100644 index 000000000..4c7696bda --- /dev/null +++ b/Tests/NightscoutTreatmentDeduplicationTests.swift @@ -0,0 +1,64 @@ +// LoopFollow +// NightscoutTreatmentDeduplicationTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct NightscoutTreatmentDeduplicationTests { + private typealias Entry = [String: AnyObject] + + @Test("keeps Trio FPU siblings with one id and distinct times") + func keepsFPUOccurrences() { + let entries = [ + entry("newer", createdAt: "2026-08-16T02:06:00Z"), + entry("older", createdAt: "2026-08-16T01:06:00Z"), + ] + #expect(deduplicatedIDs(entries) == ["newer", "older"]) + } + + @Test("collapses duplicate Nightscout documents and keeps the first") + func collapsesDuplicates() { + let entries = [ + entry("first", createdAt: "2026-08-16T01:06:00Z"), + entry("duplicate", createdAt: "2026-08-16T01:06:00Z"), + ] + #expect(deduplicatedIDs(entries) == ["first"]) + } + + @Test("uses normalized effective time and event type") + func usesLogicalOccurrence() { + let first = entry("first", timestamp: "2026-08-16T01:06:00Z", createdAt: "2026-08-16T01:05:58Z") + let equivalent = entry("equivalent", timestamp: "2026-08-16T01:06:00.000Z", createdAt: "2026-08-16T01:06:02Z") + let createdAtOnly = entry("created-at", timestamp: nil, createdAt: "2026-08-16T01:06:00Z") + let bolus = entry("bolus", eventType: "Correction Bolus", timestamp: "2026-08-16T01:06:00Z") + #expect(deduplicatedIDs([first, equivalent, createdAtOnly, bolus]) == ["first", "bolus"]) + } + + @Test("preserves missing identifiers and fallback behavior") + func preservesFallbacks() { + let noID = entry("no-id", id: nil) + let blankID = entry("blank-id", id: "") + let noTime = [entry("no-time"), entry("no-time-duplicate")] + let sensor = entry("sensor", eventType: "Sensor Start", timestamp: "2026-08-16T02:00:00Z", createdAt: "2026-08-16T01:00:00Z") + let sensorDuplicate = entry("sensor-duplicate", eventType: "Sensor Start", timestamp: "2026-08-16T03:00:00Z", createdAt: "2026-08-16T01:00:00Z") + #expect(deduplicatedIDs([noID, noID, blankID, blankID]) == ["no-id", "no-id", "blank-id", "blank-id"]) + #expect(deduplicatedIDs(noTime) == ["no-time"]) + #expect(deduplicatedIDs([sensor, sensorDuplicate]) == ["sensor"]) + } + + private func deduplicatedIDs(_ entries: [Entry]) -> [String] { + MainViewController.deduplicatedTreatmentEntries(entries).compactMap { $0["_id"] as? String } + } + + private func entry(_ mongoID: String, id: String? = "shared-id", eventType: String = "Carb Correction", timestamp: String? = nil, createdAt: String? = nil) -> Entry { + var result: Entry = [ + "_id": mongoID as AnyObject, + "eventType": eventType as AnyObject, + ] + if let id { result["id"] = id as AnyObject } + if let timestamp { result["timestamp"] = timestamp as AnyObject } + if let createdAt { result["created_at"] = createdAt as AnyObject } + return result + } +} diff --git a/Tests/PumpReservoirTests.swift b/Tests/PumpReservoirTests.swift new file mode 100644 index 000000000..625e43802 --- /dev/null +++ b/Tests/PumpReservoirTests.swift @@ -0,0 +1,151 @@ +// LoopFollow +// PumpReservoirTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct PumpReservoirTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let pump = "17CB71F7" + + private func resolve( + reservoir: Double? = nil, + pumpID: String? = "17CB71F7", + manufacturer: String? = "Insulet", + model: String? = "Omnipod DASH", + cache: PumpReservoirCache? = nil, + at date: Date? = nil + ) -> PumpReservoirResolver.Resolution { + PumpReservoirResolver.resolve( + reservoir: reservoir, + pumpID: pumpID, + manufacturer: manufacturer, + model: model, + cache: cache, + now: date ?? now + ) + } + + /// A pump that came online `settledFor` ago and reported `units` `readingAge` ago. + private func cache(units: Double, readingAge: TimeInterval, settledFor: TimeInterval = 60 * 60) -> PumpReservoirCache { + PumpReservoirCache( + pumpID: pump, + pumpSince: now.addingTimeInterval(-settledFor), + reading: .init(units: units, date: now.addingTimeInterval(-readingAge)) + ) + } + + @Test("a reported volume is used and kept for the pump it came from") + func reportedVolume() { + let result = resolve(reservoir: 12.5) + #expect(result.state == .units(12.5)) + #expect(result.cache?.pumpID == pump) + #expect(result.cache?.reading == .init(units: 12.5, date: now)) + } + + @Test("zero is a volume, not a missing reading") + func zeroVolume() { + #expect(resolve(reservoir: 0).state == .units(0)) + } + + @Test("a record without a volume reuses a recent reading from the same pump") + func carriesRecentReading() { + let result = resolve(cache: cache(units: 9.9, readingAge: 25 * 60)) + #expect(result.state == .units(9.9)) + #expect(result.cache?.reading?.units == 9.9) + } + + @Test("a reading older than 30 minutes is not shown, and does not become 50+") + func staleReadingIsUnknown() { + let result = resolve(cache: cache(units: 9.9, readingAge: 31 * 60)) + #expect(result.state == .unknown) + // Kept, so the next record still reads as unknown. + #expect(result.cache?.reading?.units == 9.9) + #expect(resolve(cache: result.cache, at: now.addingTimeInterval(5 * 60)).state == .unknown) + } + + @Test("a reading dated in the future is treated as stale") + func futureReadingIsUnknown() { + #expect(resolve(cache: cache(units: 9.9, readingAge: -60 * 60)).state == .unknown) + } + + @Test("a volume reported right after a pod change is shown but not trusted once stale") + func podChangeCarryoverIsDiscarded() { + // Loop's first records for a new pod still carry the previous pod's final volume. + let carryover = resolve(reservoir: 9.9, cache: PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: nil)) + #expect(carryover.state == .units(9.9)) + #expect(carryover.cache?.pumpID == pump) + + let laterOnTheSamePod = resolve(cache: carryover.cache, at: now.addingTimeInterval(31 * 60)) + #expect(laterOnTheSamePod.state == .aboveReportingLimit) + #expect(laterOnTheSamePod.cache?.pumpID == pump) + #expect(laterOnTheSamePod.cache?.reading == nil) + } + + @Test("a pod change drops the previous pod's reading") + func podChangeDropsReading() { + let previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 9.9, date: now.addingTimeInterval(-5 * 60))) + let result = resolve(cache: previousPod) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache?.reading == nil) + #expect(result.cache?.pumpSince == now) + } + + @Test("an Omnipod that has never reported a volume reads as 50+") + func omnipodWithoutReading() { + #expect(resolve().state == .aboveReportingLimit) + #expect(resolve(manufacturer: "Insulet", model: "Dash").state == .aboveReportingLimit) + #expect(resolve(manufacturer: nil, model: "Omnipod").state == .aboveReportingLimit) + } + + @Test("a pump that reports its volume gives no number when the field is missing") + func otherPumpWithoutReading() { + let result = resolve(manufacturer: "Medtronic", model: "723") + #expect(result.state == .unknown) + #expect(result.cache?.pumpID == pump) + #expect(result.cache?.reading == nil) + } + + @Test("an uploader that names no pump resolves from the record alone") + func unidentifiedPump() { + // Trio and iAPS name no pump, so there is nothing to tie a reading to. + let withVolume = resolve(reservoir: 18, pumpID: nil, manufacturer: nil, model: nil) + #expect(withVolume.state == .units(18)) + #expect(withVolume.cache == nil) + + let withoutVolume = resolve(pumpID: nil, manufacturer: nil, model: nil) + #expect(withoutVolume.state == .aboveReportingLimit) + #expect(withoutVolume.cache == nil) + } + + @Test("a pump with no pod paired is not an identity to cache against") + func unknownPumpID() { + let result = resolve(pumpID: "Unknown", cache: cache(units: 9.9, readingAge: 5 * 60)) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache == nil) + } + + @Test("a settled pump stays unknown for as long as it reports nothing") + func settledUnknownPersists() { + var result = resolve(cache: cache(units: 9.9, readingAge: 31 * 60)) + #expect(result.state == .unknown) + result = resolve(cache: result.cache, at: now.addingTimeInterval(3 * 60 * 60)) + #expect(result.state == .unknown) + } + + @Test("a settled pump recovers as soon as it reports again") + func settledUnknownRecovers() { + let stale = resolve(cache: cache(units: 9.9, readingAge: 31 * 60)) + let reported = resolve(reservoir: 8.4, cache: stale.cache, at: now.addingTimeInterval(5 * 60)) + #expect(reported.state == .units(8.4)) + #expect(reported.cache?.reading?.units == 8.4) + } + + @Test("the cache survives a round trip through storage") + func cacheRoundTrips() throws { + let original = cache(units: 12.5, readingAge: 5 * 60) + let decoded = try JSONDecoder().decode(PumpReservoirCache.self, from: JSONEncoder().encode(original)) + #expect(decoded == original) + } +}