NotLoopingMonitor.swift 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
  1. import Combine
  2. import Foundation
  3. import LoopKit
  4. import Swinject
  5. /// Issues a `.notLooping` alarm if no successful loop completes within the
  6. /// configured grace period (default 20 minutes). On every successful loop
  7. /// the pending alarm is retracted and a fresh delayed alarm is rescheduled
  8. /// — using `Alert.Trigger.delayed(interval:)` so it fires via UN even if
  9. /// the app is suspended.
  10. ///
  11. /// Replaces the legacy `scheduleMissingLoopNotifiactions` direct-UN path
  12. /// in `BaseUserNotificationsManager`. The alert now flows through
  13. /// `TrioAlertManager` and inherits tier config from Pump Alarms
  14. /// (Critical tier by default).
  15. final class NotLoopingMonitor: Injectable {
  16. @Injected() private var apsManager: APSManager!
  17. @Injected() private var trioAlertManager: TrioAlertManager!
  18. @Injected() private var settingsManager: SettingsManager!
  19. /// Minutes of staleness before the alarm fires. Mirrors the legacy
  20. /// `firstInterval` (20 min) — the second 40-min reminder is dropped;
  21. /// retract-on-loop semantics make it redundant.
  22. private static let gracePeriodMinutes: Int = 20
  23. private static let alertID = Alert.Identifier(
  24. managerIdentifier: "trio.aps",
  25. alertIdentifier: "loop.notActive"
  26. )
  27. private var subscriptions = Set<AnyCancellable>()
  28. init(resolver: Resolver) {
  29. injectServices(resolver)
  30. subscribe(to: apsManager.lastLoopDateSubject.eraseToAnyPublisher())
  31. }
  32. /// Publisher-only seam for tests: assigns the alert manager directly and
  33. /// subscribes to a supplied loop-date publisher, avoiding the need to stub
  34. /// the full `APSManager` protocol.
  35. init(loopDates: AnyPublisher<Date, Never>, trioAlertManager: TrioAlertManager) {
  36. self.trioAlertManager = trioAlertManager
  37. subscribe(to: loopDates)
  38. }
  39. private func subscribe(to loopDates: AnyPublisher<Date, Never>) {
  40. loopDates
  41. .sink { [weak self] _ in self?.rescheduleAlarm() }
  42. .store(in: &subscriptions)
  43. }
  44. private func rescheduleAlarm() {
  45. // Retract first — clears pending UN, modal timer, and throttler so the
  46. // next issueAlert isn't blocked by 5-min duplicate suppression.
  47. trioAlertManager.retractAlert(identifier: Self.alertID)
  48. // Skip when Trio isn't expected to be auto-enacting: open loop, an
  49. // active manual temp basal, or a suspended pump. In all three the
  50. // user has implicitly told Trio "don't loop right now"; a "Not
  51. // Looping" alarm would just be noise. nil injection (publisher-only
  52. // test seam) passes so the existing tests still exercise the
  53. // retract/reschedule plumbing.
  54. guard settingsManager?.settings.closedLoop != false,
  55. apsManager?.isManualTempBasal != true,
  56. apsManager?.isSuspended != true
  57. else { return }
  58. let content = Alert.Content(
  59. title: String(localized: "Trio Not Active"),
  60. body: String(
  61. format: String(localized: "Last loop was more than %d min ago"),
  62. Self.gracePeriodMinutes
  63. ),
  64. acknowledgeActionButtonLabel: String(localized: "OK")
  65. )
  66. let alert = Alert(
  67. identifier: Self.alertID,
  68. foregroundContent: content,
  69. backgroundContent: content,
  70. trigger: .delayed(interval: TimeInterval(Self.gracePeriodMinutes * 60)),
  71. interruptionLevel: .critical,
  72. sound: .sound(name: "honk.caf")
  73. )
  74. trioAlertManager.issueAlert(alert)
  75. }
  76. }