// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import BackgroundTasks import Foundation public import SignalServiceKit public enum BGProcessingTaskStartCondition: Equatable { /// Don't schedule the BGProcessingTask at all. case never /// Tell the OS to run the BGProcessingTask as soon as it can. case asSoonAsPossible /// Provide the date to ``BGProcessingTaskRequest.earliestBeginDate`` case after(Date) } /// Base protocol for classes that manage running a BGProcessingTask. /// Implement the protocol methods and let the extension methods handle /// the standardized registration and running of the BGProcessingTask. public protocol BGProcessingTaskRunner { /// MUST be defined in Info.plist under the "Permitted background task scheduler identifiers" key. static var taskIdentifier: String { get } /// Prefix for any logs related to the BGProcessingTask itself. static var logPrefix: String? { get } /// If true, informs iOS that we require a network connection to perform the task. static var requiresNetworkConnectivity: Bool { get } /// If true, informs iOS that we require external power to perform the task; typically /// you want this if CPU utilization will be very high, as without power iOS is much /// more aggressive at terminating the process at high CPU utilization. static var requiresExternalPower: Bool { get } /// See ``BGProcessingTaskStartCondition`` documentation. func startCondition() -> BGProcessingTaskStartCondition /// Run the operation. /// /// Conformers should detect Task cancellation to gracefully handle /// BGProcessingTask termination, and they should still make incremental /// progress when that happens. func run() async throws } extension BGProcessingTaskRunner where Self: Sendable { private var logger: PrefixedLogger { PrefixedLogger(prefix: Self.logPrefix ?? "", suffix: "[\(Self.taskIdentifier)]") } /// Must be called synchronously within appDidFinishLaunching for every BGProcessingTask /// regardless of whether we eventually schedule and run it or not. /// Call `scheduleBGProcessingTaskIfNeeded` to actually schedule the task /// to run; that will simply not schedule any unecessary tasks. public func registerBGProcessingTask(appReadiness: any AppReadiness) { // We register the handler _regardless_ of whether we schedule the task. // Scheduling is what makes it actually run; apple docs say apps must register // handlers for every task identifier declared in info.plist. // https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler/register(fortaskwithidentifier:using:launchhandler:) // (Apple's WWDC sample app also unconditionally registers and then conditionally schedules.) BGTaskScheduler.shared.register( forTaskWithIdentifier: Self.taskIdentifier, using: nil, launchHandler: { bgTask in let task = Task { await withCheckedContinuation { continuation in appReadiness.runNowOrWhenAppDidBecomeReadyAsync { continuation.resume() } } do { logger.info("Starting...") try await self.run() logger.info("Success!") await scheduleBGProcessingTaskIfNeeded() logger.info("Re-scheduled.") bgTask.setTaskCompleted(success: true) } catch is CancellationError { // Re-schedule so we try to run it again. We do this unconditionally // because tasks we cancel haven't finished and have more work to do. await self.scheduleBGProcessingTask(startCondition: .asSoonAsPossible) // Apple WWDC talk specifies tasks must be completed even if the expiration // handler is called. bgTask.setTaskCompleted(success: false) } catch { logger.warn("Failed with error. \(error)") bgTask.setTaskCompleted(success: false) } } bgTask.expirationHandler = { logger.warn("Canceling due to expiration.") // WWDC talk says we get a grace period after the expiration handler // is called; use it to cleanly cancel the task. task.cancel() } }, ) } public func scheduleBGProcessingTaskIfNeeded() async { let startCondition = self.startCondition() guard startCondition != .never else { return } await self.scheduleBGProcessingTask(startCondition: startCondition) } private func scheduleBGProcessingTask(startCondition: BGProcessingTaskStartCondition) async { // Dispatching off the main thread is recommended by apple in their WWDC talk // as BGTaskScheduler.submit can take time and block the main thread. let request = BGProcessingTaskRequest(identifier: Self.taskIdentifier) switch startCondition { case .never: return case .asSoonAsPossible: break case .after(let date): request.earliestBeginDate = date } request.requiresNetworkConnectivity = Self.requiresNetworkConnectivity request.requiresExternalPower = Self.requiresExternalPower do { try BGTaskScheduler.shared.submit(request) logger.info("Scheduled.") } catch BGTaskScheduler.Error.notPermitted { logger.warn("Skipping: notPermitted") } catch BGTaskScheduler.Error.tooManyPendingTaskRequests { // Note: if we reschedule the same identifier, we don't get this error. logger.error("Skipping: tooManyPendingTaskRequests") } catch BGTaskScheduler.Error.unavailable { logger.warn("Skipping: unavailable (in a simulator?)") } catch { logger.error("Skipping: \(error)") } } /// Helper to run a migration in multiple batches. /// /// - Parameter willBegin: Called before the first call to `runNextBatch`. /// /// - Parameter runNextBatch: Run the next batch of migration, returning /// true if the entire migration is completed. func runInBatches( willBegin: () -> Void, runNextBatch: () async -> Bool, ) async throws(CancellationError) { logger.info("Starting.") // Note: we _could_ check the minimum date from ``BGProcessingTaskStartCondition.after``, // but we rely on the OS to run us at the right time rather than risk clock skew // funkiness breaking things here. guard startCondition() != .never else { logger.info("Finished early because we don't need to run.") return } willBegin() var batchCount = 0 var didFinish = false while !didFinish { if Task.isCancelled { logger.warn("Canceled after \(batchCount) batches") throw CancellationError() } didFinish = await runNextBatch() batchCount += 1 } logger.info("Finished after \(batchCount) batches") } func runWithChatConnection( backgroundMessageFetcherFactory: BackgroundMessageFetcherFactory, operation: () async throws -> T, ) async throws -> T { let backgroundMessageFetcher = backgroundMessageFetcherFactory.buildFetcher() // We want a chat connection, and if we get a chat connection, we're also // going to need to deal with message processing. await backgroundMessageFetcher.start() // Run the operation that matters. This may throw an error or be canceled. let result = await Result(catching: { try await operation() }) // We don't care about the result of this -- we just want to try and wait // for any incoming messages so that we can tear down gracefully. try? await backgroundMessageFetcher.waitForFetchingProcessingAndSideEffects() await backgroundMessageFetcher.stopAndWaitBeforeSuspending() // Pass the result of operation() to the caller. return try result.get() } }