BackgroundAlertManager.swift 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. // LoopFollow
  2. // BackgroundAlertManager.swift
  3. import Foundation
  4. import UserNotifications
  5. /// Enum representing different background alert durations.
  6. enum BackgroundAlertDuration: TimeInterval, CaseIterable {
  7. case sixMinutes = 360 // 6 minutes in seconds
  8. case twelveMinutes = 720 // 12 minutes in seconds
  9. case eighteenMinutes = 1080 // 18 minutes in seconds
  10. }
  11. /// Unique identifiers for each background alert, scoped to the current bundle
  12. /// so multiple LoopFollow instances don't interfere with each other's notifications.
  13. enum BackgroundAlertIdentifier: CaseIterable {
  14. case sixMin
  15. case twelveMin
  16. case eighteenMin
  17. private static let prefix = Bundle.main.bundleIdentifier ?? "loopfollow"
  18. var rawValue: String {
  19. switch self {
  20. case .sixMin: "\(Self.prefix).background.alert.6min"
  21. case .twelveMin: "\(Self.prefix).background.alert.12min"
  22. case .eighteenMin: "\(Self.prefix).background.alert.18min"
  23. }
  24. }
  25. static let categoryIdentifier = "\(prefix).background.alert"
  26. }
  27. class BackgroundAlertManager {
  28. static let shared = BackgroundAlertManager()
  29. private init() {}
  30. /// Flag indicating whether background alerts are currently scheduled.
  31. private var isAlertScheduled: Bool = false
  32. /// Title prefix for all background refresh notifications.
  33. private let notificationTitlePrefix = "LoopFollow Background Refresh"
  34. /// Timestamp of the last scheduled background alert.
  35. private var lastScheduleDate: Date?
  36. /// Start scheduling background alerts.
  37. func startBackgroundAlert() {
  38. isAlertScheduled = true
  39. // Force execution to bypass throttle when starting
  40. scheduleBackgroundAlert(force: true)
  41. }
  42. /// Stop all scheduled background alerts.
  43. func stopBackgroundAlert() {
  44. isAlertScheduled = false
  45. removeDeliveredNotifications()
  46. cancelBackgroundAlerts()
  47. }
  48. /// (Re)schedule all background alerts based on predefined durations.
  49. /// - Parameter force: When true, the scheduling is executed regardless of throttle constraints.
  50. func scheduleBackgroundAlert(force: Bool = false) {
  51. guard isAlertScheduled, Storage.shared.backgroundRefreshType.value != .none else { return }
  52. // Throttle execution if not forced: only run once every 10 seconds.
  53. if !force {
  54. let now = Date()
  55. if let lastDate = lastScheduleDate, now.timeIntervalSince(lastDate) < 10 {
  56. return
  57. }
  58. lastScheduleDate = now
  59. }
  60. removeDeliveredNotifications()
  61. let isBluetoothActive = Storage.shared.backgroundRefreshType.value.isBluetooth
  62. let expectedHeartbeat = BLEManager.shared.expectedHeartbeatInterval()
  63. // Define alerts
  64. let alerts: [BackgroundAlert] = [
  65. BackgroundAlert(
  66. identifier: BackgroundAlertIdentifier.sixMin.rawValue,
  67. timeInterval: BackgroundAlertDuration.sixMinutes.rawValue,
  68. body: isBluetoothActive
  69. ? "App inactive for 6 minutes. Verify Bluetooth connectivity."
  70. : "App inactive for 6 minutes. Open to resume."
  71. ),
  72. BackgroundAlert(
  73. identifier: BackgroundAlertIdentifier.twelveMin.rawValue,
  74. timeInterval: BackgroundAlertDuration.twelveMinutes.rawValue,
  75. body: isBluetoothActive
  76. ? "App inactive for 12 minutes. Verify Bluetooth connectivity."
  77. : "App inactive for 12 minutes. Open to resume."
  78. ),
  79. BackgroundAlert(
  80. identifier: BackgroundAlertIdentifier.eighteenMin.rawValue,
  81. timeInterval: BackgroundAlertDuration.eighteenMinutes.rawValue,
  82. body: isBluetoothActive
  83. ? "App inactive for 18 minutes. Verify Bluetooth connectivity."
  84. : "App inactive for 18 minutes. Open to resume."
  85. ),
  86. ]
  87. for alert in alerts {
  88. // Skip if the expected heartbeat interval matches or exceeds 1.2x the alert time interval
  89. if let heartbeat = expectedHeartbeat, heartbeat * 1.2 >= alert.timeInterval {
  90. continue
  91. }
  92. let content = createNotificationContent(for: notificationTitlePrefix, body: alert.body)
  93. let trigger = UNTimeIntervalNotificationTrigger(timeInterval: alert.timeInterval, repeats: false)
  94. let request = UNNotificationRequest(identifier: alert.identifier, content: content, trigger: trigger)
  95. UNUserNotificationCenter.current().add(request) { error in
  96. if let error = error {
  97. LogManager.shared.log(category: .general, message: "Error scheduling \(alert.timeInterval / 60)-minute background alert: \(error)")
  98. }
  99. }
  100. }
  101. }
  102. /// Create notification content with a given title and body.
  103. /// - Parameters:
  104. /// - title: The title of the notification.
  105. /// - body: The body text of the notification.
  106. /// - Returns: Configured `UNMutableNotificationContent` object.
  107. private func createNotificationContent(for title: String, body: String) -> UNMutableNotificationContent {
  108. let content = UNMutableNotificationContent()
  109. content.title = title
  110. content.body = body
  111. content.sound = .defaultCritical
  112. content.categoryIdentifier = BackgroundAlertIdentifier.categoryIdentifier
  113. return content
  114. }
  115. /// Cancel all scheduled background alerts.
  116. private func cancelBackgroundAlerts() {
  117. let identifiers = BackgroundAlertIdentifier.allCases.map { $0.rawValue }
  118. UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers)
  119. }
  120. /// Remove all delivered notifications.
  121. private func removeDeliveredNotifications() {
  122. let identifiers = BackgroundAlertIdentifier.allCases.map { $0.rawValue }
  123. UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
  124. }
  125. }
  126. /// Struct representing a single background alert.
  127. struct BackgroundAlert {
  128. let identifier: String
  129. let timeInterval: TimeInterval
  130. let body: String
  131. }