BackgroundAlertManager.swift 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  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 = String(localized: "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. // Only query BLEManager for a Bluetooth mode — touching it otherwise would
  63. // initialize CoreBluetooth and trigger the permission prompt for users
  64. // (e.g. Silent Tune) who never opted into Bluetooth.
  65. let expectedHeartbeat = isBluetoothActive ? BLEManager.shared.expectedHeartbeatInterval() : nil
  66. // Define alerts
  67. let alerts: [BackgroundAlert] = [
  68. BackgroundAlert(
  69. identifier: BackgroundAlertIdentifier.sixMin.rawValue,
  70. timeInterval: BackgroundAlertDuration.sixMinutes.rawValue,
  71. body: isBluetoothActive
  72. ? String(localized: "App inactive for 6 minutes. Verify Bluetooth connectivity.")
  73. : String(localized: "App inactive for 6 minutes. Open to resume.")
  74. ),
  75. BackgroundAlert(
  76. identifier: BackgroundAlertIdentifier.twelveMin.rawValue,
  77. timeInterval: BackgroundAlertDuration.twelveMinutes.rawValue,
  78. body: isBluetoothActive
  79. ? String(localized: "App inactive for 12 minutes. Verify Bluetooth connectivity.")
  80. : String(localized: "App inactive for 12 minutes. Open to resume.")
  81. ),
  82. BackgroundAlert(
  83. identifier: BackgroundAlertIdentifier.eighteenMin.rawValue,
  84. timeInterval: BackgroundAlertDuration.eighteenMinutes.rawValue,
  85. body: isBluetoothActive
  86. ? String(localized: "App inactive for 18 minutes. Verify Bluetooth connectivity.")
  87. : String(localized: "App inactive for 18 minutes. Open to resume.")
  88. ),
  89. ]
  90. for alert in alerts {
  91. // Skip if the expected heartbeat interval matches or exceeds 1.2x the alert time interval
  92. if let heartbeat = expectedHeartbeat, heartbeat * 1.2 >= alert.timeInterval {
  93. continue
  94. }
  95. let content = createNotificationContent(for: notificationTitlePrefix, body: alert.body)
  96. let trigger = UNTimeIntervalNotificationTrigger(timeInterval: alert.timeInterval, repeats: false)
  97. let request = UNNotificationRequest(identifier: alert.identifier, content: content, trigger: trigger)
  98. UNUserNotificationCenter.current().add(request) { error in
  99. if let error = error {
  100. LogManager.shared.log(category: .general, message: "Error scheduling \(alert.timeInterval / 60)-minute background alert: \(error)")
  101. }
  102. }
  103. }
  104. }
  105. /// Create notification content with a given title and body.
  106. /// - Parameters:
  107. /// - title: The title of the notification.
  108. /// - body: The body text of the notification.
  109. /// - Returns: Configured `UNMutableNotificationContent` object.
  110. private func createNotificationContent(for title: String, body: String) -> UNMutableNotificationContent {
  111. let content = UNMutableNotificationContent()
  112. content.title = title
  113. content.body = body
  114. content.sound = .defaultCritical
  115. content.categoryIdentifier = BackgroundAlertIdentifier.categoryIdentifier
  116. return content
  117. }
  118. /// Cancel all scheduled background alerts.
  119. private func cancelBackgroundAlerts() {
  120. let identifiers = BackgroundAlertIdentifier.allCases.map { $0.rawValue }
  121. UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers)
  122. }
  123. /// Remove all delivered notifications.
  124. private func removeDeliveredNotifications() {
  125. let identifiers = BackgroundAlertIdentifier.allCases.map { $0.rawValue }
  126. UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
  127. }
  128. }
  129. /// Struct representing a single background alert.
  130. struct BackgroundAlert {
  131. let identifier: String
  132. let timeInterval: TimeInterval
  133. let body: String
  134. }