// LoopFollow // BackgroundAlertManager.swift import Foundation import UserNotifications /// Enum representing different background alert durations. enum BackgroundAlertDuration: TimeInterval, CaseIterable { case sixMinutes = 360 // 6 minutes in seconds case twelveMinutes = 720 // 12 minutes in seconds case eighteenMinutes = 1080 // 18 minutes in seconds } /// Unique identifiers for each background alert, scoped to the current bundle /// so multiple LoopFollow instances don't interfere with each other's notifications. enum BackgroundAlertIdentifier: CaseIterable { case sixMin case twelveMin case eighteenMin private static let prefix = Bundle.main.bundleIdentifier ?? "loopfollow" var rawValue: String { switch self { case .sixMin: "\(Self.prefix).background.alert.6min" case .twelveMin: "\(Self.prefix).background.alert.12min" case .eighteenMin: "\(Self.prefix).background.alert.18min" } } static let categoryIdentifier = "\(prefix).background.alert" } class BackgroundAlertManager { static let shared = BackgroundAlertManager() private init() {} /// Flag indicating whether background alerts are currently scheduled. private var isAlertScheduled: Bool = false /// Title prefix for all background refresh notifications. private let notificationTitlePrefix = String(localized: "LoopFollow Background Refresh") /// Timestamp of the last scheduled background alert. private var lastScheduleDate: Date? /// Start scheduling background alerts. func startBackgroundAlert() { isAlertScheduled = true // Force execution to bypass throttle when starting scheduleBackgroundAlert(force: true) } /// Stop all scheduled background alerts. func stopBackgroundAlert() { isAlertScheduled = false removeDeliveredNotifications() cancelBackgroundAlerts() } /// (Re)schedule all background alerts based on predefined durations. /// - Parameter force: When true, the scheduling is executed regardless of throttle constraints. 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 } removeDeliveredNotifications() let isBluetoothActive = Storage.shared.backgroundRefreshType.value.isBluetooth // Only query BLEManager for a Bluetooth mode — touching it otherwise would // initialize CoreBluetooth and trigger the permission prompt for users // (e.g. Silent Tune) who never opted into Bluetooth. let expectedHeartbeat = isBluetoothActive ? BLEManager.shared.expectedHeartbeatInterval() : nil // Define alerts let alerts: [BackgroundAlert] = [ BackgroundAlert( identifier: BackgroundAlertIdentifier.sixMin.rawValue, timeInterval: BackgroundAlertDuration.sixMinutes.rawValue, body: isBluetoothActive ? String(localized: "App inactive for 6 minutes. Verify Bluetooth connectivity.") : String(localized: "App inactive for 6 minutes. Open to resume.") ), BackgroundAlert( identifier: BackgroundAlertIdentifier.twelveMin.rawValue, timeInterval: BackgroundAlertDuration.twelveMinutes.rawValue, body: isBluetoothActive ? String(localized: "App inactive for 12 minutes. Verify Bluetooth connectivity.") : String(localized: "App inactive for 12 minutes. Open to resume.") ), BackgroundAlert( identifier: BackgroundAlertIdentifier.eighteenMin.rawValue, timeInterval: BackgroundAlertDuration.eighteenMinutes.rawValue, body: isBluetoothActive ? String(localized: "App inactive for 18 minutes. Verify Bluetooth connectivity.") : String(localized: "App inactive for 18 minutes. Open to resume.") ), ] for alert in alerts { // Skip if the expected heartbeat interval matches or exceeds 1.2x the alert time interval if let heartbeat = expectedHeartbeat, heartbeat * 1.2 >= alert.timeInterval { continue } let content = createNotificationContent(for: notificationTitlePrefix, body: alert.body) let trigger = UNTimeIntervalNotificationTrigger(timeInterval: alert.timeInterval, repeats: false) let request = UNNotificationRequest(identifier: alert.identifier, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { error in if let error = error { LogManager.shared.log(category: .general, message: "Error scheduling \(alert.timeInterval / 60)-minute background alert: \(error)") } } } } /// Create notification content with a given title and body. /// - Parameters: /// - title: The title of the notification. /// - body: The body text of the notification. /// - Returns: Configured `UNMutableNotificationContent` object. private func createNotificationContent(for title: String, body: String) -> UNMutableNotificationContent { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .defaultCritical content.categoryIdentifier = BackgroundAlertIdentifier.categoryIdentifier return content } /// Cancel all scheduled background alerts. private func cancelBackgroundAlerts() { let identifiers = BackgroundAlertIdentifier.allCases.map { $0.rawValue } UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers) } /// Remove all delivered notifications. private func removeDeliveredNotifications() { let identifiers = BackgroundAlertIdentifier.allCases.map { $0.rawValue } UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) } } /// Struct representing a single background alert. struct BackgroundAlert { let identifier: String let timeInterval: TimeInterval let body: String }